refactor(workspace): migrate from per-folder windows to single-window workspace

Replace the legacy folder + welcome routes with a unified /workspace route
that hosts all folders, conversations, tabs, and terminals in one window.

- Persist opened tabs to the database (opened_tabs entity + migration)
  so tab layout survives restarts and deep-link bootstrap restores state
- Replace FolderContext shim with AppWorkspaceProvider, ActiveFolderProvider,
  and TabProvider; expose both opened (folders) and full DB (allFolders)
  listings via list_all_folder_details
- Return conversations across all non-deleted folders from list_all when
  no folder filter is given, so the sidebar can show every folder's history
- Add ConversationContextBar above the chat input with folder picker
  (auto-opens unopened folders on select), branch picker, and commit /
  push / merge / stash entries to restore BranchDropdown functionality
- Rework sidebar with stats header, search, flat / folder-grouped view
  modes (localStorage-persisted), reveal-in-sidebar event subscriber,
  and per-folder context menu (focus, close tabs, remove from workspace);
  indent conversations under folder headers in grouped mode
- Gate terminal creation on active folder and show folder context
- Remove deprecated BranchDropdown, FolderNameDropdown, welcome route,
  and per-folder window commands
- Localize all new strings across 10 locales
This commit is contained in:
xintaofei
2026-04-20 21:22:36 +08:00
parent 10801bf393
commit d9323d7399
89 changed files with 3701 additions and 2743 deletions

View File

@@ -13,7 +13,7 @@ import ignore from "ignore"
import { Check, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useTabContext } from "@/contexts/tab-context"
import { useTerminalContext } from "@/contexts/terminal-context"
@@ -797,7 +797,7 @@ export function FileTreeTab() {
const t = useTranslations("Folder.fileTreeTab")
const tCommon = useTranslations("Folder.common")
const { pendingRevealPath, consumePendingRevealPath } = useAuxPanelContext()
const { folder } = useFolderContext()
const { activeFolder: folder } = useActiveFolder()
const { tabs, activeTabId } = useTabContext()
const { createTerminalInDirectory } = useTerminalContext()
const {

View File

@@ -34,7 +34,7 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { Skeleton } from "@/components/ui/skeleton"
import { useFolderContext } from "@/contexts/folder-context"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
@@ -378,7 +378,7 @@ export function GitChangesTab() {
const t = useTranslations("Folder.gitChangesTab")
const tCommon = useTranslations("Folder.common")
const tFileTree = useTranslations("Folder.fileTreeTab")
const { folder } = useFolderContext()
const { activeFolder: folder } = useActiveFolder()
const { tabs, activeTabId } = useTabContext()
const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext()
const workspaceState = useWorkspaceStateStore(folder?.path ?? null)

View File

@@ -77,7 +77,7 @@ import {
} from "@/components/ui/collapsible"
import { Skeleton } from "@/components/ui/skeleton"
import { subscribe } from "@/lib/platform"
import { useFolderContext } from "@/contexts/folder-context"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import {
@@ -691,7 +691,7 @@ function BranchSelector({
export function GitLogTab() {
const t = useTranslations("Folder.gitLogTab")
const tCommon = useTranslations("Folder.common")
const { folder } = useFolderContext()
const { activeFolder: folder } = useActiveFolder()
const { openCommitDiff, openFilePreview } = useWorkspaceContext()
const workspaceState = useWorkspaceStateStore(folder?.path ?? null)
const isGitRepo = workspaceState.isGitRepo

View File

@@ -3,7 +3,7 @@
import { useMemo, useState } from "react"
import { ChevronRight, FileIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTabContext } from "@/contexts/tab-context"
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
@@ -58,7 +58,7 @@ function SessionFilesContent({ conversationId }: { conversationId: number }) {
const { loading } = useConversationDetail(conversationId)
const { getTimelineTurns } = useConversationRuntime()
const { openSessionFileDiff } = useWorkspaceContext()
const { folder } = useFolderContext()
const { activeFolder: folder } = useActiveFolder()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const turns = useMemo(

View File

@@ -1,977 +0,0 @@
"use client"
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
const emitEvent = async (event: string, payload?: unknown) => {
try {
const { emit } = await import("@tauri-apps/api/event")
await emit(event, payload)
} catch {
/* not in Tauri */
}
}
import { openFileDialog, subscribe } from "@/lib/platform"
import {
GitBranch,
ChevronDown,
ChevronRight,
ArrowDownToLine,
Upload,
GitBranchPlus,
GitCommitHorizontal,
Archive,
ArchiveRestore,
GitFork,
GitMerge,
GitPullRequestArrow,
Trash2,
Loader2,
RefreshCw,
FolderGit2,
FolderOpen,
ArrowLeftRight,
Globe,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
gitInit,
gitPull,
gitFetch,
gitNewBranch,
gitWorktreeAdd,
gitCheckout,
gitListAllBranches,
gitMerge,
gitRebase,
gitDeleteBranch,
gitDeleteRemoteBranch,
openFolderWindow,
openCommitWindow,
setFolderParentBranch,
openStashWindow,
openPushWindow,
} from "@/lib/api"
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
import { ConflictDialog } from "@/components/layout/conflict-dialog"
import { StashDialog } from "@/components/layout/stash-dialog"
import { toErrorMessage } from "@/lib/app-error"
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context"
import { useTaskContext } from "@/contexts/task-context"
import { useAlertContext } from "@/contexts/alert-context"
import { useGitCredential } from "@/contexts/git-credential-context"
interface BranchDropdownProps {
branch: string | null
parentBranch: string | null
onBranchChange: () => void
}
type ConfirmAction = {
type: "merge" | "rebase" | "delete" | "forceDelete" | "deleteRemote"
branchName: string
}
interface GitCommitSucceededEventPayload {
folder_id: number
committed_files: number
}
interface GitPushSucceededEventPayload {
folder_id: number
pushed_commits: number
upstream_set: boolean
}
export function BranchDropdown({
branch,
parentBranch,
onBranchChange,
}: BranchDropdownProps) {
const t = useTranslations("Folder.branchDropdown")
const tCommon = useTranslations("Folder.common")
const { folder } = useFolderContext()
const folderPath = folder?.path ?? ""
const { addTask, updateTask, removeTask } = useTaskContext()
const { pushAlert } = useAlertContext()
const { withCredentialRetry } = useGitCredential()
const [branchList, setBranchList] = useState<GitBranchList>({
local: [],
remote: [],
worktree_branches: [],
})
const [newBranchOpen, setNewBranchOpen] = useState(false)
const [newBranchName, setNewBranchName] = useState("")
const [loading, setLoading] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const [branchLoading, setBranchLoading] = useState(false)
const [localOpen, setLocalOpen] = useState(false)
const [remoteOpen, setRemoteOpen] = useState(false)
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
const [worktreeOpen, setWorktreeOpen] = useState(false)
const [worktreeBranchName, setWorktreeBranchName] = useState("")
const [worktreePath, setWorktreePath] = useState("")
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
const [stashDialogOpen, setStashDialogOpen] = useState(false)
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
() => new Set(branchList.worktree_branches),
[branchList.worktree_branches]
)
const groupedRemoteBranches = useMemo(() => {
const groups: Record<string, string[]> = {}
for (const b of branchList.remote) {
const slashIndex = b.indexOf("/")
const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin"
if (!groups[remoteName]) groups[remoteName] = []
groups[remoteName].push(b)
}
return groups
}, [branchList.remote])
const remoteNames = Object.keys(groupedRemoteBranches)
const hasMultipleRemotes = remoteNames.length > 1
useEffect(() => {
if (!folder) return
let unlisten: (() => void) | null = null
subscribe<GitCommitSucceededEventPayload>(
"folder://git-commit-succeeded",
(payload) => {
if (payload.folder_id !== folder.id) return
toast.success(t("toasts.commitCodeCompleted"), {
description: t("toasts.committedFiles", {
count: payload.committed_files,
}),
})
onBranchChange()
}
)
.then((fn) => {
unlisten = fn
})
.catch((err) => {
console.error("[BranchDropdown] failed to listen commit event:", err)
})
return () => {
unlisten?.()
}
}, [folder, onBranchChange, t])
useEffect(() => {
if (!folder) return
let unlisten: (() => void) | null = null
subscribe<GitPushSucceededEventPayload>(
"folder://git-push-succeeded",
(payload) => {
if (payload.folder_id !== folder.id) return
const { pushed_commits, upstream_set } = payload
let description: string
if (upstream_set) {
description =
pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", { count: pushed_commits })
} else if (pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", { count: pushed_commits })
}
toast.success(t("toasts.pushCodeCompleted"), { description })
onBranchChange()
}
)
.then((fn) => {
unlisten = fn
})
.catch((err) => {
console.error("[BranchDropdown] failed to listen push event:", err)
})
return () => {
unlisten?.()
}
}, [folder, onBranchChange, t])
async function runGitTask<T>(
label: string,
action: () => Promise<T>,
getSuccessDescription?: (result: T) => string | false | undefined,
onError?: (errorMsg: string) => boolean
) {
const taskId = `git-${++taskSeq.current}-${Date.now()}`
setLoading(true)
addTask(taskId, label)
updateTask(taskId, { status: "running" })
try {
const result = await action()
const successDescription = getSuccessDescription?.(result)
updateTask(taskId, { status: "completed" })
onBranchChange()
void emitEvent("folder://git-branch-changed", {
folder_id: folder?.id,
})
if (successDescription !== false) {
toast.success(
t("toasts.taskCompleted", { label }),
successDescription
? {
description: successDescription,
}
: undefined
)
}
} catch (err) {
removeTask(taskId)
const errorMsg = toErrorMessage(err)
if (onError?.(errorMsg)) {
return
}
const errorTitle = t("toasts.taskFailed", { label })
pushAlert("error", errorTitle, errorMsg)
toast.error(errorTitle, { description: errorMsg })
} finally {
setLoading(false)
}
}
const loadAllBranches = useCallback(async () => {
setBranchLoading(true)
try {
const list = await gitListAllBranches(folderPath)
setBranchList(list)
} catch {
setBranchList({ local: [], remote: [], worktree_branches: [] })
} finally {
setBranchLoading(false)
}
}, [folderPath])
function handleDropdownOpenChange(open: boolean) {
setDropdownOpen(open)
if (open && branch !== null) {
loadAllBranches()
}
if (!open) {
setLocalOpen(false)
setRemoteOpen(false)
}
}
async function handleNewBranch() {
const name = newBranchName.trim()
if (!name) return
setNewBranchOpen(false)
setNewBranchName("")
await runGitTask(t("tasks.newBranch", { name }), () =>
gitNewBranch(folderPath, name)
)
}
function handleOpenWorktreeDialog() {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
let random = ""
for (let i = 0; i < 6; i++) {
random += chars[Math.floor(Math.random() * chars.length)]
}
const folderName = folderPath.split("/").filter(Boolean).pop() ?? "project"
const currentBranch = branch ?? "main"
const defaultBranch = `cv-${currentBranch}-${random}`
const parentDir = folderPath.substring(0, folderPath.lastIndexOf("/"))
setWorktreeBranchName(defaultBranch)
setWorktreePath(`${parentDir}/${folderName}-${currentBranch}-${random}`)
setWorktreeOpen(true)
}
function handleWorktreeBranchChange(name: string) {
setWorktreeBranchName(name)
}
async function handleBrowseWorktreePath() {
const selected = await openFileDialog({ directory: true, multiple: false })
if (selected) {
setWorktreePath(Array.isArray(selected) ? selected[0] : selected)
}
}
async function handleNewWorktree() {
const name = worktreeBranchName.trim()
const wtPath = worktreePath.trim()
if (!name || !wtPath) return
setWorktreeOpen(false)
await runGitTask(t("tasks.newWorktree", { name }), async () => {
await gitWorktreeAdd(folderPath, name, wtPath)
await openFolderWindow(wtPath, { newWindow: true })
await setFolderParentBranch(wtPath, branch)
})
}
function handleMergeParent() {
if (!parentBranch) return
setConfirmAction({ type: "merge", branchName: parentBranch })
}
async function handleCheckout(branchName: string) {
setDropdownOpen(false)
await runGitTask(t("tasks.checkoutTo", { branchName }), () =>
gitCheckout(folderPath, branchName)
)
}
async function handleCheckoutRemote(remoteBranch: string) {
const localName = remoteBranch.replace(/^[^/]+\//, "")
setDropdownOpen(false)
await runGitTask(t("tasks.checkoutTo", { branchName: localName }), () =>
gitCheckout(folderPath, localName)
)
}
async function handleConfirm() {
if (!confirmAction) return
const { type, branchName } = confirmAction
setConfirmAction(null)
switch (type) {
case "merge":
await runGitTask(
t("tasks.mergeBranch", { branchName }),
() => gitMerge(folderPath, branchName),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
if (result.merged_commits === 0) {
return t("toasts.mergeNoNewCommits", { branchName })
}
return t("toasts.mergedCommits", { count: result.merged_commits })
}
)
break
case "rebase":
await runGitTask(
t("tasks.rebaseTo", { branchName }),
() => gitRebase(folderPath, branchName),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
return undefined
}
)
break
case "delete":
await runGitTask(
t("tasks.deleteBranch", { branchName }),
() => gitDeleteBranch(folderPath, branchName),
undefined,
(errorMsg) => {
if (/not fully merged/i.test(errorMsg)) {
setConfirmAction({ type: "forceDelete", branchName })
return true
}
return false
}
)
break
case "forceDelete":
await runGitTask(t("tasks.deleteBranch", { branchName }), () =>
gitDeleteBranch(folderPath, branchName, true)
)
break
case "deleteRemote": {
const idx = branchName.indexOf("/")
const remote = branchName.substring(0, idx)
const rb = branchName.substring(idx + 1)
await runGitTask(t("tasks.deleteRemoteBranch", { branchName }), () =>
withCredentialRetry(
(creds) => gitDeleteRemoteBranch(folderPath, remote, rb, creds),
{ folderPath }
)
)
break
}
}
}
function getConfirmTitle() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return t("confirm.mergeTitle")
case "rebase":
return t("confirm.rebaseTitle")
case "delete":
return t("confirm.deleteTitle")
case "forceDelete":
return t("confirm.forceDeleteTitle")
case "deleteRemote":
return t("confirm.deleteRemoteTitle")
}
}
function getConfirmDescription() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return t("confirm.mergeDescription", {
branchName: confirmAction.branchName,
currentBranch: branch ?? "-",
})
case "rebase":
return t("confirm.rebaseDescription", {
currentBranch: branch ?? "-",
branchName: confirmAction.branchName,
})
case "delete":
return t("confirm.deleteDescription", {
branchName: confirmAction.branchName,
})
case "forceDelete":
return t("confirm.forceDeleteDescription", {
branchName: confirmAction.branchName,
})
case "deleteRemote":
return t("confirm.deleteRemoteDescription", {
branchName: confirmAction.branchName,
})
}
}
function renderBranchItem(
b: string,
isRemote: boolean,
displayName?: string
) {
const label = displayName ?? b
const isCurrent = b === branch
const isTrackingCurrent =
isRemote && !!branch && b.replace(/^[^/]+\//, "") === branch
const isWorktree = worktreeBranchSet.has(
isRemote ? b.replace(/^[^/]+\//, "") : b
)
const BranchIcon = isWorktree ? FolderGit2 : GitBranch
if (isCurrent) {
return (
<div
key={b}
className="flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm opacity-50 select-none"
>
<BranchIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
<span className="ml-auto text-xs">{t("current")}</span>
</div>
)
}
return (
<DropdownMenuSub key={b}>
<DropdownMenuSubTrigger
className="hover:bg-accent hover:text-accent-foreground"
disabled={loading}
>
<BranchIcon className="h-3.5 w-3.5" />
{label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => {
if (isRemote) {
handleCheckoutRemote(b)
} else {
handleCheckout(b)
}
}}
>
<GitBranch className="h-3.5 w-3.5" />
{t("switchToBranch")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "merge", branchName: b })
}}
>
<GitMerge className="h-3.5 w-3.5" />
{t("mergeBranchIntoCurrent", {
branchName: b,
currentBranch: branch ?? "-",
})}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "rebase", branchName: b })
}}
>
<GitPullRequestArrow className="h-3.5 w-3.5" />
{t("rebaseCurrentToBranch", {
currentBranch: branch ?? "-",
branchName: b,
})}
</DropdownMenuItem>
{!isTrackingCurrent && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({
type: isRemote ? "deleteRemote" : "delete",
branchName: b,
})
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("deleteBranch")}
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
if (branch === null) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
<GitFork className="h-3 w-3 shrink-0" />
<span className="truncate">{t("versionControl")}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath))
}
>
<GitBranch className="h-3.5 w-3.5" />
{t("initGitRepo")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-sm tracking-tight hover:text-foreground/80 transition-colors outline-none cursor-default">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="truncate">{branch}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(
t("tasks.pullCode"),
() =>
withCredentialRetry((creds) => gitPull(folderPath, creds), {
folderPath,
}),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
if (result.updated_files === 0) {
return t("toasts.allFilesUpToDate")
}
return t("toasts.updatedFiles", {
count: result.updated_files,
})
}
)
}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
{t("pullCode")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.fetchInfo"), () =>
withCredentialRetry((creds) => gitFetch(folderPath, creds), {
folderPath,
})
)
}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("fetchRemoteBranches")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
setDropdownOpen(false)
openCommitWindow(folder.id).catch((err) => {
const title = t("toasts.openCommitWindowFailed")
const msg = toErrorMessage(err)
pushAlert("error", title, msg)
toast.error(title, { description: msg })
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
{t("openCommitWindow")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
setDropdownOpen(false)
openPushWindow(folder.id).catch((err) => {
const title = t("toasts.openPushWindowFailed")
const msg = toErrorMessage(err)
pushAlert("error", title, msg)
toast.error(title, { description: msg })
})
}}
>
<Upload className="h-3.5 w-3.5" />
{t("pushCode")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setNewBranchName("")
setNewBranchOpen(true)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
{t("newBranch")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={handleOpenWorktreeDialog}
>
<FolderGit2 className="h-3.5 w-3.5" />
{t("newWorktree")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setDropdownOpen(false)
setStashDialogOpen(true)
}}
>
<Archive className="h-3.5 w-3.5" />
{t("stashChanges")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
openStashWindow(folder.id).catch((err) => {
const msg = toErrorMessage(err)
pushAlert("error", t("stashPop"), msg)
})
}}
>
<ArchiveRestore className="h-3.5 w-3.5" />
{t("stashPop")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setDropdownOpen(false)
setManageRemotesOpen(true)
}}
>
<Globe className="h-3.5 w-3.5" />
{t("manageRemotes")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{branchLoading ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-64">
<Collapsible open={localOpen} onOpenChange={setLocalOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("localBranches", { count: branchList.local.length })}
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.local.length === 0 ? (
<DropdownMenuItem disabled>
{t("noLocalBranches")}
</DropdownMenuItem>
) : (
branchList.local.map((b) => renderBranchItem(b, false))
)}
</CollapsibleContent>
</Collapsible>
<Collapsible open={remoteOpen} onOpenChange={setRemoteOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("remoteBranches", { count: branchList.remote.length })}
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.remote.length === 0 ? (
<DropdownMenuItem disabled>
{t("noRemoteBranches")}
</DropdownMenuItem>
) : hasMultipleRemotes ? (
remoteNames.map((remoteName) => (
<Collapsible key={remoteName}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-3 py-2 pl-6 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{remoteName} (
{groupedRemoteBranches[remoteName].length})
</CollapsibleTrigger>
<CollapsibleContent className="pl-3">
{groupedRemoteBranches[remoteName].map((b) =>
renderBranchItem(
b,
true,
b.substring(remoteName.length + 1)
)
)}
</CollapsibleContent>
</Collapsible>
))
) : (
branchList.remote.map((b) => {
const slashIndex = b.indexOf("/")
const shortName =
slashIndex > 0 ? b.substring(slashIndex + 1) : b
return renderBranchItem(b, true, shortName)
})
)}
</CollapsibleContent>
</Collapsible>
</ScrollArea>
)}
</DropdownMenuContent>
</DropdownMenu>
{parentBranch && (
<button
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-orange-500 dark:text-orange-400 hover:bg-accent hover:text-orange-600 dark:hover:text-orange-300 transition-colors cursor-default select-none"
disabled={loading}
onClick={handleMergeParent}
title={t("parentBranchHint", { parentBranch })}
>
<ArrowLeftRight className="h-3 w-3 shrink-0" />
<span className="truncate max-w-32">{parentBranch}</span>
</button>
)}
<AlertDialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open) setConfirmAction(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>
{getConfirmDescription()}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={
confirmAction?.type === "delete" ||
confirmAction?.type === "forceDelete" ||
confirmAction?.type === "deleteRemote"
? "destructive"
: "default"
}
onClick={handleConfirm}
>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("dialogs.newBranchTitle")}</DialogTitle>
<DialogDescription>
{t("dialogs.newBranchDescription", { branch: branch ?? "-" })}
</DialogDescription>
</DialogHeader>
<Input
placeholder={t("dialogs.branchNamePlaceholder")}
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewBranch()
}}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
disabled={!newBranchName.trim() || loading}
onClick={handleNewBranch}
>
{tCommon("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={worktreeOpen} onOpenChange={setWorktreeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("dialogs.newWorktreeTitle")}</DialogTitle>
<DialogDescription>
{t("dialogs.newWorktreeDescription", { branch: branch ?? "-" })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="wt-branch">{t("dialogs.branchNameLabel")}</Label>
<Input
id="wt-branch"
placeholder={t("dialogs.branchNamePlaceholder")}
value={worktreeBranchName}
onChange={(e) => handleWorktreeBranchChange(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewWorktree()
}}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="wt-path">{t("dialogs.worktreePathLabel")}</Label>
<div className="flex gap-2">
<Input
id="wt-path"
placeholder={t("dialogs.worktreePathPlaceholder")}
value={worktreePath}
onChange={(e) => setWorktreePath(e.target.value)}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleBrowseWorktreePath}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWorktreeOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
disabled={
!worktreeBranchName.trim() || !worktreePath.trim() || loading
}
onClick={handleNewWorktree}
>
{tCommon("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RemoteManageDialog
open={manageRemotesOpen}
onOpenChange={setManageRemotesOpen}
folderPath={folderPath}
onSaved={() => loadAllBranches()}
/>
<ConflictDialog
conflictInfo={conflictInfo}
folderId={folder?.id ?? 0}
folderPath={folderPath}
onClose={() => setConflictInfo(null)}
onResolved={onBranchChange}
/>
<StashDialog
open={stashDialogOpen}
folderPath={folderPath}
onClose={() => setStashDialogOpen(false)}
onStashed={onBranchChange}
/>
</>
)
}

View File

@@ -11,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useFolderContext } from "@/contexts/folder-context"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import {
bootstrapFolderCommandsFromPackageJson,
@@ -40,7 +40,7 @@ function setSelectedCommandId(folderId: number, cmdId: number) {
export function CommandDropdown() {
const t = useTranslations("Folder.commandDropdown")
const { folder } = useFolderContext()
const { activeFolder: folder } = useActiveFolder()
const {
createTerminalWithCommand,
exitedTerminals,

View File

@@ -1,173 +0,0 @@
"use client"
import { useState } from "react"
import {
ChevronDown,
Folder,
FolderOpen,
GitBranch,
Rocket,
} from "lucide-react"
import { useTranslations } from "next-intl"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
focusFolderWindow,
listOpenFolders,
loadFolderHistory,
openFolderWindow,
openProjectBootWindow,
} from "@/lib/api"
import { isDesktop, openFileDialog } from "@/lib/platform"
import { useFolderContext } from "@/contexts/folder-context"
import { CloneDialog } from "@/components/welcome/clone-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import type { FolderHistoryEntry } from "@/lib/types"
export function FolderNameDropdown() {
const t = useTranslations("Folder.folderNameDropdown")
const { folder } = useFolderContext()
const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([])
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const folderPath = folder?.path ?? ""
const folderName = folder?.name ?? t("fallbackFolderName")
async function handleOpenChange(open: boolean) {
if (open) {
try {
const [openEntries, historyEntries] = await Promise.all([
listOpenFolders(),
loadFolderHistory(),
])
setOpenFolders(openEntries)
const openPaths = new Set(openEntries.map((e) => e.path))
setHistory(historyEntries.filter((e) => !openPaths.has(e.path)))
} catch {
setOpenFolders([])
setHistory([])
}
}
}
async function handleOpenFolder() {
if (isDesktop()) {
const selected = await openFileDialog({
directory: true,
multiple: false,
})
if (selected) {
await openFolderWindow(
Array.isArray(selected) ? selected[0] : selected,
{ newWindow: true }
)
}
} else {
setBrowserOpen(true)
}
}
async function handleSelect(path: string) {
try {
await openFolderWindow(path, { newWindow: true })
} catch {
// ignore
}
}
return (
<>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
suppressHydrationWarning
className="flex items-center gap-1 text-sm tracking-tight truncate hover:text-foreground/80 transition-colors outline-none cursor-default"
>
{folderName}
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuItem onSelect={handleOpenFolder}>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
{t("openFolder")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setCloneOpen(true)}>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
{t("cloneRepository")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectBootWindow()}>
<Rocket className="h-3.5 w-3.5 shrink-0" />
{t("projectBoot")}
</DropdownMenuItem>
{openFolders.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("opened")}</DropdownMenuLabel>
{openFolders.map((entry) => (
<DropdownMenuItem
key={entry.path}
onSelect={() => focusFolderWindow(entry.id)}
>
{entry.path === folderPath ? (
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
) : (
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div
className={`truncate ${entry.path === folderPath ? "font-medium text-foreground" : ""}`}
>
{entry.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">
{entry.path}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
{history.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("recentOpen")}</DropdownMenuLabel>
{history.map((entry) => (
<DropdownMenuItem
key={entry.path}
onSelect={() => handleSelect(entry.path)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate">{entry.name}</div>
<div className="text-[10px] text-muted-foreground truncate">
{entry.path}
</div>
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => {
openFolderWindow(path, { newWindow: true }).catch((err) => {
console.error("[FolderNameDropdown] failed to open folder:", err)
})
}}
/>
</>
)
}

View File

@@ -20,9 +20,10 @@ import {
SquareTerminal,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
import { openSettingsWindow } from "@/lib/api"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { isDesktop, openFileDialog } from "@/lib/platform"
import { useFolderContext } from "@/contexts/folder-context"
import { Button } from "@/components/ui/button"
import { useSidebarContext } from "@/contexts/sidebar-context"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
@@ -36,8 +37,6 @@ import {
matchShortcutEvent,
} from "@/lib/keyboard-shortcuts"
import { AppTitleBar } from "./app-title-bar"
import { FolderNameDropdown } from "./folder-name-dropdown"
import { BranchDropdown } from "./branch-dropdown"
import { CommandDropdown } from "./command-dropdown"
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
@@ -71,7 +70,8 @@ const MODE_TABS = [
export function FolderTitleBar() {
const tModes = useTranslations("Folder.modes")
const tTitleBar = useTranslations("Folder.folderTitleBar")
const { folder } = useFolderContext()
const { openFolder } = useAppWorkspace()
const { activeFolder } = useActiveFolder()
const { isOpen, toggle } = useSidebarContext()
const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext()
const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext()
@@ -79,14 +79,8 @@ export function FolderTitleBar() {
const { mode, setMode } = useWorkspaceContext()
const isMac = useIsMac()
const { shortcuts } = useShortcutSettings()
const [branch, setBranch] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
)
const folderPath = folder?.path ?? ""
const handleOpenFolder = useCallback(async () => {
if (isDesktop()) {
@@ -97,14 +91,14 @@ export function FolderTitleBar() {
})
if (!result) return
const selected = Array.isArray(result) ? result[0] : result
await openFolderWindow(selected, { newWindow: true })
await openFolder(selected)
} catch (err) {
console.error("[FolderTitleBar] failed to open folder:", err)
}
} else {
setBrowserOpen(true)
}
}, [])
}, [openFolder])
const handleOpenSettings = useCallback(() => {
openSettingsWindow().catch((err) => {
@@ -112,63 +106,6 @@ export function FolderTitleBar() {
})
}, [])
useEffect(() => {
if (!folderPath) return
let cancelled = false
// 10s when we have a branch, 60s when we don't. The slow poll still
// discovers a branch created externally (e.g. `git init` in a terminal)
// without hammering the backend when there is nothing to find.
const POLL_FAST_MS = 10_000
const POLL_SLOW_MS = 60_000
const clearPoll = () => {
if (pollTimerRef.current !== undefined) {
clearTimeout(pollTimerRef.current)
pollTimerRef.current = undefined
}
}
const scheduleNext = (delayMs: number) => {
clearPoll()
pollTimerRef.current = setTimeout(() => {
pollTimerRef.current = undefined
void doFetch()
}, delayMs)
}
async function doFetch() {
if (document.visibilityState !== "visible") return
let nextDelayMs = POLL_FAST_MS
try {
const b = await getGitBranch(folderPath)
if (cancelled) return
setBranch(b)
if (b === null) nextDelayMs = POLL_SLOW_MS
} catch {
if (!cancelled) setBranch(null)
nextDelayMs = POLL_SLOW_MS
}
if (!cancelled) scheduleNext(nextDelayMs)
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void doFetch()
}
}
void doFetch()
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
cancelled = true
clearPoll()
document.removeEventListener("visibilitychange", handleVisibilityChange)
}
}, [folderPath])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (matchShortcutEvent(e, shortcuts.toggle_search)) {
@@ -192,9 +129,9 @@ export function FolderTitleBar() {
return
}
if (matchShortcutEvent(e, shortcuts.new_conversation)) {
if (!folderPath) return
if (!activeFolder) return
e.preventDefault()
openNewConversationTab(folderPath)
openNewConversationTab(activeFolder.id, activeFolder.path)
return
}
if (matchShortcutEvent(e, shortcuts.open_folder)) {
@@ -210,7 +147,7 @@ export function FolderTitleBar() {
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [
folderPath,
activeFolder,
handleOpenFolder,
handleOpenSettings,
openNewConversationTab,
@@ -220,14 +157,6 @@ export function FolderTitleBar() {
toggleTerminal,
])
const refreshBranch = useCallback(async () => {
if (!folderPath) return
try {
setBranch(await getGitBranch(folderPath))
} catch {
setBranch(null)
}
}, [folderPath])
const isMobile = useIsMobile()
const modeContainerRef = useRef<HTMLDivElement>(null)
const modeItemRefs = useRef<Map<string, HTMLDivElement>>(new Map())
@@ -326,7 +255,6 @@ export function FolderTitleBar() {
className="block h-3 w-3 shrink-0"
shapeRendering="geometricPrecision"
/>
{/* Hide text labels on mobile to save space */}
{!isMobile && (
<span
className={cn(
@@ -365,23 +293,9 @@ export function FolderTitleBar() {
>
<Menu className="h-4 w-4" />
</Button>
<FolderNameDropdown />
<BranchDropdown
branch={branch}
parentBranch={folder?.parent_branch ?? null}
onBranchChange={refreshBranch}
/>
</div>
) : (
<div className="flex min-w-0 items-center gap-4">
<FolderNameDropdown />
<BranchDropdown
branch={branch}
parentBranch={folder?.parent_branch ?? null}
onBranchChange={refreshBranch}
/>
<div data-tauri-drag-region className="h-8 flex-1" />
</div>
<div data-tauri-drag-region className="h-8 flex-1" />
)
}
center={isMobile ? undefined : modeTabsElement}
@@ -511,7 +425,7 @@ export function FolderTitleBar() {
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => {
openFolderWindow(path, { newWindow: true }).catch((err) => {
openFolder(path).catch((err) => {
console.error("[FolderTitleBar] failed to open folder:", err)
})
}}

View File

@@ -1,9 +1,21 @@
"use client"
import { useCallback, useRef } from "react"
import { ChevronsDownUp, ChevronsUpDown, Crosshair, Plus } from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
ChevronsDownUp,
ChevronsUpDown,
Crosshair,
FolderPlus,
FolderTree,
Plus,
Rows3,
Search,
X,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { toast } from "sonner"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context"
import { useSidebarContext } from "@/contexts/sidebar-context"
import {
@@ -11,66 +23,213 @@ import {
type SidebarConversationListHandle,
} from "@/components/conversations/sidebar-conversation-list"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { isDesktop, openFileDialog } from "@/lib/platform"
import {
loadSidebarViewMode,
saveSidebarViewMode,
type SidebarViewMode,
} from "@/lib/sidebar-view-mode-storage"
import { cn } from "@/lib/utils"
export function Sidebar() {
const t = useTranslations("Folder.sidebar")
const { folder } = useFolderContext()
const { activeFolder } = useActiveFolder()
const { allFolders, conversations, openFolder } = useAppWorkspace()
const { openNewConversationTab } = useTabContext()
const { isOpen, toggle } = useSidebarContext()
const isMobile = useIsMobile()
const listRef = useRef<SidebarConversationListHandle>(null)
const [viewMode, setViewMode] = useState<SidebarViewMode>("flat")
const [searchQuery, setSearchQuery] = useState("")
useEffect(() => {
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setViewMode(loadSidebarViewMode())
}, [])
const handleSetViewMode = useCallback((mode: SidebarViewMode) => {
setViewMode(mode)
saveSidebarViewMode(mode)
}, [])
useEffect(() => {
const onReveal = (e: Event) => {
const detail = (e as CustomEvent<{ folderId: number }>).detail
if (!detail) return
if (viewMode !== "grouped") {
setViewMode("grouped")
saveSidebarViewMode("grouped")
}
listRef.current?.revealFolder(detail.folderId)
}
window.addEventListener("sidebar:reveal-folder", onReveal)
return () => {
window.removeEventListener("sidebar:reveal-folder", onReveal)
}
}, [viewMode])
const handleNewConversation = useCallback(() => {
if (!folder) return
openNewConversationTab(folder.path)
}, [folder, openNewConversationTab])
if (!activeFolder) return
openNewConversationTab(activeFolder.id, activeFolder.path)
}, [activeFolder, openNewConversationTab])
const handleOpenFolder = useCallback(async () => {
try {
if (!isDesktop()) {
toast.error(t("toasts.openFolderFailed"))
return
}
const result = await openFileDialog({
directory: true,
multiple: false,
})
if (!result) return
const selected = Array.isArray(result) ? result[0] : result
const detail = await openFolder(selected)
toast.success(t("toasts.folderOpened", { name: detail.name }))
} catch (err) {
console.error("[Sidebar] open folder failed:", err)
toast.error(t("toasts.openFolderFailed"))
}
}, [openFolder, t])
if (!isOpen) return null
return (
<aside className="group/sidebar flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
<div className="flex h-10 items-center justify-between border-b border-border px-4">
<h2 className="text-xs font-bold">{t("title")}</h2>
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.scrollToActive()}
title={t("locateActiveConversation")}
>
<Crosshair className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.expandAll()}
title={t("expandAllGroups")}
>
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.collapseAll()}
title={t("collapseAllGroups")}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleNewConversation}
title={t("newConversation")}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<TooltipProvider>
<div className="flex items-center justify-between border-b border-border px-3 py-2 gap-2">
<div className="flex items-center gap-2 min-w-0 text-[11px] text-muted-foreground tabular-nums">
<span className="truncate">
{t("statsLabel", {
folders: allFolders.length,
convos: conversations.length,
})}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleOpenFolder}
>
<FolderPlus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("openFolder")}</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<div className="relative flex-1 min-w-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground pointer-events-none" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
className="h-7 pl-6 pr-6 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground rounded-sm p-0.5"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 text-muted-foreground",
viewMode === "flat" && "bg-accent text-foreground"
)}
onClick={() => handleSetViewMode("flat")}
>
<Rows3 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("viewFlat")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 text-muted-foreground",
viewMode === "grouped" && "bg-accent text-foreground"
)}
onClick={() => handleSetViewMode("grouped")}
>
<FolderTree className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("viewGrouped")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center justify-between border-b border-border px-2 h-7">
<h2 className="text-xs font-bold text-muted-foreground truncate">
{t("title")}
</h2>
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover/sidebar:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.scrollToActive()}
title={t("locateActiveConversation")}
>
<Crosshair className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.expandAll()}
title={t("expandAllGroups")}
>
<ChevronsUpDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={() => listRef.current?.collapseAll()}
title={t("collapseAllGroups")}
>
<ChevronsDownUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground"
onClick={handleNewConversation}
disabled={!activeFolder}
title={t("newConversation")}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</TooltipProvider>
{/* On mobile, clicking a conversation card auto-closes the Sheet */}
<div
@@ -86,7 +245,11 @@ export function Sidebar() {
: undefined
}
>
<SidebarConversationList ref={listRef} />
<SidebarConversationList
ref={listRef}
viewMode={viewMode}
searchQuery={searchQuery}
/>
</div>
</aside>
)

View File

@@ -5,7 +5,7 @@ import { Unplug } from "lucide-react"
import { useTranslations } from "next-intl"
import { useConnectionStore } from "@/contexts/acp-connections-context"
import { useTabContext } from "@/contexts/tab-context"
import { useFolderContext } from "@/contexts/folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { AgentIcon } from "@/components/agent-icon"
import {
Tooltip,
@@ -42,7 +42,7 @@ export function StatusBarConnection() {
const t = useTranslations("Folder.statusBar.connection")
const store = useConnectionStore()
const { tabs, activeTabId } = useTabContext()
const { conversations } = useFolderContext()
const { conversations } = useAppWorkspace()
// Subscribe to activeKey changes
const subscribeActiveKey = useCallback(

View File

@@ -3,11 +3,11 @@
import { useMemo } from "react"
import { GitBranch } from "lucide-react"
import { useTabContext } from "@/contexts/tab-context"
import { useFolderContext } from "@/contexts/folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
export function StatusBarSessionInfo() {
const { tabs, activeTabId } = useTabContext()
const { conversations } = useFolderContext()
const { conversations } = useAppWorkspace()
const activeTab = useMemo(
() => tabs.find((t) => t.id === activeTabId) ?? null,

View File

@@ -3,7 +3,7 @@
import { useMemo } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
import { useFolderContext } from "@/contexts/folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { AGENT_LABELS } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon"
import {
@@ -14,7 +14,7 @@ import {
export function StatusBarStats() {
const t = useTranslations("Folder.statusBar.stats")
const { stats } = useFolderContext()
const { stats } = useAppWorkspace()
const activeAgents = useMemo(
() => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [],