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:
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"
|
||||
import { openUrl } from "@/lib/platform"
|
||||
import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown"
|
||||
import { toast } from "sonner"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -223,7 +223,7 @@ function LinkSafetyModal({
|
||||
|
||||
export function useStreamdownLinkSafety(): LinkSafetyConfig {
|
||||
const t = useTranslations("Folder.chat.linkSafety")
|
||||
const { folder } = useFolderContext()
|
||||
const { activeFolder: folder } = useActiveFolder()
|
||||
const folderPath = folder?.path
|
||||
const { openFilePreview } = useWorkspaceContext()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AvailableCommandInfo,
|
||||
} from "@/lib/types"
|
||||
import type { QueuedMessage } from "@/hooks/use-message-queue"
|
||||
import { ConversationContextBar } from "@/components/chat/conversation-context-bar"
|
||||
import { MessageInput } from "@/components/chat/message-input"
|
||||
import { MessageQueueDisplay } from "@/components/chat/message-queue-display"
|
||||
|
||||
@@ -92,6 +93,7 @@ export const ChatInput = memo(function ChatInput({
|
||||
className="p-4 pt-0"
|
||||
onContextMenu={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ConversationContextBar />
|
||||
{queue &&
|
||||
queue.length > 0 &&
|
||||
onQueueReorder &&
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitMerge,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Upload,
|
||||
Plus,
|
||||
Archive,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import {
|
||||
gitListAllBranches,
|
||||
gitCheckout,
|
||||
gitNewBranch,
|
||||
gitDeleteBranch,
|
||||
openCommitWindow,
|
||||
openPushWindow,
|
||||
openStashWindow,
|
||||
openMergeWindow,
|
||||
} from "@/lib/api"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import type { GitBranchList } from "@/lib/types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export const ConversationContextBar = memo(function ConversationContextBar() {
|
||||
const t = useTranslations("Folder.conversationContextBar")
|
||||
const tBd = useTranslations("Folder.branchDropdown")
|
||||
const { tabs, activeTabId, setTabFolder } = useTabContext()
|
||||
const {
|
||||
folders,
|
||||
allFolders,
|
||||
branches,
|
||||
setBranch,
|
||||
openFolder,
|
||||
addFolderToWorkspaceById,
|
||||
refreshFolder,
|
||||
} = useAppWorkspace()
|
||||
const { addTask, updateTask } = useTaskContext()
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((x) => x.id === activeTabId) ?? null,
|
||||
[tabs, activeTabId]
|
||||
)
|
||||
|
||||
const activeFolder = useMemo(
|
||||
() =>
|
||||
activeTab
|
||||
? (allFolders.find((f) => f.id === activeTab.folderId) ?? null)
|
||||
: null,
|
||||
[activeTab, allFolders]
|
||||
)
|
||||
|
||||
if (!activeTab || !activeFolder) return null
|
||||
|
||||
const isNewConversation = activeTab.conversationId == null
|
||||
const currentBranch =
|
||||
branches.get(activeFolder.id) ?? activeFolder.git_branch ?? null
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-1.5 h-9 px-3 border-b border-border/40 bg-muted/20 text-xs">
|
||||
<FolderPicker
|
||||
folders={allFolders}
|
||||
currentFolderId={activeFolder.id}
|
||||
currentFolderName={activeFolder.name}
|
||||
editable={isNewConversation}
|
||||
onSelect={async (folderId) => {
|
||||
const target = allFolders.find((f) => f.id === folderId)
|
||||
if (!target) return
|
||||
const isOpen = folders.some((f) => f.id === folderId)
|
||||
try {
|
||||
const detail = isOpen
|
||||
? target
|
||||
: await addFolderToWorkspaceById(folderId)
|
||||
setTabFolder(activeTab.id, detail.id, detail.path)
|
||||
toast.success(t("toasts.folderChanged", { name: detail.name }))
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[ConversationContextBar] switch folder failed:",
|
||||
err
|
||||
)
|
||||
toast.error(t("toasts.openFolderFailed"))
|
||||
}
|
||||
}}
|
||||
onOpenNewFolder={async () => {
|
||||
try {
|
||||
if (isDesktop()) {
|
||||
const result = await openFileDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
})
|
||||
if (!result) return
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
const detail = await openFolder(selected)
|
||||
setTabFolder(activeTab.id, detail.id, detail.path)
|
||||
toast.success(t("toasts.folderChanged", { name: detail.name }))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ConversationContextBar] open folder failed:", err)
|
||||
toast.error(t("toasts.openFolderFailed"))
|
||||
}
|
||||
}}
|
||||
labelOpenNew={t("openNewFolder")}
|
||||
labelEmpty={t("noFolders")}
|
||||
labelSearch={t("searchFolder")}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<BranchPicker
|
||||
folderId={activeFolder.id}
|
||||
folderPath={activeFolder.path}
|
||||
currentBranch={currentBranch}
|
||||
onCheckout={async (branchName) => {
|
||||
const taskId = `checkout-${activeFolder.id}-${Date.now()}`
|
||||
addTask(taskId, tBd("tasks.checkoutTo", { branchName }))
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
await gitCheckout(activeFolder.path, branchName)
|
||||
setBranch(activeFolder.id, branchName)
|
||||
await refreshFolder(activeFolder.id)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateTask(taskId, { status: "failed", error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}}
|
||||
onNewBranch={async (branchName, startPoint) => {
|
||||
const taskId = `new-branch-${activeFolder.id}-${Date.now()}`
|
||||
addTask(taskId, tBd("tasks.newBranch", { name: branchName }))
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
await gitNewBranch(activeFolder.path, branchName, startPoint)
|
||||
setBranch(activeFolder.id, branchName)
|
||||
await refreshFolder(activeFolder.id)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateTask(taskId, { status: "failed", error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}}
|
||||
onDeleteBranch={async (branchName) => {
|
||||
const taskId = `delete-branch-${activeFolder.id}-${Date.now()}`
|
||||
addTask(taskId, tBd("tasks.deleteBranch", { branchName }))
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
await gitDeleteBranch(activeFolder.path, branchName, false)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateTask(taskId, { status: "failed", error: msg })
|
||||
toast.error(msg)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<GitActionButtons
|
||||
folderId={activeFolder.id}
|
||||
currentBranch={currentBranch}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
|
||||
ConversationContextBar.displayName = "ConversationContextBar"
|
||||
|
||||
// ============================================================================
|
||||
// FolderPicker
|
||||
// ============================================================================
|
||||
|
||||
interface FolderPickerProps {
|
||||
folders: { id: number; name: string; path: string }[]
|
||||
currentFolderId: number
|
||||
currentFolderName: string
|
||||
editable: boolean
|
||||
onSelect: (folderId: number) => void | Promise<void>
|
||||
onOpenNewFolder: () => void | Promise<void>
|
||||
labelOpenNew: string
|
||||
labelEmpty: string
|
||||
labelSearch: string
|
||||
}
|
||||
|
||||
const FolderPicker = memo(function FolderPicker({
|
||||
folders,
|
||||
currentFolderId,
|
||||
currentFolderName,
|
||||
editable,
|
||||
onSelect,
|
||||
onOpenNewFolder,
|
||||
labelOpenNew,
|
||||
labelEmpty,
|
||||
labelSearch,
|
||||
}: FolderPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-2 gap-1.5 font-normal",
|
||||
!editable && "cursor-default hover:bg-transparent"
|
||||
)}
|
||||
disabled={!editable && false}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="max-w-[140px] truncate">{currentFolderName}</span>
|
||||
{editable && (
|
||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground opacity-60" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (!editable) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{currentFolderName}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0 w-72">
|
||||
<Command>
|
||||
<CommandInput placeholder={labelSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{labelEmpty}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{folders.map((f) => (
|
||||
<CommandItem
|
||||
key={f.id}
|
||||
value={`${f.name} ${f.path}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void onSelect(f.id)
|
||||
}}
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate font-medium">{f.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{f.path}
|
||||
</span>
|
||||
</div>
|
||||
{f.id === currentFolderId && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void onOpenNewFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{labelOpenNew}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// BranchPicker
|
||||
// ============================================================================
|
||||
|
||||
interface BranchPickerProps {
|
||||
folderId: number
|
||||
folderPath: string
|
||||
currentBranch: string | null
|
||||
onCheckout: (branchName: string) => Promise<void>
|
||||
onNewBranch: (branchName: string, startPoint?: string) => Promise<void>
|
||||
onDeleteBranch: (branchName: string) => Promise<void>
|
||||
}
|
||||
|
||||
const BranchPicker = memo(function BranchPicker({
|
||||
folderId,
|
||||
folderPath,
|
||||
currentBranch,
|
||||
onCheckout,
|
||||
onNewBranch,
|
||||
onDeleteBranch,
|
||||
}: BranchPickerProps) {
|
||||
const t = useTranslations("Folder.conversationContextBar")
|
||||
const tBd = useTranslations("Folder.branchDropdown")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [branchList, setBranchList] = useState<GitBranchList | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [newBranchOpen, setNewBranchOpen] = useState(false)
|
||||
const [newBranchName, setNewBranchName] = useState("")
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const list = await gitListAllBranches(folderPath)
|
||||
setBranchList(list)
|
||||
} catch (err) {
|
||||
console.error("[BranchPicker] list failed:", err)
|
||||
setBranchList({ local: [], remote: [], worktree_branches: [] })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [folderPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadBranches()
|
||||
}, [open, loadBranches])
|
||||
|
||||
// Reset branches cache when folder changes
|
||||
useEffect(() => {
|
||||
setBranchList(null)
|
||||
}, [folderId])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5 font-normal"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="max-w-[160px] truncate">
|
||||
{currentBranch ?? t("noBranch")}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0 w-80">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("searchBranch")} />
|
||||
<CommandList>
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>{t("noBranches")}</CommandEmpty>
|
||||
{branchList && branchList.local.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={tBd("localBranches", {
|
||||
count: branchList.local.length,
|
||||
})}
|
||||
>
|
||||
{branchList.local.map((b) => (
|
||||
<CommandItem
|
||||
key={`local-${b}`}
|
||||
value={`local ${b}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
if (b !== currentBranch) void onCheckout(b)
|
||||
}}
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span className="flex-1 truncate">{b}</span>
|
||||
{b === currentBranch && (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{b !== currentBranch && (
|
||||
<Trash2
|
||||
className="h-3.5 w-3.5 opacity-50 hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
void onDeleteBranch(b)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{branchList && branchList.remote.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={tBd("remoteBranches", {
|
||||
count: branchList.remote.length,
|
||||
})}
|
||||
>
|
||||
{branchList.remote.map((b) => (
|
||||
<CommandItem
|
||||
key={`remote-${b}`}
|
||||
value={`remote ${b}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void onCheckout(b)
|
||||
}}
|
||||
>
|
||||
<GitBranch className="h-4 w-4 opacity-60" />
|
||||
<span className="flex-1 truncate text-muted-foreground">
|
||||
{b}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
setNewBranchName("")
|
||||
setNewBranchOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{tBd("newBranch")}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tBd("dialogs.newBranchTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{tBd("dialogs.newBranchDescription", {
|
||||
branch: currentBranch ?? "-",
|
||||
})}
|
||||
</div>
|
||||
<Input
|
||||
placeholder={tBd("dialogs.branchNamePlaceholder")}
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!newBranchName.trim()}
|
||||
onClick={async () => {
|
||||
const name = newBranchName.trim()
|
||||
if (!name) return
|
||||
setNewBranchOpen(false)
|
||||
await onNewBranch(name, currentBranch ?? undefined)
|
||||
}}
|
||||
>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// GitActionButtons
|
||||
// ============================================================================
|
||||
|
||||
interface GitActionButtonsProps {
|
||||
folderId: number
|
||||
currentBranch: string | null
|
||||
}
|
||||
|
||||
const GitActionButtons = memo(function GitActionButtons({
|
||||
folderId,
|
||||
currentBranch,
|
||||
}: GitActionButtonsProps) {
|
||||
const t = useTranslations("Folder.conversationContextBar")
|
||||
const tBd = useTranslations("Folder.branchDropdown")
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
try {
|
||||
await openCommitWindow(folderId)
|
||||
} catch (err) {
|
||||
console.error("[GitActions] commit failed:", err)
|
||||
toast.error(tBd("toasts.openCommitWindowFailed"))
|
||||
}
|
||||
}, [folderId, tBd])
|
||||
|
||||
const handlePush = useCallback(async () => {
|
||||
try {
|
||||
await openPushWindow(folderId)
|
||||
} catch (err) {
|
||||
console.error("[GitActions] push failed:", err)
|
||||
toast.error(tBd("toasts.openPushWindowFailed"))
|
||||
}
|
||||
}, [folderId, tBd])
|
||||
|
||||
const handleStash = useCallback(async () => {
|
||||
try {
|
||||
await openStashWindow(folderId)
|
||||
} catch (err) {
|
||||
console.error("[GitActions] stash failed:", err)
|
||||
toast.error(t("toasts.openStashFailed"))
|
||||
}
|
||||
}, [folderId, t])
|
||||
|
||||
const handleMerge = useCallback(async () => {
|
||||
try {
|
||||
await openMergeWindow(folderId, "merge")
|
||||
} catch (err) {
|
||||
console.error("[GitActions] merge failed:", err)
|
||||
toast.error(t("toasts.openMergeFailed"))
|
||||
}
|
||||
}, [folderId, t])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5 font-normal"
|
||||
onClick={handleCommit}
|
||||
disabled={currentBranch == null}
|
||||
>
|
||||
<GitCommit className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("commit")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tBd("openCommitWindow")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5 font-normal"
|
||||
onClick={handlePush}
|
||||
disabled={currentBranch == null}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("push")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tBd("pushCode")}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={currentBranch == null}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={handleMerge}>
|
||||
<GitMerge className="h-4 w-4" />
|
||||
{t("merge")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleStash}>
|
||||
<Archive className="h-4 w-4" />
|
||||
{tBd("stashChanges")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleCommit}>
|
||||
<GitCommit className="h-4 w-4" />
|
||||
{tBd("openCommitWindow")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handlePush}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{tBd("pushCode")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -23,7 +23,8 @@ import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
@@ -159,8 +160,9 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
const t = useTranslations("Folder.conversation")
|
||||
const tWelcome = useTranslations("Folder.chat.welcomeInputPanel")
|
||||
const sharedT = useTranslations("Folder.chat.shared")
|
||||
const { folder, folderId, refreshConversations, updateConversationLocal } =
|
||||
useFolderContext()
|
||||
const { activeFolder: folder, activeFolderId } = useActiveFolder()
|
||||
const { refreshConversations, updateConversationLocal } = useAppWorkspace()
|
||||
const folderId = activeFolderId ?? 0
|
||||
const { tabs, bindConversationTab, setTabRuntimeConversationId, pinTab } =
|
||||
useTabContext()
|
||||
const { setSessionStats } = useSessionStats()
|
||||
@@ -267,8 +269,8 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
if (dbConversationId != null) {
|
||||
return buildConversationDraftStorageKey(selectedAgent, dbConversationId)
|
||||
}
|
||||
return buildNewConversationDraftStorageKey({ folderId })
|
||||
}, [dbConversationId, folderId, selectedAgent])
|
||||
return buildNewConversationDraftStorageKey({ tabId })
|
||||
}, [dbConversationId, tabId, selectedAgent])
|
||||
const workingDirForConnection = useMemo(() => {
|
||||
if (dbConversationId != null) {
|
||||
return detailLoading ? undefined : folder?.path
|
||||
@@ -624,7 +626,7 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
effectiveConversationId
|
||||
)
|
||||
moveMessageInputDraft(
|
||||
buildNewConversationDraftStorageKey({ folderId }),
|
||||
buildNewConversationDraftStorageKey({ tabId }),
|
||||
buildConversationDraftStorageKey(selectedAgent, newConversationId)
|
||||
)
|
||||
statusUpdatedRef.current = false
|
||||
@@ -1034,13 +1036,9 @@ export function ConversationDetailPanel() {
|
||||
getSession,
|
||||
removeConversation: runtimeRemoveConversation,
|
||||
} = useConversationRuntime()
|
||||
const {
|
||||
folder,
|
||||
newConversation,
|
||||
conversations,
|
||||
refreshConversations,
|
||||
updateConversationLocal,
|
||||
} = useFolderContext()
|
||||
const { activeFolder: folder } = useActiveFolder()
|
||||
const { conversations, refreshConversations, updateConversationLocal } =
|
||||
useAppWorkspace()
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -1050,6 +1048,13 @@ export function ConversationDetailPanel() {
|
||||
switchTab,
|
||||
onPreviewTabReplaced,
|
||||
} = useTabContext()
|
||||
const newConversation = useMemo(() => {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
||||
if (!activeTab || activeTab.conversationId != null) return null
|
||||
const workingDir = activeTab.workingDir ?? folder?.path
|
||||
if (!workingDir) return null
|
||||
return { workingDir, folderId: activeTab.folderId }
|
||||
}, [tabs, activeTabId, folder?.path])
|
||||
const { disconnect: disconnectByKey } = useAcpActions()
|
||||
const { addTask, updateTask } = useTaskContext()
|
||||
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
||||
@@ -1298,7 +1303,7 @@ export function ConversationDetailPanel() {
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!folder) return
|
||||
openNewConversationTab(folder.path)
|
||||
openNewConversationTab(folder.id, folder.path)
|
||||
}, [folder, openNewConversationTab])
|
||||
|
||||
const handleCloseActiveTab = useCallback(() => {
|
||||
@@ -1372,7 +1377,10 @@ export function ConversationDetailPanel() {
|
||||
if (!folder) return
|
||||
|
||||
if (hasNoTabs) {
|
||||
openNewConversationTab(newConversation?.workingDir ?? folder.path)
|
||||
openNewConversationTab(
|
||||
folder.id,
|
||||
newConversation?.workingDir ?? folder.path
|
||||
)
|
||||
}
|
||||
}, [folder, hasNoTabs, newConversation?.workingDir, openNewConversationTab])
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||
import { File, Folder } from "lucide-react"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { listFolderConversations } from "@/lib/api"
|
||||
import { listAllConversations } from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
@@ -43,7 +44,16 @@ export function SearchCommandDialog({
|
||||
const locale = useLocale()
|
||||
const dateFnsLocale =
|
||||
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS
|
||||
const { folderId, folder, conversations } = useFolderContext()
|
||||
const { activeFolder: folder, activeFolderId } = useActiveFolder()
|
||||
const { conversations: allConversations } = useAppWorkspace()
|
||||
const folderId = activeFolderId ?? 0
|
||||
const conversations = useMemo(
|
||||
() =>
|
||||
activeFolderId == null
|
||||
? []
|
||||
: allConversations.filter((c) => c.folder_id === activeFolderId),
|
||||
[allConversations, activeFolderId]
|
||||
)
|
||||
const { openTab } = useTabContext()
|
||||
const { openFilePreview } = useWorkspaceContext()
|
||||
const { revealInFileTree } = useAuxPanelContext()
|
||||
@@ -96,8 +106,8 @@ export function SearchCommandDialog({
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await listFolderConversations({
|
||||
folder_id: folderId,
|
||||
const data = await listAllConversations({
|
||||
folder_ids: folderId > 0 ? [folderId] : null,
|
||||
search: q.trim() || null,
|
||||
agent_type: agent,
|
||||
})
|
||||
@@ -136,7 +146,7 @@ export function SearchCommandDialog({
|
||||
|
||||
const handleSelectConversation = useCallback(
|
||||
(conv: DbConversationSummary) => {
|
||||
openTab(conv.id, conv.agent_type, true)
|
||||
openTab(conv.folder_id, conv.id, conv.agent_type, true)
|
||||
onOpenChange(false)
|
||||
},
|
||||
[openTab, onOpenChange]
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua"
|
||||
import { CheckCheck, ChevronRight, Download, Loader2, Plus } from "lucide-react"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import {
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Loader2,
|
||||
Plus,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
import {
|
||||
@@ -25,6 +33,11 @@ import {
|
||||
} from "@/lib/api"
|
||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
||||
import {
|
||||
loadFolderExpanded,
|
||||
saveFolderExpanded,
|
||||
type SidebarViewMode,
|
||||
} from "@/lib/sidebar-view-mode-storage"
|
||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -69,12 +82,99 @@ function compareByUpdatedAtDesc(
|
||||
}
|
||||
|
||||
type FlatItem =
|
||||
| { type: "header"; status: ConversationStatus; count: number }
|
||||
| {
|
||||
type: "folder_header"
|
||||
folderId: number
|
||||
folderName: string
|
||||
branch: string | null
|
||||
count: number
|
||||
expanded: boolean
|
||||
}
|
||||
| {
|
||||
type: "status_header"
|
||||
status: ConversationStatus
|
||||
count: number
|
||||
parentFolderId?: number
|
||||
}
|
||||
| { type: "conversation"; conversation: DbConversationSummary }
|
||||
|
||||
const CARD_HEIGHT = 62
|
||||
|
||||
const GroupHeader = memo(function GroupHeader({
|
||||
const FolderHeader = memo(function FolderHeader({
|
||||
folderId,
|
||||
folderName,
|
||||
branch,
|
||||
count,
|
||||
expanded,
|
||||
onToggle,
|
||||
onFocus,
|
||||
onCloseFolderTabs,
|
||||
onRemoveFromWorkspace,
|
||||
highlighted,
|
||||
t,
|
||||
}: {
|
||||
folderId: number
|
||||
folderName: string
|
||||
branch: string | null
|
||||
count: number
|
||||
expanded: boolean
|
||||
onToggle: (folderId: number) => void
|
||||
onFocus: (folderId: number) => void
|
||||
onCloseFolderTabs: (folderId: number) => void
|
||||
onRemoveFromWorkspace: (folderId: number) => void
|
||||
highlighted: boolean
|
||||
t: ReturnType<typeof useTranslations>
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
data-folder-id={folderId}
|
||||
onClick={() => onToggle(folderId)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 w-full px-1.5 py-1.5 text-xs font-medium cursor-pointer transition-all",
|
||||
"text-foreground hover:bg-accent/50 rounded-sm",
|
||||
highlighted && "ring-2 ring-primary ring-offset-1"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform text-muted-foreground",
|
||||
expanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate flex-1 text-left">{folderName}</span>
|
||||
{branch && (
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
{branch}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
|
||||
({count})
|
||||
</span>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onFocus(folderId)}>
|
||||
{t("folderHeaderMenu.focus")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => onCloseFolderTabs(folderId)}>
|
||||
{t("folderHeaderMenu.closeFolderTabs")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onRemoveFromWorkspace(folderId)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("folderHeaderMenu.removeFromWorkspace")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})
|
||||
|
||||
const StatusHeader = memo(function StatusHeader({
|
||||
status,
|
||||
count,
|
||||
isOpen,
|
||||
@@ -168,32 +268,62 @@ export interface SidebarConversationListHandle {
|
||||
scrollToActive: () => void
|
||||
expandAll: () => void
|
||||
collapseAll: () => void
|
||||
revealFolder: (folderId: number) => void
|
||||
}
|
||||
|
||||
export interface SidebarConversationListProps {
|
||||
viewMode?: SidebarViewMode
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export function SidebarConversationList({
|
||||
ref,
|
||||
}: {
|
||||
viewMode = "flat",
|
||||
searchQuery = "",
|
||||
}: SidebarConversationListProps & {
|
||||
ref?: Ref<SidebarConversationListHandle>
|
||||
}) {
|
||||
const t = useTranslations("Folder.sidebar")
|
||||
const tStatus = useTranslations("Folder.statusLabels")
|
||||
const tCommon = useTranslations("Folder.common")
|
||||
const {
|
||||
folder,
|
||||
allFolders,
|
||||
conversations,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
selectedConversation,
|
||||
folderId,
|
||||
conversationsLoading: loading,
|
||||
conversationsError: error,
|
||||
refreshConversations,
|
||||
updateConversationLocal,
|
||||
} = useFolderContext()
|
||||
branches,
|
||||
removeFolderFromWorkspace,
|
||||
} = useAppWorkspace()
|
||||
const refreshing = loading
|
||||
const { activeFolder } = useActiveFolder()
|
||||
|
||||
const { openTab, closeConversationTab, openNewConversationTab } =
|
||||
useTabContext()
|
||||
const {
|
||||
openTab,
|
||||
closeConversationTab,
|
||||
closeTabsByFolder,
|
||||
openNewConversationTab,
|
||||
activeTabId,
|
||||
tabs,
|
||||
} = useTabContext()
|
||||
const { addTask, updateTask } = useTaskContext()
|
||||
|
||||
const folderIndex = useMemo(() => {
|
||||
const map = new Map<number, { name: string; path: string }>()
|
||||
for (const f of allFolders) map.set(f.id, { name: f.name, path: f.path })
|
||||
return map
|
||||
}, [allFolders])
|
||||
|
||||
const selectedConversation = useMemo(() => {
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
||||
if (!activeTab || activeTab.conversationId == null) return null
|
||||
return {
|
||||
id: activeTab.conversationId,
|
||||
agentType: activeTab.agentType,
|
||||
}
|
||||
}, [tabs, activeTabId])
|
||||
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [completeReviewOpen, setCompleteReviewOpen] = useState(false)
|
||||
const [completingReview, setCompletingReview] = useState(false)
|
||||
@@ -205,10 +335,160 @@ export function SidebarConversationList({
|
||||
completed: false,
|
||||
cancelled: false,
|
||||
})
|
||||
const [folderExpanded, setFolderExpanded] = useState<Record<number, boolean>>(
|
||||
{}
|
||||
)
|
||||
const [highlightedFolder, setHighlightedFolder] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [removeConfirm, setRemoveConfirm] = useState<{
|
||||
folderId: number
|
||||
folderName: string
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||
|
||||
setFolderExpanded(loadFolderExpanded())
|
||||
}, [])
|
||||
|
||||
const scrollToActiveRef = useRef<() => void>(() => {})
|
||||
const pendingScrollRef = useRef(false)
|
||||
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
||||
const highlightTimerRef = useRef<number | null>(null)
|
||||
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase()
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (!normalizedSearch) return conversations
|
||||
return conversations.filter((c) => {
|
||||
const title = (c.title ?? "").toLowerCase()
|
||||
return title.includes(normalizedSearch)
|
||||
})
|
||||
}, [conversations, normalizedSearch])
|
||||
|
||||
const byStatus = useMemo(() => {
|
||||
const map = new Map<ConversationStatus, DbConversationSummary[]>()
|
||||
for (const conv of filteredConversations) {
|
||||
const status = conv.status as ConversationStatus
|
||||
const list = map.get(status)
|
||||
if (list) list.push(conv)
|
||||
else map.set(status, [conv])
|
||||
}
|
||||
for (const list of map.values()) list.sort(compareByUpdatedAtDesc)
|
||||
return map
|
||||
}, [filteredConversations])
|
||||
|
||||
const byFolder = useMemo(() => {
|
||||
const map = new Map<
|
||||
number,
|
||||
Map<ConversationStatus, DbConversationSummary[]>
|
||||
>()
|
||||
for (const conv of filteredConversations) {
|
||||
const folderId = conv.folder_id
|
||||
let inner = map.get(folderId)
|
||||
if (!inner) {
|
||||
inner = new Map<ConversationStatus, DbConversationSummary[]>()
|
||||
map.set(folderId, inner)
|
||||
}
|
||||
const status = conv.status as ConversationStatus
|
||||
const list = inner.get(status)
|
||||
if (list) list.push(conv)
|
||||
else inner.set(status, [conv])
|
||||
}
|
||||
for (const inner of map.values()) {
|
||||
for (const list of inner.values()) list.sort(compareByUpdatedAtDesc)
|
||||
}
|
||||
return map
|
||||
}, [filteredConversations])
|
||||
|
||||
const orderedFolderIds = useMemo(() => {
|
||||
// Show every folder in the workspace DB, even ones without conversations.
|
||||
// Folders that only have orphan conversations still appear via byFolder.
|
||||
const seen = new Set<number>()
|
||||
const ids: number[] = []
|
||||
for (const f of allFolders) {
|
||||
if (!seen.has(f.id)) {
|
||||
seen.add(f.id)
|
||||
ids.push(f.id)
|
||||
}
|
||||
}
|
||||
for (const id of byFolder.keys()) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
ids.push(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}, [allFolders, byFolder])
|
||||
|
||||
const flatItems = useMemo<FlatItem[]>(() => {
|
||||
const items: FlatItem[] = []
|
||||
if (viewMode === "grouped") {
|
||||
for (const folderId of orderedFolderIds) {
|
||||
const inner = byFolder.get(folderId)
|
||||
const totalCount = inner
|
||||
? Array.from(inner.values()).reduce(
|
||||
(sum, list) => sum + list.length,
|
||||
0
|
||||
)
|
||||
: 0
|
||||
const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
|
||||
const branch = branches.get(folderId) ?? null
|
||||
const expanded = folderExpanded[folderId] ?? true
|
||||
items.push({
|
||||
type: "folder_header",
|
||||
folderId,
|
||||
folderName,
|
||||
branch,
|
||||
count: totalCount,
|
||||
expanded,
|
||||
})
|
||||
if (!expanded || !inner) continue
|
||||
for (const status of STATUS_ORDER) {
|
||||
const list = inner.get(status)
|
||||
if (!list || list.length === 0) continue
|
||||
items.push({
|
||||
type: "status_header",
|
||||
status,
|
||||
count: list.length,
|
||||
parentFolderId: folderId,
|
||||
})
|
||||
if (groupExpanded[status]) {
|
||||
for (const conv of list) {
|
||||
items.push({ type: "conversation", conversation: conv })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const status of STATUS_ORDER) {
|
||||
const list = byStatus.get(status)
|
||||
if (!list || list.length === 0) continue
|
||||
items.push({ type: "status_header", status, count: list.length })
|
||||
if (groupExpanded[status]) {
|
||||
for (const conv of list) {
|
||||
items.push({ type: "conversation", conversation: conv })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
viewMode,
|
||||
orderedFolderIds,
|
||||
byFolder,
|
||||
folderIndex,
|
||||
branches,
|
||||
folderExpanded,
|
||||
byStatus,
|
||||
groupExpanded,
|
||||
])
|
||||
|
||||
const reviewConversations = useMemo(
|
||||
() => byStatus.get("pending_review") ?? [],
|
||||
[byStatus]
|
||||
)
|
||||
const reviewConversationCount = reviewConversations.length
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToActive() {
|
||||
@@ -230,45 +510,42 @@ export function SidebarConversationList({
|
||||
cancelled: false,
|
||||
})
|
||||
},
|
||||
revealFolder(folderId: number) {
|
||||
setFolderExpanded((prev) => {
|
||||
if (prev[folderId] === true) return prev
|
||||
const next = { ...prev, [folderId]: true }
|
||||
saveFolderExpanded(next)
|
||||
return next
|
||||
})
|
||||
setHighlightedFolder(folderId)
|
||||
if (highlightTimerRef.current) {
|
||||
window.clearTimeout(highlightTimerRef.current)
|
||||
}
|
||||
highlightTimerRef.current = window.setTimeout(() => {
|
||||
setHighlightedFolder(null)
|
||||
highlightTimerRef.current = null
|
||||
}, 1200)
|
||||
requestAnimationFrame(() => {
|
||||
const idx = flatItems.findIndex(
|
||||
(item) => item.type === "folder_header" && item.folderId === folderId
|
||||
)
|
||||
if (idx >= 0) {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: "start",
|
||||
smooth: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ConversationStatus, DbConversationSummary[]>()
|
||||
for (const conv of conversations) {
|
||||
const status = conv.status as ConversationStatus
|
||||
const list = map.get(status)
|
||||
if (list) {
|
||||
list.push(conv)
|
||||
} else {
|
||||
map.set(status, [conv])
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current) {
|
||||
window.clearTimeout(highlightTimerRef.current)
|
||||
}
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort(compareByUpdatedAtDesc)
|
||||
}
|
||||
return map
|
||||
}, [conversations])
|
||||
|
||||
const flatItems = useMemo<FlatItem[]>(() => {
|
||||
const items: FlatItem[] = []
|
||||
for (const status of STATUS_ORDER) {
|
||||
const list = grouped.get(status)
|
||||
if (!list || list.length === 0) continue
|
||||
items.push({ type: "header", status, count: list.length })
|
||||
if (groupExpanded[status]) {
|
||||
for (const conv of list) {
|
||||
items.push({ type: "conversation", conversation: conv })
|
||||
}
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [grouped, groupExpanded])
|
||||
|
||||
const reviewConversations = useMemo(
|
||||
() => grouped.get("pending_review") ?? [],
|
||||
[grouped]
|
||||
)
|
||||
const reviewConversationCount = reviewConversations.length
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToActiveRef.current = () => {
|
||||
@@ -285,6 +562,15 @@ export function SidebarConversationList({
|
||||
pendingScrollRef.current = true
|
||||
return
|
||||
}
|
||||
if (viewMode === "grouped" && !(folderExpanded[conv.folder_id] ?? true)) {
|
||||
setFolderExpanded((prev) => {
|
||||
const next = { ...prev, [conv.folder_id]: true }
|
||||
saveFolderExpanded(next)
|
||||
return next
|
||||
})
|
||||
pendingScrollRef.current = true
|
||||
return
|
||||
}
|
||||
const index = flatItems.findIndex(
|
||||
(item) =>
|
||||
item.type === "conversation" &&
|
||||
@@ -303,12 +589,72 @@ export function SidebarConversationList({
|
||||
pendingScrollRef.current = false
|
||||
scrollToActiveRef.current()
|
||||
}
|
||||
}, [selectedConversation, flatItems, conversations, groupExpanded])
|
||||
}, [
|
||||
selectedConversation,
|
||||
flatItems,
|
||||
conversations,
|
||||
groupExpanded,
|
||||
folderExpanded,
|
||||
viewMode,
|
||||
])
|
||||
|
||||
const toggleGroup = useCallback((status: ConversationStatus) => {
|
||||
setGroupExpanded((prev) => ({ ...prev, [status]: !prev[status] }))
|
||||
}, [])
|
||||
|
||||
const toggleFolder = useCallback((folderId: number) => {
|
||||
setFolderExpanded((prev) => {
|
||||
const next = { ...prev, [folderId]: !(prev[folderId] ?? true) }
|
||||
saveFolderExpanded(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusFolder = useCallback(
|
||||
(folderId: number) => {
|
||||
const idx = flatItems.findIndex(
|
||||
(item) => item.type === "folder_header" && item.folderId === folderId
|
||||
)
|
||||
if (idx >= 0) {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: "start",
|
||||
smooth: true,
|
||||
})
|
||||
}
|
||||
},
|
||||
[flatItems]
|
||||
)
|
||||
|
||||
const handleCloseFolderTabs = useCallback(
|
||||
(folderId: number) => {
|
||||
closeTabsByFolder(folderId)
|
||||
},
|
||||
[closeTabsByFolder]
|
||||
)
|
||||
|
||||
const handleRemoveFolder = useCallback(
|
||||
(folderId: number) => {
|
||||
const name = folderIndex.get(folderId)?.name ?? String(folderId)
|
||||
setRemoveConfirm({ folderId, folderName: name })
|
||||
},
|
||||
[folderIndex]
|
||||
)
|
||||
|
||||
const handleRemoveFolderConfirm = useCallback(async () => {
|
||||
if (!removeConfirm) return
|
||||
const { folderId, folderName } = removeConfirm
|
||||
try {
|
||||
closeTabsByFolder(folderId)
|
||||
await removeFolderFromWorkspace(folderId)
|
||||
toast.success(t("toasts.folderRemoved", { name: folderName }))
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error(t("toasts.removeFolderFailed", { message: msg }))
|
||||
} finally {
|
||||
setRemoveConfirm(null)
|
||||
}
|
||||
}, [removeConfirm, closeTabsByFolder, removeFolderFromWorkspace, t])
|
||||
|
||||
const handleOpenCompleteReview = useCallback(
|
||||
() => setCompleteReviewOpen(true),
|
||||
[]
|
||||
@@ -316,16 +662,34 @@ export function SidebarConversationList({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: number, agentType: string) => {
|
||||
openTab(id, agentType as Parameters<typeof openTab>[1], false)
|
||||
const conv = conversations.find(
|
||||
(c) => c.id === id && c.agent_type === agentType
|
||||
)
|
||||
if (!conv) return
|
||||
openTab(
|
||||
conv.folder_id,
|
||||
id,
|
||||
agentType as Parameters<typeof openTab>[2],
|
||||
false
|
||||
)
|
||||
},
|
||||
[openTab]
|
||||
[openTab, conversations]
|
||||
)
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(id: number, agentType: string) => {
|
||||
openTab(id, agentType as Parameters<typeof openTab>[1], true)
|
||||
const conv = conversations.find(
|
||||
(c) => c.id === id && c.agent_type === agentType
|
||||
)
|
||||
if (!conv) return
|
||||
openTab(
|
||||
conv.folder_id,
|
||||
id,
|
||||
agentType as Parameters<typeof openTab>[2],
|
||||
true
|
||||
)
|
||||
},
|
||||
[openTab]
|
||||
[openTab, conversations]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(
|
||||
@@ -338,11 +702,20 @@ export function SidebarConversationList({
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number, agentType: string) => {
|
||||
const conv = conversations.find(
|
||||
(c) => c.id === id && c.agent_type === agentType
|
||||
)
|
||||
await deleteConversation(id)
|
||||
closeConversationTab(id, agentType as Parameters<typeof openTab>[1])
|
||||
if (conv) {
|
||||
closeConversationTab(
|
||||
conv.folder_id,
|
||||
id,
|
||||
agentType as Parameters<typeof openTab>[2]
|
||||
)
|
||||
}
|
||||
refreshConversations()
|
||||
},
|
||||
[closeConversationTab, refreshConversations]
|
||||
[closeConversationTab, refreshConversations, conversations]
|
||||
)
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
@@ -354,18 +727,19 @@ export function SidebarConversationList({
|
||||
)
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (!folder) return
|
||||
openNewConversationTab(folder.path)
|
||||
}, [folder, openNewConversationTab])
|
||||
if (!activeFolder) return
|
||||
openNewConversationTab(activeFolder.id, activeFolder.path)
|
||||
}, [activeFolder, openNewConversationTab])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (importing) return
|
||||
if (!activeFolder) return
|
||||
setImporting(true)
|
||||
const taskId = `import-${folderId}-${Date.now()}`
|
||||
const taskId = `import-${activeFolder.id}-${Date.now()}`
|
||||
addTask(taskId, t("importLocalSessions"))
|
||||
updateTask(taskId, { status: "running" })
|
||||
try {
|
||||
const result = await importLocalConversations(folderId)
|
||||
const result = await importLocalConversations(activeFolder.id)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
refreshConversations()
|
||||
if (result.imported > 0) {
|
||||
@@ -385,13 +759,12 @@ export function SidebarConversationList({
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [importing, folderId, addTask, updateTask, refreshConversations, t])
|
||||
}, [importing, activeFolder, addTask, updateTask, refreshConversations, t])
|
||||
|
||||
const handleCompleteAllReview = useCallback(async () => {
|
||||
if (completingReview || reviewConversationCount === 0) return
|
||||
setCompletingReview(true)
|
||||
try {
|
||||
// Optimistic: update all locally first
|
||||
for (const conversation of reviewConversations) {
|
||||
updateConversationLocal(conversation.id, { status: "completed" })
|
||||
}
|
||||
@@ -407,7 +780,6 @@ export function SidebarConversationList({
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
toast.error(t("toasts.completeReviewFailed", { message: msg }))
|
||||
// Revert on error — refetch from server
|
||||
refreshConversations()
|
||||
} finally {
|
||||
setCompletingReview(false)
|
||||
@@ -421,6 +793,9 @@ export function SidebarConversationList({
|
||||
t,
|
||||
])
|
||||
|
||||
const emptyAfterSearch =
|
||||
filteredConversations.length === 0 && normalizedSearch.length > 0
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-h-0">
|
||||
{(loading || refreshing) && (
|
||||
@@ -451,7 +826,7 @@ export function SidebarConversationList({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={importing}
|
||||
disabled={importing || !activeFolder}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{importing ? (
|
||||
@@ -464,17 +839,29 @@ export function SidebarConversationList({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleNewConversation}>
|
||||
<ContextMenuItem
|
||||
onSelect={handleNewConversation}
|
||||
disabled={!activeFolder}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("newConversation")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={handleImport}>
|
||||
<ContextMenuItem
|
||||
disabled={importing || !activeFolder}
|
||||
onSelect={handleImport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? t("importing") : t("importLocalSessions")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : emptyAfterSearch ? (
|
||||
<div className="flex-1 flex items-center justify-center px-3">
|
||||
<p className="text-muted-foreground text-xs text-center">
|
||||
{t("noMatchingConversations")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -483,53 +870,87 @@ export function SidebarConversationList({
|
||||
className={cn("h-full min-h-0 px-2", "[overflow-anchor:none]")}
|
||||
>
|
||||
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
|
||||
{flatItems.map((item) => {
|
||||
const key =
|
||||
item.type === "header"
|
||||
? `header-${item.status}`
|
||||
: `conv-${item.conversation.id}`
|
||||
return (
|
||||
<div key={key}>
|
||||
{item.type === "header" ? (
|
||||
item.status === "pending_review" ? (
|
||||
<PendingReviewHeader
|
||||
count={item.count}
|
||||
isOpen={groupExpanded[item.status]}
|
||||
onToggle={toggleGroup}
|
||||
reviewConversationCount={reviewConversationCount}
|
||||
completingReview={completingReview}
|
||||
onCompleteReview={handleOpenCompleteReview}
|
||||
tStatus={tStatus}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<GroupHeader
|
||||
status={item.status}
|
||||
count={item.count}
|
||||
isOpen={groupExpanded[item.status]}
|
||||
onToggle={toggleGroup}
|
||||
tStatus={tStatus}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<SidebarConversationCard
|
||||
conversation={item.conversation}
|
||||
isSelected={
|
||||
selectedConversation?.agentType ===
|
||||
item.conversation.agent_type &&
|
||||
selectedConversation?.id === item.conversation.id
|
||||
}
|
||||
onSelect={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onStatusChange={handleStatusChange}
|
||||
onNewConversation={handleNewConversation}
|
||||
onImport={handleImport}
|
||||
importing={importing}
|
||||
{flatItems.map((item, index) => {
|
||||
if (item.type === "folder_header") {
|
||||
return (
|
||||
<FolderHeader
|
||||
key={`folder-${item.folderId}`}
|
||||
folderId={item.folderId}
|
||||
folderName={item.folderName}
|
||||
branch={item.branch}
|
||||
count={item.count}
|
||||
expanded={item.expanded}
|
||||
onToggle={toggleFolder}
|
||||
onFocus={focusFolder}
|
||||
onCloseFolderTabs={handleCloseFolderTabs}
|
||||
onRemoveFromWorkspace={handleRemoveFolder}
|
||||
highlighted={highlightedFolder === item.folderId}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const indented =
|
||||
viewMode === "grouped" &&
|
||||
(item.type === "status_header"
|
||||
? item.parentFolderId != null
|
||||
: true)
|
||||
if (item.type === "status_header") {
|
||||
const key = `status-${item.parentFolderId ?? "root"}-${item.status}-${index}`
|
||||
const headerNode =
|
||||
item.status === "pending_review" ? (
|
||||
<PendingReviewHeader
|
||||
key={key}
|
||||
count={item.count}
|
||||
isOpen={groupExpanded[item.status]}
|
||||
onToggle={toggleGroup}
|
||||
reviewConversationCount={reviewConversationCount}
|
||||
completingReview={completingReview}
|
||||
onCompleteReview={handleOpenCompleteReview}
|
||||
tStatus={tStatus}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<StatusHeader
|
||||
key={key}
|
||||
status={item.status}
|
||||
count={item.count}
|
||||
isOpen={groupExpanded[item.status]}
|
||||
onToggle={toggleGroup}
|
||||
tStatus={tStatus}
|
||||
/>
|
||||
)
|
||||
return indented ? (
|
||||
<div key={key} className="pl-4">
|
||||
{headerNode}
|
||||
</div>
|
||||
) : (
|
||||
headerNode
|
||||
)
|
||||
}
|
||||
const conv = item.conversation
|
||||
const cardNode = (
|
||||
<SidebarConversationCard
|
||||
conversation={conv}
|
||||
isSelected={
|
||||
selectedConversation?.agentType === conv.agent_type &&
|
||||
selectedConversation?.id === conv.id
|
||||
}
|
||||
onSelect={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onStatusChange={handleStatusChange}
|
||||
onNewConversation={handleNewConversation}
|
||||
onImport={handleImport}
|
||||
importing={importing}
|
||||
/>
|
||||
)
|
||||
return indented ? (
|
||||
<div key={`conv-${conv.id}`} className="pl-4">
|
||||
{cardNode}
|
||||
</div>
|
||||
) : (
|
||||
<div key={`conv-${conv.id}`}>{cardNode}</div>
|
||||
)
|
||||
})}
|
||||
</Virtualizer>
|
||||
@@ -537,12 +958,18 @@ export function SidebarConversationList({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={handleNewConversation}>
|
||||
<ContextMenuItem
|
||||
onSelect={handleNewConversation}
|
||||
disabled={!activeFolder}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("newConversation")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={importing} onSelect={handleImport}>
|
||||
<ContextMenuItem
|
||||
disabled={importing || !activeFolder}
|
||||
onSelect={handleImport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{importing ? t("importing") : t("importLocalSessions")}
|
||||
</ContextMenuItem>
|
||||
@@ -577,6 +1004,28 @@ export function SidebarConversationList({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={removeConfirm !== null}
|
||||
onOpenChange={(open) => !open && setRemoveConfirm(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("removeFolderConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("removeFolderConfirmDescription", {
|
||||
name: removeConfirm?.folderName ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRemoveFolderConfirm}>
|
||||
{tCommon("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
@@ -529,7 +529,7 @@ export function UnifiedDiffPreview({
|
||||
className?: string
|
||||
}) {
|
||||
const t = useTranslations("Folder.diffPreview")
|
||||
const { folder } = useFolderContext()
|
||||
const { activeFolder: folder } = useActiveFolder()
|
||||
const files = useMemo(() => parseUnifiedDiff(diffText), [diffText])
|
||||
|
||||
if (!diffText.trim()) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import dynamic from "next/dynamic"
|
||||
import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react"
|
||||
import type { editor as MonacoEditorNs } from "monaco-editor"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { ImagePreview } from "@/components/files/image-preview"
|
||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
||||
@@ -766,7 +766,7 @@ export function FileWorkspacePanel() {
|
||||
saveActiveFile,
|
||||
updateActiveFileContent,
|
||||
} = useWorkspaceContext()
|
||||
const { folder } = useFolderContext()
|
||||
const { activeFolder: folder } = useActiveFolder()
|
||||
const folderPath = folder?.path ?? null
|
||||
const activeScope = activeFileTab?.id ?? "__default__"
|
||||
const editorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(null)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Reorder } from "motion/react"
|
||||
import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { openPath } from "@/lib/platform"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
@@ -33,7 +33,7 @@ export function FileWorkspaceTabBar() {
|
||||
previewFileTabIds,
|
||||
toggleFileTabPreview,
|
||||
} = useWorkspaceContext()
|
||||
const { folder } = useFolderContext()
|
||||
const { activeFolder: folder } = useActiveFolder()
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ?? [],
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import {
|
||||
createShadcnProject,
|
||||
openFolderWindow,
|
||||
openFolder,
|
||||
detectPackageManager,
|
||||
} from "@/lib/api"
|
||||
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
|
||||
@@ -122,7 +122,7 @@ export function CreateProjectDialog({
|
||||
toast.success(t("toasts.createSuccess"))
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
await openFolderWindow(projectPath)
|
||||
await openFolder(projectPath)
|
||||
} catch (err) {
|
||||
const appErr = extractAppCommandError(err)
|
||||
const message =
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
expertsOpenCentralDir,
|
||||
expertsReadContent,
|
||||
expertsUnlinkFromAgent,
|
||||
openFolderWindow,
|
||||
openFolder,
|
||||
} from "@/lib/api"
|
||||
import { invalidateAgentExpertsCache } from "@/hooks/use-agent-experts"
|
||||
import type {
|
||||
@@ -369,7 +369,7 @@ export function ExpertsSettings() {
|
||||
const handleOpenCentralDir = useCallback(async () => {
|
||||
try {
|
||||
const path = await expertsOpenCentralDir()
|
||||
await openFolderWindow(path)
|
||||
await openFolder(path)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("toasts.openFolderFailed"), { description: message })
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
acpListAgents,
|
||||
acpListAgentSkills,
|
||||
loadFolderHistory,
|
||||
openFolderWindow,
|
||||
openFolder,
|
||||
acpReadAgentSkill,
|
||||
acpSaveAgentSkill,
|
||||
} from "@/lib/api"
|
||||
@@ -475,7 +475,7 @@ export function SkillsSettings() {
|
||||
async (skill: AgentSkillItem) => {
|
||||
const dirPath = skillDirectoryPath(skill)
|
||||
try {
|
||||
await openFolderWindow(dirPath)
|
||||
await openFolder(dirPath)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("toasts.openFolderFailed"), { description: message })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Reorder } from "motion/react"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
@@ -18,11 +19,25 @@ export function TabBar() {
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
closeTabsByFolder,
|
||||
pinTab,
|
||||
toggleTileMode,
|
||||
reorderTabs,
|
||||
} = useTabContext()
|
||||
const { folders, branches } = useAppWorkspace()
|
||||
const { mode, activePane } = useWorkspaceContext()
|
||||
|
||||
const folderIndex = useMemo(() => {
|
||||
const map = new Map<number, { name: string }>()
|
||||
for (const f of folders) map.set(f.id, { name: f.name })
|
||||
return map
|
||||
}, [folders])
|
||||
|
||||
const handleRevealInSidebar = useCallback((folderId: number) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sidebar:reveal-folder", { detail: { folderId } })
|
||||
)
|
||||
}, [])
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
@@ -86,20 +101,27 @@ export function TabBar() {
|
||||
: ["pb-1.5", "[&::-webkit-scrollbar]:h-0"]
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isTileMode={isTileMode}
|
||||
onSwitch={switchTab}
|
||||
onClose={closeTab}
|
||||
onCloseOthers={closeOtherTabs}
|
||||
onCloseAll={closeAllTabs}
|
||||
onPin={pinTab}
|
||||
onToggleTile={toggleTileMode}
|
||||
/>
|
||||
))}
|
||||
{tabs.map((tab) => {
|
||||
const folderInfo = folderIndex.get(tab.folderId)
|
||||
return (
|
||||
<TabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isTileMode={isTileMode}
|
||||
folderName={folderInfo?.name ?? null}
|
||||
folderBranch={branches.get(tab.folderId) ?? null}
|
||||
onSwitch={switchTab}
|
||||
onClose={closeTab}
|
||||
onCloseOthers={closeOtherTabs}
|
||||
onCloseAll={closeAllTabs}
|
||||
onCloseFolderTabs={closeTabsByFolder}
|
||||
onRevealInSidebar={handleRevealInSidebar}
|
||||
onPin={pinTab}
|
||||
onToggleTile={toggleTileMode}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Reorder.Group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,14 @@ interface TabItemProps {
|
||||
tab: TabItemData
|
||||
isActive: boolean
|
||||
isTileMode: boolean
|
||||
folderName: string | null
|
||||
folderBranch: string | null
|
||||
onSwitch: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseAll: () => void
|
||||
onCloseFolderTabs: (folderId: number) => void
|
||||
onRevealInSidebar: (folderId: number) => void
|
||||
onPin: (tabId: string) => void
|
||||
onToggleTile: () => void
|
||||
}
|
||||
@@ -32,10 +36,14 @@ export const TabItem = memo(function TabItem({
|
||||
tab,
|
||||
isActive,
|
||||
isTileMode,
|
||||
folderName,
|
||||
folderBranch,
|
||||
onSwitch,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
onCloseFolderTabs,
|
||||
onRevealInSidebar,
|
||||
onPin,
|
||||
onToggleTile,
|
||||
}: TabItemProps) {
|
||||
@@ -43,6 +51,19 @@ export const TabItem = memo(function TabItem({
|
||||
const isDragging = useRef(false)
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const resolvedFolderName = folderName ?? String(tab.folderId)
|
||||
const tooltip = folderBranch
|
||||
? `${resolvedFolderName} · ${folderBranch} — ${tab.title}`
|
||||
: `${resolvedFolderName} — ${tab.title}`
|
||||
|
||||
const handleCloseFolderTabs = useCallback(() => {
|
||||
onCloseFolderTabs(tab.folderId)
|
||||
}, [onCloseFolderTabs, tab.folderId])
|
||||
|
||||
const handleRevealInSidebar = useCallback(() => {
|
||||
onRevealInSidebar(tab.folderId)
|
||||
}, [onRevealInSidebar, tab.folderId])
|
||||
|
||||
const clearResidualStyles = useCallback(() => {
|
||||
const el = itemRef.current
|
||||
if (!el) return
|
||||
@@ -119,7 +140,7 @@ export const TabItem = memo(function TabItem({
|
||||
"truncate max-w-[140px]",
|
||||
!tab.isPinned && "[font-style:oblique]"
|
||||
)}
|
||||
title={tab.title}
|
||||
title={tooltip}
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
@@ -146,7 +167,13 @@ export const TabItem = memo(function TabItem({
|
||||
<ContextMenuItem onSelect={handleCloseOthers}>
|
||||
{t("closeOthers")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={handleCloseFolderTabs}>
|
||||
{t("closeFolderTabs", { folder: resolvedFolderName })}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={handleRevealInSidebar}>
|
||||
{t("revealInSidebar")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onToggleTile}>
|
||||
{isTileMode ? t("untileDisplay") : t("tileDisplay")}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { Minus, Plus, X } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import { useIsMac } from "@/hooks/use-is-mac"
|
||||
@@ -15,6 +17,13 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import { FolderBadge } from "@/components/ui/folder-badge"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
export function TerminalTabBar() {
|
||||
const t = useTranslations("Folder.terminal")
|
||||
@@ -31,6 +40,16 @@ export function TerminalTabBar() {
|
||||
createTerminal,
|
||||
toggle,
|
||||
} = useTerminalContext()
|
||||
const { activeFolderId } = useActiveFolder()
|
||||
const { folders } = useAppWorkspace()
|
||||
|
||||
const folderIndex = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const f of folders) map.set(f.id, f.name)
|
||||
return map
|
||||
}, [folders])
|
||||
|
||||
const canCreateTerminal = activeFolderId != null
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState("")
|
||||
@@ -62,6 +81,13 @@ export function TerminalTabBar() {
|
||||
}`}
|
||||
onClick={() => switchTerminal(tab.id)}
|
||||
>
|
||||
<FolderBadge
|
||||
folderId={tab.folderId}
|
||||
folderName={
|
||||
folderIndex.get(tab.folderId) ?? String(tab.folderId)
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
{editingId === tab.id ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -108,14 +134,26 @@ export function TerminalTabBar() {
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => createTerminal()}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => createTerminal()}
|
||||
disabled={!canCreateTerminal}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canCreateTerminal && (
|
||||
<TooltipContent side="top">{t("openFolderFirst")}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { memo } from "react"
|
||||
import { folderBadgeColor, folderBadgeLabel } from "@/lib/folder-badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface FolderBadgeProps {
|
||||
folderId: number
|
||||
folderName: string
|
||||
/** Size variant: sm ~= tab bar (14px), md ~= sidebar card (16px). */
|
||||
size?: "sm" | "md"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const FolderBadge = memo(function FolderBadge({
|
||||
folderId,
|
||||
folderName,
|
||||
size = "sm",
|
||||
className,
|
||||
}: FolderBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
aria-label={folderName}
|
||||
title={folderName}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center shrink-0 rounded text-white font-medium",
|
||||
size === "sm" ? "w-3.5 h-3.5 text-[9px]" : "w-4 h-4 text-[10px]",
|
||||
folderBadgeColor(folderId),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{folderBadgeLabel(folderName)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Settings } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
import { FolderList } from "@/components/welcome/folder-list"
|
||||
import { FolderActions } from "@/components/welcome/folder-actions"
|
||||
import { SoftwareInfo } from "@/components/welcome/software-info"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const t = useTranslations("WelcomePage")
|
||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const isMobile = useIsMobile()
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
openSettingsWindow().catch((err) => {
|
||||
console.error("[WelcomeScreen] failed to open settings:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
toast.error(t("toasts.openSettingsFailed"), {
|
||||
description: resolvedError.detail ?? t(resolvedError.key),
|
||||
})
|
||||
})
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (matchShortcutEvent(e, shortcuts.open_settings)) {
|
||||
e.preventDefault()
|
||||
handleOpenSettings()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [shortcuts, handleOpenSettings])
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
setHistory(await loadFolderHistory())
|
||||
} catch (err) {
|
||||
console.error("[WelcomeScreen] failed to load folder history:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
toast.error(t("toasts.loadFolderHistoryFailed"), {
|
||||
description: resolvedError.detail ?? t(resolvedError.key),
|
||||
})
|
||||
setHistory([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
refreshHistory()
|
||||
}, [refreshHistory])
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<span className="text-sm font-bold tracking-tight">{t("title")}</span>
|
||||
}
|
||||
right={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-foreground/80"
|
||||
onClick={handleOpenSettings}
|
||||
title={t("openSettings")}
|
||||
aria-label={t("openSettings")}
|
||||
type="button"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
? "flex-1 flex flex-col overflow-hidden"
|
||||
: "flex-1 flex overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
? "shrink-0 flex flex-col border-b"
|
||||
: "w-60 shrink-0 flex flex-col border-r"
|
||||
}
|
||||
>
|
||||
<SoftwareInfo />
|
||||
<FolderActions />
|
||||
</div>
|
||||
<FolderList
|
||||
history={history}
|
||||
loading={loading}
|
||||
onRefresh={refreshHistory}
|
||||
/>
|
||||
</div>
|
||||
<AppToaster position="bottom-right" closeButton duration={4000} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+5
-3
@@ -3,8 +3,9 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { cloneRepository, openFolderWindow } from "@/lib/api"
|
||||
import { cloneRepository } from "@/lib/api"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useGitCredential } from "@/contexts/git-credential-context"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FolderOpen, Loader2 } from "lucide-react"
|
||||
import { resolveCloneError } from "@/components/welcome/error-utils"
|
||||
import { resolveCloneError } from "@/components/workspace-empty/error-utils"
|
||||
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||
|
||||
interface CloneDialogProps {
|
||||
@@ -28,6 +29,7 @@ interface CloneDialogProps {
|
||||
export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
||||
const t = useTranslations("WelcomePage")
|
||||
const { withCredentialRetry } = useGitCredential()
|
||||
const { openFolder } = useAppWorkspace()
|
||||
const [url, setUrl] = useState("")
|
||||
const [targetDir, setTargetDir] = useState("")
|
||||
const [cloning, setCloning] = useState(false)
|
||||
@@ -73,7 +75,7 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
||||
(creds) => cloneRepository(url, fullPath, creds),
|
||||
{ remoteUrl: url }
|
||||
)
|
||||
await openFolderWindow(fullPath)
|
||||
await openFolder(fullPath)
|
||||
onOpenChange(false)
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
+6
-4
@@ -4,15 +4,17 @@ import { useState } from "react"
|
||||
import { FolderOpen, GitBranch, Rocket } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { openFolderWindow, openProjectBootWindow } from "@/lib/api"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { openProjectBootWindow } from "@/lib/api"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CloneDialog } from "./clone-dialog"
|
||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||
import { resolveWelcomeError } from "@/components/workspace-empty/error-utils"
|
||||
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
|
||||
|
||||
export function FolderActions() {
|
||||
const t = useTranslations("WelcomePage")
|
||||
const { openFolder } = useAppWorkspace()
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [browserOpen, setBrowserOpen] = useState(false)
|
||||
|
||||
@@ -25,7 +27,7 @@ export function FolderActions() {
|
||||
if (!result) return
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
try {
|
||||
await openFolderWindow(selected)
|
||||
await openFolder(selected)
|
||||
} catch (err) {
|
||||
console.error("[FolderActions] failed to open folder:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
@@ -40,7 +42,7 @@ export function FolderActions() {
|
||||
|
||||
const handleBrowserSelect = async (path: string) => {
|
||||
try {
|
||||
await openFolderWindow(path)
|
||||
await openFolder(path)
|
||||
} catch (err) {
|
||||
console.error("[FolderActions] failed to open folder:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
+5
-3
@@ -6,10 +6,11 @@ import { formatDistanceToNow } from "date-fns"
|
||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { openFolderWindow, removeFolderFromHistory } from "@/lib/api"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { removeFolderFromHistory } from "@/lib/api"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||
import { resolveWelcomeError } from "@/components/workspace-empty/error-utils"
|
||||
|
||||
interface FolderListProps {
|
||||
history: FolderHistoryEntry[]
|
||||
@@ -20,6 +21,7 @@ interface FolderListProps {
|
||||
export function FolderList({ history, loading, onRefresh }: FolderListProps) {
|
||||
const t = useTranslations("WelcomePage")
|
||||
const locale = useLocale()
|
||||
const { openFolder } = useAppWorkspace()
|
||||
const [search, setSearch] = useState("")
|
||||
const dateFnsLocale =
|
||||
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS
|
||||
@@ -37,7 +39,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
|
||||
|
||||
const handleOpen = async (path: string) => {
|
||||
try {
|
||||
await openFolderWindow(path)
|
||||
await openFolder(path)
|
||||
} catch (err) {
|
||||
console.error("Failed to open folder:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { loadFolderHistory } from "@/lib/api"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
import { FolderActions } from "./folder-actions"
|
||||
import { FolderList } from "./folder-list"
|
||||
import { SoftwareInfo } from "./software-info"
|
||||
|
||||
export function WorkspaceEmpty() {
|
||||
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await loadFolderHistory()
|
||||
setHistory(result)
|
||||
} catch (err) {
|
||||
console.error("[WorkspaceEmpty] loadFolderHistory failed:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r">
|
||||
<SoftwareInfo />
|
||||
<FolderActions />
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0">
|
||||
<FolderList history={history} loading={loading} onRefresh={refresh} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import type { AgentType } from "@/lib/types"
|
||||
|
||||
/**
|
||||
* Handles `/workspace?folderId=X&conversationId=Y&agent=Z` URLs.
|
||||
* Runs once after both folders and tabs have hydrated.
|
||||
*/
|
||||
export function DeepLinkBootstrap() {
|
||||
const { foldersHydrated, folders, addFolderToWorkspaceById, conversations } =
|
||||
useAppWorkspace()
|
||||
const { tabsHydrated, openTab } = useTabContext()
|
||||
const ranRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) return
|
||||
if (!foldersHydrated || !tabsHydrated) return
|
||||
ranRef.current = true
|
||||
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const rawFolderId = params.get("folderId")
|
||||
const rawConversationId = params.get("conversationId")
|
||||
const rawAgent = params.get("agent") as AgentType | null
|
||||
|
||||
if (!rawFolderId && !rawConversationId) return
|
||||
|
||||
const clearUrl = () => {
|
||||
try {
|
||||
window.history.replaceState({}, "", "/workspace")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const folderId = rawFolderId ? Number(rawFolderId) : null
|
||||
const conversationId = rawConversationId
|
||||
? Number(rawConversationId)
|
||||
: null
|
||||
|
||||
if (folderId == null || !Number.isFinite(folderId)) return
|
||||
if (conversationId == null || !Number.isFinite(conversationId)) return
|
||||
if (!rawAgent) return
|
||||
|
||||
let folder = folders.find((f) => f.id === folderId)
|
||||
if (!folder) {
|
||||
try {
|
||||
folder = await addFolderToWorkspaceById(folderId)
|
||||
} catch (err) {
|
||||
console.error("[DeepLinkBootstrap] open folder failed:", err)
|
||||
toast.error("Unable to open linked folder")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasConv = conversations.some(
|
||||
(c) =>
|
||||
c.id === conversationId &&
|
||||
c.folder_id === folderId &&
|
||||
c.agent_type === rawAgent
|
||||
)
|
||||
if (!hasConv) {
|
||||
toast.error("Linked conversation not found")
|
||||
return
|
||||
}
|
||||
|
||||
openTab(folderId, conversationId, rawAgent, true)
|
||||
} finally {
|
||||
clearUrl()
|
||||
}
|
||||
})()
|
||||
}, [
|
||||
foldersHydrated,
|
||||
tabsHydrated,
|
||||
folders,
|
||||
conversations,
|
||||
addFolderToWorkspaceById,
|
||||
openTab,
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user