Files
codeg/src/components/layout/command-dropdown.tsx
xintaofei d9323d7399 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
2026-04-20 21:22:36 +08:00

321 lines
9.2 KiB
TypeScript

"use client"
import { useState, useEffect, useCallback, useMemo, useRef } from "react"
import { ChevronDown, Play, Plus, Square } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import {
bootstrapFolderCommandsFromPackageJson,
listFolderCommands,
terminalKill,
} from "@/lib/api"
import type { FolderCommand } from "@/lib/types"
import { CommandManageDialog } from "./command-manage-dialog"
function getSelectedCommandId(folderId: number): number | null {
try {
const v = localStorage.getItem(`lastCmd:${folderId}`)
return v ? Number(v) : null
} catch {
return null
}
}
function setSelectedCommandId(folderId: number, cmdId: number) {
try {
localStorage.setItem(`lastCmd:${folderId}`, String(cmdId))
} catch {
/* ignore */
}
}
export function CommandDropdown() {
const t = useTranslations("Folder.commandDropdown")
const { activeFolder: folder } = useActiveFolder()
const {
createTerminalWithCommand,
exitedTerminals,
tabs: terminalTabs,
} = useTerminalContext()
const [commands, setCommands] = useState<FolderCommand[]>([])
const [manageOpen, setManageOpen] = useState(false)
const [bootstrapping, setBootstrapping] = useState(false)
const [selectedCommandId, setSelectedCommandIdState] = useState<
number | null
>(null)
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
Record<number, string>
>({})
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
const folderId = folder?.id ?? 0
const folderPath = folder?.path ?? ""
useEffect(() => {
runningCommandTerminalsRef.current = runningCommandTerminals
}, [runningCommandTerminals])
// React to process exits reported by the terminal context
useEffect(() => {
if (exitedTerminals.size === 0) return
setRunningCommandTerminals((prev) => {
if (Object.keys(prev).length === 0) return prev
let changed = false
const next = { ...prev }
for (const [cmdId, termId] of Object.entries(prev)) {
if (exitedTerminals.has(termId)) {
delete next[Number(cmdId)]
changed = true
}
}
return changed ? next : prev
})
}, [exitedTerminals])
// React to terminal tabs being closed (e.g. user closes the tab directly)
useEffect(() => {
setRunningCommandTerminals((prev) => {
if (Object.keys(prev).length === 0) return prev
const tabIds = new Set(terminalTabs.map((t) => t.id))
let changed = false
const next = { ...prev }
for (const [cmdId, termId] of Object.entries(prev)) {
if (!tabIds.has(termId)) {
delete next[Number(cmdId)]
changed = true
}
}
return changed ? next : prev
})
}, [terminalTabs])
const selectCommand = useCallback(
(commandId: number) => {
if (!folderId) return
setSelectedCommandId(folderId, commandId)
setSelectedCommandIdState(commandId)
},
[folderId]
)
useEffect(() => {
if (!folderId) {
setSelectedCommandIdState(null)
setRunningCommandTerminals({})
return
}
setSelectedCommandIdState(getSelectedCommandId(folderId))
setRunningCommandTerminals({})
}, [folderId])
const refreshCommands = useCallback(async () => {
if (!folderId) return
try {
setCommands(await listFolderCommands(folderId))
} catch (err) {
console.error("Failed to load commands:", err)
}
}, [folderId])
useEffect(() => {
if (!folderId) return
let ignore = false
const loadCommands = async () => {
try {
setBootstrapping(false)
const data = await listFolderCommands(folderId)
if (ignore) return
if (data.length > 0 || !folderPath) {
setCommands(data)
return
}
setBootstrapping(true)
const bootstrapped = await bootstrapFolderCommandsFromPackageJson(
folderId,
folderPath
)
if (!ignore) setCommands(bootstrapped)
} catch (err) {
console.error("Failed to load commands:", err)
} finally {
if (!ignore) setBootstrapping(false)
}
}
loadCommands()
return () => {
ignore = true
}
}, [folderId, folderPath])
const runCommand = useCallback(
async (cmd: FolderCommand) => {
if (!folderPath) return
if (runningCommandTerminalsRef.current[cmd.id]) return
selectCommand(cmd.id)
const terminalId = await createTerminalWithCommand(cmd.name, cmd.command)
if (!terminalId) return
setRunningCommandTerminals((prev) => ({ ...prev, [cmd.id]: terminalId }))
},
[createTerminalWithCommand, folderPath, selectCommand]
)
const stopCommand = useCallback(async (cmd: FolderCommand) => {
const terminalId = runningCommandTerminalsRef.current[cmd.id]
if (!terminalId) return
setRunningCommandTerminals((prev) => {
if (!(cmd.id in prev)) return prev
const next = { ...prev }
delete next[cmd.id]
return next
})
try {
await terminalKill(terminalId)
} catch (err) {
console.error("Failed to stop command terminal:", err)
}
}, [])
const activeCmd = useMemo(
() =>
commands.find((c) => c.id === selectedCommandId) ?? commands[0] ?? null,
[commands, selectedCommandId]
)
const activeTerminalId = activeCmd
? runningCommandTerminals[activeCmd.id]
: undefined
const isActiveCommandRunning = Boolean(activeTerminalId)
useEffect(() => {
if (!activeCmd && selectedCommandId !== null) {
setSelectedCommandIdState(null)
return
}
if (!activeCmd || selectedCommandId === activeCmd.id) return
selectCommand(activeCmd.id)
}, [activeCmd, selectedCommandId, selectCommand])
const handleRunOrStop = useCallback(() => {
if (!activeCmd) return
if (isActiveCommandRunning) {
void stopCommand(activeCmd)
return
}
void runCommand(activeCmd)
}, [activeCmd, isActiveCommandRunning, runCommand, stopCommand])
const handleSelectCommand = useCallback(
(cmd: FolderCommand) => {
selectCommand(cmd.id)
},
[selectCommand]
)
if (!folder) return null
// No commands → show add command button
if (commands.length === 0) {
return (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1 hover:text-foreground/80"
onClick={() => setManageOpen(true)}
disabled={bootstrapping}
>
<Plus className="h-3 w-3" />
{bootstrapping ? t("loading") : t("addCommand")}
</Button>
<CommandManageDialog
open={manageOpen}
onOpenChange={setManageOpen}
folderId={folderId}
commands={commands}
onSaved={refreshCommands}
/>
</>
)
}
// Has commands → split button: [name ▼] [run/stop]
return (
<>
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-6 hover:text-foreground/80">
<span className="max-w-24 truncate">{activeCmd?.name}</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
{commands.map((cmd) => (
<DropdownMenuItem
key={cmd.id}
onClick={() => handleSelectCommand(cmd)}
className={`flex items-center justify-between gap-4 ${
cmd.id === activeCmd?.id ? "bg-accent/60" : ""
}`}
>
<span className="truncate">{cmd.name}</span>
<span className="text-xs text-muted-foreground font-mono truncate max-w-32">
{cmd.command}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setManageOpen(true)}>
{t("manageCommands")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs gap-1 ${
isActiveCommandRunning
? "text-destructive hover:text-destructive"
: "hover:text-foreground/80"
}`}
onClick={handleRunOrStop}
title={
isActiveCommandRunning
? t("stopCommandTitle", { command: activeCmd?.command ?? "" })
: t("runCommandTitle", { command: activeCmd?.command ?? "" })
}
>
{isActiveCommandRunning ? (
<Square className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
</div>
<CommandManageDialog
open={manageOpen}
onOpenChange={setManageOpen}
folderId={folderId}
commands={commands}
onSaved={refreshCommands}
/>
</>
)
}