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
321 lines
9.2 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|