Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
"use client"
import type { ReactNode } from "react"
import { usePlatform } from "@/hooks/use-platform"
import { cn } from "@/lib/utils"
import { WindowControls } from "./window-controls"
interface AppTitleBarProps {
left?: ReactNode
center?: ReactNode
right?: ReactNode
className?: string
rowClassName?: string
centerInteractive?: boolean
showWindowControls?: boolean
}
export function AppTitleBar({
left,
center,
right,
className,
rowClassName,
centerInteractive = false,
showWindowControls = true,
}: AppTitleBarProps) {
const { isMac, isWindows } = usePlatform()
const rowPadding = cn(
"px-3",
isMac && "pl-[76px]",
isWindows && showWindowControls && "pr-[138px]"
)
return (
<div
className={cn(
"relative h-8 shrink-0 border-b bg-muted/70 select-none",
className
)}
>
<div data-tauri-drag-region className="absolute inset-0" />
<div
data-tauri-drag-region
className={cn(
"relative z-10 flex h-full items-center",
rowPadding,
rowClassName
)}
>
<div className="min-w-0 flex-1">{left}</div>
{right ? (
<div
className={cn(
"ml-auto shrink-0",
isWindows && showWindowControls && "mr-4"
)}
>
{right}
</div>
) : null}
</div>
{center ? (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className={cn(centerInteractive && "pointer-events-auto")}>
{center}
</div>
</div>
) : null}
{showWindowControls && isWindows ? (
<div className="absolute right-0 top-0 z-30">
<WindowControls />
</div>
) : null}
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { ChevronRight, FileIcon } from "lucide-react"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
import type { LiveMessage } from "@/contexts/acp-connections-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useConnection } from "@/hooks/use-connection"
import { useDbMessageDetail } from "@/hooks/use-db-message-detail"
import { extractSessionFilesGrouped } from "@/lib/session-files"
import { getPendingPromptText } from "@/lib/pending-prompt-text"
import {
inferLiveToolName,
normalizeToolName,
} from "@/lib/tool-call-normalization"
import type { ConnectionStatus, MessageTurn } from "@/lib/types"
import {
CommitFileAdditions,
CommitFileDeletions,
} from "@/components/ai-elements/commit"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
const LIVE_FILE_WRITE_OPS = new Set(["edit", "write", "apply_patch"])
function isRemovedFileDiff(diff: string | null): boolean {
if (!diff) return false
return (
/^\*\*\* Delete File:\s+/m.test(diff) ||
/^deleted file mode\b/m.test(diff) ||
/^\+\+\+\s+\/dev\/null$/m.test(diff)
)
}
function normalizeSlashPath(path: string): string {
return path.replace(/\\/g, "/")
}
function toFolderRelativePath(filePath: string, folderPath?: string): string {
const normalizedFilePath = normalizeSlashPath(filePath)
if (!folderPath) return normalizedFilePath
const normalizedFolderPath = normalizeSlashPath(folderPath).replace(
/\/+$/,
""
)
if (!normalizedFolderPath) return normalizedFilePath
const folderPrefix = `${normalizedFolderPath}/`
if (normalizedFilePath.startsWith(folderPrefix)) {
return normalizedFilePath.slice(folderPrefix.length)
}
return normalizedFilePath
}
function extractTurnText(turn: MessageTurn | null): string | null {
if (!turn || turn.role !== "user") return null
for (const block of turn.blocks) {
if (block.type !== "text") continue
const text = block.text.trim()
if (text) return text
}
return null
}
function mergeLiveTurns(params: {
turns: MessageTurn[]
liveMessage: LiveMessage | null
connStatus: ConnectionStatus | null
pendingPromptText: string | null
}): MessageTurn[] {
const { turns, liveMessage, connStatus, pendingPromptText } = params
if (!liveMessage || connStatus !== "prompting") return turns
const liveBlocks = liveMessage.content.flatMap((block) => {
if (block.type !== "tool_call") return []
const toolName = inferLiveToolName({
title: block.info.title,
kind: block.info.kind,
rawInput: block.info.raw_input,
})
const normalizedToolName = normalizeToolName(toolName)
if (!LIVE_FILE_WRITE_OPS.has(normalizedToolName)) return []
return [
{
type: "tool_use" as const,
tool_use_id: block.info.tool_call_id,
tool_name: toolName,
input_preview: block.info.raw_input,
},
]
})
if (liveBlocks.length === 0) return turns
const now = new Date().toISOString()
const mergedTurns = [...turns]
const lastTurn = mergedTurns[mergedTurns.length - 1]
const lastUserTurn =
[...mergedTurns].reverse().find((turn) => turn.role === "user") ?? null
const pendingText = pendingPromptText?.trim() ?? ""
const shouldReuseExistingUserTurn =
pendingText.length > 0 && extractTurnText(lastUserTurn) === pendingText
if ((!lastTurn || lastTurn.role !== "user") && !shouldReuseExistingUserTurn) {
mergedTurns.push({
id: `live-user-${liveMessage.id}`,
role: "user",
blocks: [
{ type: "text", text: pendingPromptText?.trim() || "Current response" },
],
timestamp: now,
})
}
mergedTurns.push({
id: `live-assistant-${liveMessage.id}`,
role: "assistant",
blocks: liveBlocks,
timestamp: now,
})
return mergedTurns
}
function SessionFilesContent({
conversationId,
liveMessage,
connStatus,
pendingPromptText,
}: {
conversationId: number
liveMessage: LiveMessage | null
connStatus: ConnectionStatus | null
pendingPromptText: string | null
}) {
const { detail, loading, refetch } = useDbMessageDetail(conversationId)
const { openSessionFileDiff } = useWorkspaceContext()
const { folder } = useFolderContext()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
const prevStatusRef = useRef(connStatus)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = connStatus
if (prev === "prompting" && connStatus && connStatus !== "prompting") {
refetch()
}
}, [connStatus, refetch])
const turns = useMemo(
() =>
mergeLiveTurns({
turns: detail?.turns ?? [],
liveMessage,
connStatus,
pendingPromptText,
}),
[detail?.turns, liveMessage, connStatus, pendingPromptText]
)
const groups = useMemo(
() => (turns.length > 0 ? extractSessionFilesGrouped(turns) : []),
[turns]
)
const handleFileClick = (
filePath: string,
diffContent: string | null,
groupIndex: number,
changeIndex: number
) => {
openSessionFileDiff(
filePath,
diffContent ?? `No diff data available for ${filePath}`,
`msg-${groupIndex + 1}-chg-${changeIndex + 1}`
)
}
if (loading) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">Loading...</p>
</div>
)
}
if (groups.length === 0) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
No file changes found in this conversation
</p>
</div>
)
}
return (
<div className="space-y-3 p-3">
{groups.map((group, groupIndex) => {
const groupKey = `${group.userTurnId}-${group.timestamp}-${groupIndex}`
const isOpen = openGroups[groupKey] ?? false
const totalAdditions = group.files.reduce(
(sum, f) => sum + f.additions,
0
)
const totalDeletions = group.files.reduce(
(sum, f) => sum + f.deletions,
0
)
const uniqueFileCount = new Set(
group.files.map((file) => file.path.replace(/\\/g, "/"))
).size
return (
<Collapsible
key={groupKey}
className="overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
open={isOpen}
onOpenChange={(open) =>
setOpenGroups((prev) => ({
...prev,
[groupKey]: open,
}))
}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-accent/40"
>
<span
className={cn(
"mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30 text-muted-foreground transition-colors",
isOpen && "bg-accent text-accent-foreground"
)}
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
isOpen && "rotate-90"
)}
/>
</span>
<div className="flex-1 min-w-0">
<p className="line-clamp-1 text-xs leading-5 text-foreground">
{group.userMessage}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{group.files.length}{" "}
{group.files.length === 1 ? "change" : "changes"}
</span>
<span className="rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{uniqueFileCount}{" "}
{uniqueFileCount === 1 ? "File" : "Files"}
</span>
<span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-foreground">
<CommitFileAdditions
count={totalAdditions}
className="text-[10px]"
/>
<CommitFileDeletions
count={totalDeletions}
className="text-[10px]"
/>
</span>
</div>
</div>
<span className="mt-0.5 shrink-0 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] tabular-nums text-muted-foreground">
#{groupIndex + 1}
</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border bg-card">
<ul className="space-y-2 p-3">
{group.files.map((file, fileIndex) => {
const normalizedDisplayPath = toFolderRelativePath(
file.path,
folder?.path
)
const lastSlash = normalizedDisplayPath.lastIndexOf("/")
const fileName =
lastSlash >= 0
? normalizedDisplayPath.slice(lastSlash + 1)
: normalizedDisplayPath
const isRemoved = isRemovedFileDiff(file.diff)
return (
<li key={file.id}>
<button
type="button"
className={cn(
"flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left min-w-0",
isRemoved
? "border-destructive/30 bg-destructive/10 cursor-not-allowed"
: "border-border bg-card transition-colors hover:bg-accent/40"
)}
disabled={isRemoved}
onClick={
isRemoved
? undefined
: () =>
handleFileClick(
file.path,
file.diff,
groupIndex,
fileIndex
)
}
title={normalizedDisplayPath}
>
<FileIcon
className={cn(
"h-3.5 w-3.5 shrink-0",
isRemoved
? "text-destructive"
: "text-muted-foreground"
)}
/>
<p
className={cn(
"min-w-0 flex-1 truncate text-xs",
isRemoved ? "text-destructive" : "text-foreground"
)}
>
{fileName}
</p>
{isRemoved ? (
<span className="inline-flex shrink-0 items-center rounded-md border border-destructive/30 bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] text-destructive">
Remove
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-1.5 py-0.5 font-mono text-[10px] text-foreground">
<CommitFileAdditions
count={file.additions}
className="text-[10px]"
/>
<CommitFileDeletions
count={file.deletions}
className="text-[10px]"
/>
</span>
)}
</button>
</li>
)
})}
</ul>
</CollapsibleContent>
</Collapsible>
)
})}
</div>
)
}
export function SessionFilesTab() {
const { tabs, activeTabId } = useTabContext()
const activeTab = tabs.find((t) => t.id === activeTabId)
const conversationId = activeTab?.conversationId
const contextKey = activeTab?.id ?? "__session-files-tab__"
const conn = useConnection(contextKey)
const pendingPromptText = getPendingPromptText(contextKey)
if (!activeTab) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
Open a conversation to see its file changes
</p>
</div>
)
}
if (!conversationId) {
return (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
No file changes found in this conversation
</p>
</div>
)
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 min-h-0 overflow-y-auto">
<SessionFilesContent
conversationId={conversationId}
liveMessage={conn.liveMessage}
connStatus={conn.status}
pendingPromptText={pendingPromptText}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useCallback, useState } from "react"
import { FileDiff, Folder, FolderPen, GitCommit } from "lucide-react"
import {
useAuxPanelContext,
type AuxPanelTab,
} from "@/contexts/aux-panel-context"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { FileTreeTab } from "./aux-panel-file-tree-tab"
import { GitChangesTab } from "./aux-panel-git-changes-tab"
import { GitLogTab } from "./aux-panel-git-log-tab"
import { SessionFilesTab } from "./aux-panel-session-files-tab"
export function AuxPanel() {
const { isOpen, activeTab, setActiveTab } = useAuxPanelContext()
const [hasMountedFileTree, setHasMountedFileTree] = useState(
activeTab === "file_tree"
)
const [hasMountedChanges, setHasMountedChanges] = useState(
activeTab === "changes"
)
const [hasMountedGitLog, setHasMountedGitLog] = useState(
activeTab === "git_log"
)
const handleTabValueChange = useCallback(
(value: string) => {
const nextTab = value as AuxPanelTab
setActiveTab(nextTab)
if (nextTab === "file_tree") {
setHasMountedFileTree(true)
}
if (nextTab === "changes") {
setHasMountedChanges(true)
}
if (nextTab === "git_log") {
setHasMountedGitLog(true)
}
},
[setActiveTab]
)
if (!isOpen) return null
return (
<aside className="group/aux-panel flex h-full min-h-0 flex-col overflow-hidden bg-sidebar text-sidebar-foreground select-none">
<Tabs
value={activeTab}
onValueChange={handleTabValueChange}
className="flex h-full flex-col gap-0"
>
<TabsList
variant="line"
className="h-10 w-full shrink-0 justify-start border-b border-border px-3 group-data-horizontal/tabs:h-10"
>
<TabsTrigger value="session_files" title="Diff" aria-label="Diff">
<FileDiff className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="file_tree" title="Files" aria-label="Files">
<Folder className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="changes" title="Changes" aria-label="Changes">
<FolderPen className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="git_log" title="Commits" aria-label="Commits">
<GitCommit className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
<TabsContent
value="session_files"
className="mt-0 flex-1 min-h-0 overflow-hidden"
>
<SessionFilesTab />
</TabsContent>
<TabsContent
value="file_tree"
forceMount
className="mt-0 flex-1 min-h-0 overflow-hidden"
>
{hasMountedFileTree ? <FileTreeTab /> : null}
</TabsContent>
<TabsContent
value="changes"
forceMount
className="mt-0 flex-1 min-h-0 overflow-hidden"
>
{hasMountedChanges ? <GitChangesTab /> : null}
</TabsContent>
<TabsContent
value="git_log"
forceMount
className="mt-0 flex-1 min-h-0 overflow-hidden"
>
{hasMountedGitLog ? <GitLogTab /> : null}
</TabsContent>
</Tabs>
</aside>
)
}

View File

@@ -0,0 +1,752 @@
"use client"
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
import {
GitBranch,
ChevronDown,
ChevronRight,
ArrowDownToLine,
Upload,
GitBranchPlus,
GitCommitHorizontal,
Archive,
ArchiveRestore,
GitFork,
GitMerge,
GitPullRequestArrow,
Trash2,
Loader2,
RefreshCw,
FolderGit2,
FolderOpen,
ArrowLeftRight,
} 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { open } from "@tauri-apps/plugin-dialog"
import {
gitInit,
gitPull,
gitFetch,
gitPush,
gitNewBranch,
gitWorktreeAdd,
gitCheckout,
gitListAllBranches,
gitMerge,
gitRebase,
gitDeleteBranch,
gitStash,
gitStashPop,
openFolderWindow,
openCommitWindow,
setFolderParentBranch,
} from "@/lib/tauri"
import type { GitBranchList } 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"
interface BranchDropdownProps {
branch: string | null
parentBranch: string | null
onBranchChange: () => void
}
type ConfirmAction = {
type: "merge" | "rebase" | "delete"
branchName: string
}
interface GitCommitSucceededEventPayload {
folder_id: number
committed_files: number
}
export function BranchDropdown({
branch,
parentBranch,
onBranchChange,
}: BranchDropdownProps) {
const { folder } = useFolderContext()
const folderPath = folder?.path ?? ""
const { addTask, updateTask, removeTask } = useTaskContext()
const { pushAlert } = useAlertContext()
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 [expandedBranch, setExpandedBranch] = useState<string | null>(null)
const [worktreeOpen, setWorktreeOpen] = useState(false)
const [worktreeBranchName, setWorktreeBranchName] = useState("")
const [worktreePath, setWorktreePath] = useState("")
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
() => new Set(branchList.worktree_branches),
[branchList.worktree_branches]
)
useEffect(() => {
if (!folder) return
let unlisten: UnlistenFn | null = null
listen<GitCommitSucceededEventPayload>(
"folder://git-commit-succeeded",
(event) => {
if (event.payload.folder_id !== folder.id) return
toast.success("提交代码完成", {
description: `已提交 ${event.payload.committed_files} 个文件`,
})
onBranchChange()
}
)
.then((fn) => {
unlisten = fn
})
.catch((err) => {
console.error("[BranchDropdown] failed to listen commit event:", err)
})
return () => {
if (unlisten) unlisten()
}
}, [folder, onBranchChange])
async function runGitTask<T>(
label: string,
action: () => Promise<T>,
getSuccessDescription?: (result: T) => string | undefined
) {
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()
toast.success(
`${label} 完成`,
successDescription
? {
description: successDescription,
}
: undefined
)
} catch (err) {
removeTask(taskId)
pushAlert("error", `${label}失败`, String(err))
toast.error(`${label} 失败`, { description: String(err) })
} 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)
setExpandedBranch(null)
}
}
async function handleNewBranch() {
const name = newBranchName.trim()
if (!name) return
setNewBranchOpen(false)
setNewBranchName("")
await runGitTask(`新建分支 ${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 open({ directory: true, multiple: false })
if (selected) {
setWorktreePath(selected)
}
}
async function handleNewWorktree() {
const name = worktreeBranchName.trim()
const wtPath = worktreePath.trim()
if (!name || !wtPath) return
setWorktreeOpen(false)
await runGitTask(`新建工作树 ${name}`, async () => {
await gitWorktreeAdd(folderPath, name, wtPath)
await openFolderWindow(wtPath)
await setFolderParentBranch(wtPath, branch)
})
}
function handleMergeParent() {
if (!parentBranch) return
setConfirmAction({ type: "merge", branchName: parentBranch })
}
async function handleCheckout(branchName: string) {
setDropdownOpen(false)
await runGitTask(`切换到 ${branchName}`, () =>
gitCheckout(folderPath, branchName)
)
}
async function handleCheckoutRemote(remoteBranch: string) {
const localName = remoteBranch.replace(/^[^/]+\//, "")
setDropdownOpen(false)
await runGitTask(`切换到 ${localName}`, () =>
gitCheckout(folderPath, localName)
)
}
async function handleConfirm() {
if (!confirmAction) return
const { type, branchName } = confirmAction
setConfirmAction(null)
switch (type) {
case "merge":
await runGitTask(
`合并 ${branchName}`,
() => gitMerge(folderPath, branchName),
(result) => {
if (result.merged_commits === 0) {
return `${branchName} 没有新的提交`
}
return `已合并 ${result.merged_commits} 个提交`
}
)
break
case "rebase":
await runGitTask(`变基到 ${branchName}`, () =>
gitRebase(folderPath, branchName)
)
break
case "delete":
await runGitTask(`删除分支 ${branchName}`, () =>
gitDeleteBranch(folderPath, branchName)
)
break
}
}
function getConfirmTitle() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return "合并分支"
case "rebase":
return "变基分支"
case "delete":
return "删除分支"
}
}
function getConfirmDescription() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return `确定将 ${confirmAction.branchName} 合并到当前分支 ${branch} 吗?`
case "rebase":
return `确定将当前分支 ${branch} 变基到 ${confirmAction.branchName} 吗?`
case "delete":
return `确定删除分支 ${confirmAction.branchName} 吗?此操作不可恢复。`
}
}
function renderBranchItem(b: string, isRemote: boolean) {
const isCurrent = b === 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">{b}</span>
<span className="ml-auto text-xs"></span>
</div>
)
}
return (
<DropdownMenuSub
key={b}
open={expandedBranch === b}
onOpenChange={(open) => {
if (!open) setExpandedBranch(null)
}}
>
<DropdownMenuSubTrigger
className="hover:bg-accent hover:text-accent-foreground"
disabled={loading}
onClick={() => setExpandedBranch(expandedBranch === b ? null : b)}
onPointerMove={(e) => {
e.preventDefault()
if (expandedBranch !== null && expandedBranch !== b) {
setExpandedBranch(null)
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
}
}}
onPointerLeave={(e) => e.preventDefault()}
>
<BranchIcon className="h-3.5 w-3.5" />
{b}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => {
if (isRemote) {
handleCheckoutRemote(b)
} else {
handleCheckout(b)
}
}}
>
<GitBranch className="h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "merge", branchName: b })
}}
>
<GitMerge className="h-3.5 w-3.5" /> {b} {branch}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "rebase", branchName: b })
}}
>
<GitPullRequestArrow className="h-3.5 w-3.5" /> {branch} {" "}
{b}
</DropdownMenuItem>
{!isRemote && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "delete", branchName: b })
}}
>
<Trash2 className="h-3.5 w-3.5" />
</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"></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("初始化 Git 仓库", () => gitInit(folderPath))
}
>
<GitBranch className="h-3.5 w-3.5" />
Git
</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(
"更新代码",
() => gitPull(folderPath),
(result) => {
if (result.updated_files === 0) {
return "所有文件均为最新版本"
}
return `已更新 ${result.updated_files} 个文件`
}
)
}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("获取信息", () => gitFetch(folderPath))
}
>
<RefreshCw className="h-3.5 w-3.5" />
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
setDropdownOpen(false)
openCommitWindow(folder.id).catch((err) => {
pushAlert("error", "打开提交窗口失败", String(err))
toast.error("打开提交窗口失败", { description: String(err) })
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(
"推送代码",
() => gitPush(folderPath),
(result) => {
if (result.upstream_set) {
if (result.pushed_commits === 0) {
return "已设置远程跟踪分支"
}
return `已设置远程跟踪分支并推送 ${result.pushed_commits} 个提交`
}
if (result.pushed_commits === 0) {
return "没有可推送的提交"
}
return `已推送 ${result.pushed_commits} 个提交`
}
)
}
>
<Upload className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setNewBranchName("")
setNewBranchOpen(true)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={handleOpenWorktreeDialog}
>
<FolderGit2 className="h-3.5 w-3.5" />
...
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("贮藏更改", () => gitStash(folderPath))
}
>
<Archive className="h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask("取消贮藏", () => gitStashPop(folderPath))
}
>
<ArchiveRestore className="h-3.5 w-3.5" />
...
</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" />
({branchList.local.length})
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.local.length === 0 ? (
<DropdownMenuItem disabled></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" />
({branchList.remote.length})
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.remote.length === 0 ? (
<DropdownMenuItem disabled></DropdownMenuItem>
) : (
branchList.remote.map((b) => renderBranchItem(b, true))
)}
</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={`当前分支从 ${parentBranch} 创建,点击合并 ${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></AlertDialogCancel>
<AlertDialogAction
variant={
confirmAction?.type === "delete" ? "destructive" : "default"
}
onClick={handleConfirm}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{branch}
</DialogDescription>
</DialogHeader>
<Input
placeholder="分支名称"
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)}>
</Button>
<Button
disabled={!newBranchName.trim() || loading}
onClick={handleNewBranch}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={worktreeOpen} onOpenChange={setWorktreeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{branch}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="wt-branch"></Label>
<Input
id="wt-branch"
placeholder="分支名称"
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"></Label>
<div className="flex gap-2">
<Input
id="wt-path"
placeholder="工作树路径"
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)}>
</Button>
<Button
disabled={
!worktreeBranchName.trim() || !worktreePath.trim() || loading
}
onClick={handleNewWorktree}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,365 @@
"use client"
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
import { useState, useEffect, useCallback, useMemo, useRef } from "react"
import { ChevronDown, Play, Plus, Square } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useFolderContext } from "@/contexts/folder-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import {
bootstrapFolderCommandsFromPackageJson,
listFolderCommands,
terminalKill,
terminalList,
} from "@/lib/tauri"
import type { FolderCommand, TerminalEvent } 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 { folder } = useFolderContext()
const { createTerminalWithCommand } = 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 exitUnlistenersRef = useRef<Map<string, UnlistenFn>>(new Map())
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
const folderId = folder?.id ?? 0
const folderPath = folder?.path ?? ""
useEffect(() => {
runningCommandTerminalsRef.current = runningCommandTerminals
}, [runningCommandTerminals])
const clearRunningByTerminalId = useCallback((terminalId: string) => {
const unlisten = exitUnlistenersRef.current.get(terminalId)
if (unlisten) {
unlisten()
exitUnlistenersRef.current.delete(terminalId)
}
setRunningCommandTerminals((prev) => {
let changed = false
const next = { ...prev }
for (const [commandId, mappedTerminalId] of Object.entries(prev)) {
if (mappedTerminalId === terminalId) {
delete next[Number(commandId)]
changed = true
}
}
return changed ? next : prev
})
}, [])
const clearAllRunningStates = useCallback(() => {
for (const unlisten of exitUnlistenersRef.current.values()) {
unlisten()
}
exitUnlistenersRef.current.clear()
setRunningCommandTerminals({})
}, [])
const selectCommand = useCallback(
(commandId: number) => {
if (!folderId) return
setSelectedCommandId(folderId, commandId)
setSelectedCommandIdState(commandId)
},
[folderId]
)
useEffect(() => {
if (!folderId) {
setSelectedCommandIdState(null)
clearAllRunningStates()
return
}
setSelectedCommandIdState(getSelectedCommandId(folderId))
clearAllRunningStates()
}, [clearAllRunningStates, folderId])
useEffect(
() => () => {
clearAllRunningStates()
},
[clearAllRunningStates]
)
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 registerExitListener = useCallback(
async (terminalId: string) => {
if (exitUnlistenersRef.current.has(terminalId)) return
try {
const unlisten = await listen<TerminalEvent>(
`terminal://exit/${terminalId}`,
() => {
clearRunningByTerminalId(terminalId)
}
)
exitUnlistenersRef.current.set(terminalId, unlisten)
} catch (err) {
console.error("Failed to subscribe terminal exit event:", err)
}
},
[clearRunningByTerminalId]
)
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 }))
await registerExitListener(terminalId)
},
[createTerminalWithCommand, folderPath, registerExitListener, selectCommand]
)
const stopCommand = useCallback(
async (cmd: FolderCommand) => {
const terminalId = runningCommandTerminalsRef.current[cmd.id]
if (!terminalId) return
clearRunningByTerminalId(terminalId)
try {
await terminalKill(terminalId)
} catch (err) {
console.error("Failed to stop command terminal:", err)
}
},
[clearRunningByTerminalId]
)
useEffect(() => {
if (Object.keys(runningCommandTerminals).length === 0) return
let cancelled = false
const syncRunningCommandState = async () => {
try {
const terminals = await terminalList()
if (cancelled) return
const aliveTerminalIds = new Set(terminals.map((item) => item.id))
for (const terminalId of Object.values(
runningCommandTerminalsRef.current
)) {
if (!aliveTerminalIds.has(terminalId)) {
clearRunningByTerminalId(terminalId)
}
}
} catch (err) {
console.error("Failed to sync command terminal state:", err)
}
}
syncRunningCommandState()
const timer = setInterval(syncRunningCommandState, 1500)
return () => {
cancelled = true
clearInterval(timer)
}
}, [clearRunningByTerminalId, runningCommandTerminals])
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"
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 ? "Loading..." : "Add Command"}
</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)}>
Manage Commands...
</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
? `Stop: ${activeCmd?.command}`
: `Run: ${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}
/>
</>
)
}

View File

@@ -0,0 +1,181 @@
"use client"
import { useState, useEffect } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Plus, Trash2 } from "lucide-react"
import type { FolderCommand } from "@/lib/types"
import {
createFolderCommand,
updateFolderCommand,
deleteFolderCommand,
} from "@/lib/tauri"
interface CommandDraft {
id: number | null
name: string
command: string
deleted: boolean
}
interface CommandManageDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
folderId: number
commands: FolderCommand[]
onSaved: () => void
}
export function CommandManageDialog({
open,
onOpenChange,
folderId,
commands,
onSaved,
}: CommandManageDialogProps) {
const [drafts, setDrafts] = useState<CommandDraft[]>([])
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open) {
setDrafts(
commands.map((c) => ({
id: c.id,
name: c.name,
command: c.command,
deleted: false,
}))
)
}
}, [open, commands])
const addDraft = () => {
setDrafts((prev) => [
...prev,
{ id: null, name: "", command: "", deleted: false },
])
}
const updateDraft = (
index: number,
field: "name" | "command",
value: string
) => {
setDrafts((prev) =>
prev.map((d, i) => (i === index ? { ...d, [field]: value } : d))
)
}
const removeDraft = (index: number) => {
setDrafts((prev) =>
prev.map((d, i) => (i === index ? { ...d, deleted: true } : d))
)
}
const handleSave = async () => {
setSaving(true)
try {
for (const draft of drafts) {
if (draft.deleted && draft.id != null) {
await deleteFolderCommand(draft.id)
} else if (draft.deleted) {
continue
} else if (draft.id == null && draft.name && draft.command) {
await createFolderCommand(folderId, draft.name, draft.command)
} else if (draft.id != null) {
const orig = commands.find((c) => c.id === draft.id)
if (
orig &&
(orig.name !== draft.name || orig.command !== draft.command)
) {
await updateFolderCommand(draft.id, draft.name, draft.command)
}
}
}
onSaved()
onOpenChange(false)
} catch (err) {
console.error("Failed to save commands:", err)
} finally {
setSaving(false)
}
}
const visibleDrafts = drafts.filter((d) => !d.deleted)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Manage Commands</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-72">
<div className="space-y-2 pr-2">
{visibleDrafts.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No commands yet
</p>
)}
{drafts.map(
(draft, index) =>
!draft.deleted && (
<div key={index} className="flex items-center gap-2">
<Input
placeholder="Name"
value={draft.name}
onChange={(e) =>
updateDraft(index, "name", e.target.value)
}
className="h-8 text-sm flex-1"
/>
<Input
placeholder="Command"
value={draft.command}
onChange={(e) =>
updateDraft(index, "command", e.target.value)
}
className="h-8 text-sm font-mono flex-[2]"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => removeDraft(index)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
)
)}
</div>
</ScrollArea>
<DialogFooter className="flex items-center justify-between sm:justify-between">
<Button variant="outline" size="sm" onClick={addDraft}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,998 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
FileTree,
FileTreeFile,
FileTreeFolder,
} from "@/components/ai-elements/file-tree"
import {
gitAddFiles,
gitCommit,
gitRollbackFile,
gitShowFile,
gitStatus,
deleteFileTreeEntry,
readFilePreview,
} from "@/lib/tauri"
import type { GitStatusEntry } from "@/lib/types"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { DiffViewer } from "@/components/diff/diff-viewer"
import { languageFromPath } from "@/lib/language-detect"
interface CommitWorkspaceProps {
folderPath: string
onCommitted?: () => void
onCancel?: () => void
}
interface TreeFileNode {
kind: "file"
name: string
path: string
entry: GitStatusEntry
}
interface TreeDirNode {
kind: "dir"
name: string
path: string
children: TreeNode[]
}
type TreeNode = TreeFileNode | TreeDirNode
const UNTRACKED_STATUS = "??"
const DEFAULT_LEFT_PANE_WIDTH = 420
const MIN_LEFT_PANE_WIDTH = 320
const MIN_RIGHT_PANE_WIDTH = 360
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function toPercent(pixels: number, totalPixels: number): number {
if (totalPixels <= 0) return 0
return (pixels / totalPixels) * 100
}
function buildFileTree(entries: GitStatusEntry[]): TreeNode[] {
type BuildDir = {
name: string
path: string
dirs: Map<string, BuildDir>
files: TreeFileNode[]
}
const root: BuildDir = {
name: "",
path: "",
dirs: new Map(),
files: [],
}
for (const entry of entries) {
const parts = entry.file.split("/").filter(Boolean)
if (parts.length === 0) continue
let current = root
let currentPath = ""
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i]
const isLeaf = i === parts.length - 1
currentPath = currentPath ? `${currentPath}/${part}` : part
if (isLeaf) {
current.files.push({
kind: "file",
name: part,
path: currentPath,
entry,
})
} else {
const found = current.dirs.get(part)
if (found) {
current = found
} else {
const next: BuildDir = {
name: part,
path: currentPath,
dirs: new Map(),
files: [],
}
current.dirs.set(part, next)
current = next
}
}
}
}
function sortNodes(nodes: TreeNode[]) {
return nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function toNodes(dir: BuildDir): TreeNode[] {
const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({
kind: "dir",
name: child.name,
path: child.path,
children: toNodes(child),
}))
return sortNodes([...dirs, ...dir.files])
}
return toNodes(root)
}
/** Depth-first traversal to find the first file node (matches visual order). */
function findFirstFile(nodes: TreeNode[]): string | undefined {
for (const node of nodes) {
if (node.kind === "file") return node.path
const found = findFirstFile(node.children)
if (found) return found
}
return undefined
}
function collectDirPaths(entries: GitStatusEntry[]) {
const paths = new Set<string>()
for (const entry of entries) {
const parts = entry.file.split("/").filter(Boolean)
if (parts.length < 2) continue
let currentPath = ""
for (let i = 0; i < parts.length - 1; i += 1) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
paths.add(currentPath)
}
}
return paths
}
interface ConfirmState {
open: boolean
title: string
description: string
action: (() => void) | null
variant: "default" | "destructive"
}
const CONFIRM_INITIAL: ConfirmState = {
open: false,
title: "",
description: "",
action: null,
variant: "default",
}
export function CommitWorkspace({
folderPath,
onCommitted,
onCancel,
}: CommitWorkspaceProps) {
const [entries, setEntries] = useState<GitStatusEntry[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [diffOriginal, setDiffOriginal] = useState("")
const [diffModified, setDiffModified] = useState("")
const [diffLanguage, setDiffLanguage] = useState("plaintext")
const [diffFile, setDiffFile] = useState<string | null>(null)
const messageRef = useRef("")
const [hasMessage, setHasMessage] = useState(false)
const [messageInputKey, setMessageInputKey] = useState(0)
const [loadingStatus, setLoadingStatus] = useState(false)
const [loadingDiff, setLoadingDiff] = useState(false)
const [committing, setCommitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [untrackedOpen, setUntrackedOpen] = useState(false)
const [expandedTrackedDirs, setExpandedTrackedDirs] = useState<Set<string>>(
new Set()
)
const [expandedUntrackedDirs, setExpandedUntrackedDirs] = useState<
Set<string>
>(new Set())
const [confirm, setConfirm] = useState<ConfirmState>(CONFIRM_INITIAL)
// Use refs to track mutable values without causing callback recreation
const diffFileRef = useRef(diffFile)
diffFileRef.current = diffFile
const entriesRef = useRef(entries)
entriesRef.current = entries
const folderName = useMemo(() => {
const parts = folderPath.replace(/[/\\]+$/, "").split(/[/\\]/)
return parts[parts.length - 1] || folderPath
}, [folderPath])
const trackedEntries = useMemo(
() => entries.filter((entry) => entry.status !== UNTRACKED_STATUS),
[entries]
)
const untrackedEntries = useMemo(
() => entries.filter((entry) => entry.status === UNTRACKED_STATUS),
[entries]
)
const trackedTree = useMemo(
() => buildFileTree(trackedEntries),
[trackedEntries]
)
const untrackedTree = useMemo(
() => buildFileTree(untrackedEntries),
[untrackedEntries]
)
const filePathSet = useMemo(
() => new Set(entries.map((entry) => entry.file)),
[entries]
)
const trackedFiles = useMemo(
() => trackedEntries.map((entry) => entry.file),
[trackedEntries]
)
const untrackedFiles = useMemo(
() => untrackedEntries.map((entry) => entry.file),
[untrackedEntries]
)
// Shared diff loading logic — extracted to avoid duplication
const loadDiff = useCallback(
async (file: string, allEntries?: GitStatusEntry[]) => {
if (!folderPath) return
setDiffFile(file)
setDiffLanguage(languageFromPath(file))
setLoadingDiff(true)
setDiffOriginal("")
setDiffModified("")
try {
const statusSource = allEntries ?? entriesRef.current
const isUntracked =
statusSource.find((e) => e.file === file)?.status === UNTRACKED_STATUS
const [originalContent, modifiedContent] = await Promise.all([
isUntracked
? Promise.resolve("")
: gitShowFile(folderPath, file).catch(() => ""),
readFilePreview(folderPath, file)
.then((r) => r.content)
.catch(() => ""),
])
setDiffOriginal(originalContent)
setDiffModified(modifiedContent)
} catch {
setDiffOriginal("")
setDiffModified("")
} finally {
setLoadingDiff(false)
}
},
[folderPath]
)
const loadStatus = useCallback(async () => {
if (!folderPath) return
setLoadingStatus(true)
setError(null)
try {
const result = await gitStatus(folderPath)
setEntries(result)
const tracked = result.filter(
(entry) => entry.status !== UNTRACKED_STATUS
)
const untracked = result.filter(
(entry) => entry.status === UNTRACKED_STATUS
)
setSelected(new Set(tracked.map((entry) => entry.file)))
const trackedDirs = collectDirPaths(tracked)
trackedDirs.add(folderName)
setExpandedTrackedDirs(trackedDirs)
const untrackedDirs = collectDirPaths(untracked)
untrackedDirs.add(folderName)
setExpandedUntrackedDirs(untrackedDirs)
// Auto-select the first file in visual tree order for diff preview
const firstFile =
findFirstFile(buildFileTree(tracked)) ??
findFirstFile(buildFileTree(untracked))
if (firstFile) {
await loadDiff(firstFile, result)
}
} catch (err) {
setError(String(err))
setEntries([])
setExpandedTrackedDirs(new Set())
setExpandedUntrackedDirs(new Set())
} finally {
setLoadingStatus(false)
}
}, [folderPath, folderName, loadDiff])
useEffect(() => {
if (!folderPath) return
setDiffOriginal("")
setDiffModified("")
setDiffLanguage("plaintext")
setDiffFile(null)
messageRef.current = ""
setHasMessage(false)
setMessageInputKey((key) => key + 1)
setUntrackedOpen(false)
void loadStatus()
}, [folderPath, loadStatus])
const handleViewDiff = useCallback(
(file: string) => {
if (!folderPath || diffFileRef.current === file) return
void loadDiff(file)
},
[folderPath, loadDiff]
)
const toggleFile = useCallback((file: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(file)) {
next.delete(file)
} else {
next.add(file)
}
return next
})
}, [])
const toggleAll = useCallback(() => {
setSelected((prev) => {
if (prev.size === entries.length) {
return new Set<string>()
}
return new Set(entries.map((entry) => entry.file))
})
}, [entries])
const toggleGroup = useCallback((files: string[]) => {
setSelected((prev) => {
const next = new Set(prev)
const allInGroupSelected = files.every((file) => next.has(file))
if (allInGroupSelected) {
files.forEach((file) => next.delete(file))
} else {
files.forEach((file) => next.add(file))
}
return next
})
}, [])
const handleSelectPath = useCallback(
(path: string) => {
if (!filePathSet.has(path)) return
handleViewDiff(path)
},
[filePathSet, handleViewDiff]
)
const handleCommit = useCallback(async () => {
const commitMessage = messageRef.current.trim()
if (!commitMessage || selected.size === 0 || !folderPath) return
setCommitting(true)
setError(null)
try {
const result = await gitCommit(
folderPath,
commitMessage,
Array.from(selected)
)
toast.success("提交代码完成", {
description: `已提交 ${result.committed_files} 个文件`,
})
onCommitted?.()
} catch (err) {
setError(String(err))
} finally {
setCommitting(false)
}
}, [folderPath, onCommitted, selected])
// --- Context menu actions ---
const handleAddToVcs = useCallback(
async (file: string) => {
if (!folderPath) return
try {
await gitAddFiles(folderPath, [file])
toast.success("已添加到 VCS", { description: file })
void loadStatus()
} catch (err) {
toast.error("添加到 VCS 失败", { description: String(err) })
}
},
[folderPath, loadStatus]
)
const handleDeleteFile = useCallback(
(file: string) => {
setConfirm({
open: true,
title: "确认删除",
description: `确定要删除文件「${file}」吗?此操作不可恢复。`,
variant: "destructive",
action: () => {
void (async () => {
if (!folderPath) return
try {
await deleteFileTreeEntry(folderPath, file)
toast.success("文件已删除", { description: file })
// If deleted file was being viewed, clear the diff
if (diffFileRef.current === file) {
setDiffFile(null)
setDiffOriginal("")
setDiffModified("")
}
setSelected((prev) => {
if (!prev.has(file)) return prev
const next = new Set(prev)
next.delete(file)
return next
})
void loadStatus()
} catch (err) {
toast.error("删除失败", { description: String(err) })
}
})()
},
})
},
[folderPath, loadStatus]
)
const handleRollbackFile = useCallback(
(file: string) => {
setConfirm({
open: true,
title: "确认回滚",
description: `确定要回滚文件「${file}」到 HEAD 版本吗?未保存的修改将丢失。`,
variant: "destructive",
action: () => {
void (async () => {
if (!folderPath) return
try {
await gitRollbackFile(folderPath, file)
toast.success("文件已回滚", { description: file })
if (diffFileRef.current === file) {
setDiffFile(null)
setDiffOriginal("")
setDiffModified("")
}
setSelected((prev) => {
if (!prev.has(file)) return prev
const next = new Set(prev)
next.delete(file)
return next
})
void loadStatus()
} catch (err) {
toast.error("回滚失败", { description: String(err) })
}
})()
},
})
},
[folderPath, loadStatus]
)
const closeConfirm = useCallback(() => {
setConfirm(CONFIRM_INITIAL)
}, [])
const confirmActionRef = useRef(confirm.action)
confirmActionRef.current = confirm.action
const executeConfirmAction = useCallback(() => {
confirmActionRef.current?.()
setConfirm(CONFIRM_INITIAL)
}, [])
const allSelected = useMemo(
() => entries.length > 0 && selected.size === entries.length,
[entries.length, selected.size]
)
const trackedAllSelected = useMemo(
() =>
trackedFiles.length > 0 &&
trackedFiles.every((file) => selected.has(file)),
[trackedFiles, selected]
)
const untrackedAllSelected = useMemo(
() =>
untrackedFiles.length > 0 &&
untrackedFiles.every((file) => selected.has(file)),
[untrackedFiles, selected]
)
const handleMessageChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = e.target.value
messageRef.current = nextValue
const nextHasMessage = nextValue.trim().length > 0
setHasMessage((prev) => (prev === nextHasMessage ? prev : nextHasMessage))
},
[]
)
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateWidth = (next: number) => {
setContainerWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next))
}
updateWidth(container.clientWidth)
const observer = new ResizeObserver((entries) => {
updateWidth(entries[0]?.contentRect.width ?? container.clientWidth)
})
observer.observe(container)
return () => {
observer.disconnect()
}
}, [])
const safeContainerWidth =
containerWidth > 0
? containerWidth
: DEFAULT_LEFT_PANE_WIDTH + MIN_RIGHT_PANE_WIDTH + 240
const leftMinSize = clamp(
toPercent(MIN_LEFT_PANE_WIDTH, safeContainerWidth),
5,
95
)
const rightMinSize = clamp(
toPercent(MIN_RIGHT_PANE_WIDTH, safeContainerWidth),
5,
95
)
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
const leftDefaultSize = clamp(
toPercent(DEFAULT_LEFT_PANE_WIDTH, safeContainerWidth),
leftMinSize,
leftMaxSize
)
// --- Render helpers for file tree nodes ---
const renderTrackedNode = useCallback(
function renderNode(node: TreeNode): React.ReactNode {
if (node.kind === "dir") {
return (
<FileTreeFolder
key={`tracked:${node.path}`}
name={node.name}
path={node.path}
>
{node.children.map(renderNode)}
</FileTreeFolder>
)
}
const isDeleted = node.entry.status === " D" || node.entry.status === "D"
return (
<ContextMenu key={`tracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFile
name={node.name}
path={node.path}
className="gap-1 px-1.5 py-1"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleFile(node.path)
}}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
selected.has(node.path)
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={`${selected.has(node.path) ? "取消选择" : "选择"} ${node.path}`}
>
{selected.has(node.path) && <Check className="h-3 w-3" />}
</button>
<button
type="button"
className="flex-1 truncate text-left hover:underline"
onClick={(e) => {
e.stopPropagation()
handleViewDiff(node.path)
}}
title={node.path}
>
{node.name}
</button>
<span className="w-6 shrink-0 text-right text-xs font-medium text-muted-foreground">
{node.entry.status}
</span>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
{!isDeleted && (
<ContextMenuItem onClick={() => handleRollbackFile(node.path)}>
</ContextMenuItem>
)}
<ContextMenuItem
variant="destructive"
onClick={() => handleDeleteFile(node.path)}
>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[selected, toggleFile, handleViewDiff, handleRollbackFile, handleDeleteFile]
)
const renderUntrackedNode = useCallback(
function renderNode(node: TreeNode): React.ReactNode {
if (node.kind === "dir") {
return (
<FileTreeFolder
key={`untracked:${node.path}`}
name={node.name}
path={node.path}
>
{node.children.map(renderNode)}
</FileTreeFolder>
)
}
return (
<ContextMenu key={`untracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFile
name={node.name}
path={node.path}
className="gap-1 px-1.5 py-1"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleFile(node.path)
}}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
selected.has(node.path)
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={`${selected.has(node.path) ? "取消选择" : "选择"} ${node.path}`}
>
{selected.has(node.path) && <Check className="h-3 w-3" />}
</button>
<button
type="button"
className="flex-1 truncate text-left hover:underline"
onClick={(e) => {
e.stopPropagation()
handleViewDiff(node.path)
}}
title={node.path}
>
{node.name}
</button>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleAddToVcs(node.path)}>
VCS
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => handleDeleteFile(node.path)}
>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[selected, toggleFile, handleViewDiff, handleAddToVcs, handleDeleteFile]
)
const toggleTrackedGroup = useCallback(
() => toggleGroup(trackedFiles),
[toggleGroup, trackedFiles]
)
const toggleUntrackedGroup = useCallback(
() => toggleGroup(untrackedFiles),
[toggleGroup, untrackedFiles]
)
const toggleUntrackedOpen = useCallback(
() => setUntrackedOpen((open) => !open),
[]
)
return (
<div
ref={containerRef}
className="flex h-full min-h-0 overflow-hidden rounded-lg border bg-card"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full min-h-0 min-w-0"
>
<ResizablePanel
defaultSize={leftDefaultSize}
minSize={leftMinSize}
maxSize={leftMaxSize}
>
<div className="flex h-full min-h-0 flex-col">
{error && (
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex h-9 items-center gap-2 border-b bg-muted/50 px-3">
<button
type="button"
onClick={toggleAll}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
allSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={allSelected ? "取消选择全部文件" : "选择全部文件"}
>
{allSelected && <Check className="h-3 w-3" />}
</button>
<span className="text-xs text-muted-foreground">
{loadingStatus
? "加载中..."
: `${selected.size} / ${entries.length} 个文件`}
</span>
</div>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{entries.length === 0 && !loadingStatus ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-3 p-2">
{trackedEntries.length > 0 && (
<section className="space-y-1">
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<button
type="button"
onClick={toggleTrackedGroup}
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
trackedAllSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={
trackedAllSelected
? "取消选择已跟踪改动"
: "选择已跟踪改动"
}
>
{trackedAllSelected && (
<Check className="h-2.5 w-2.5" />
)}
</button>
<span> ({trackedEntries.length})</span>
</div>
<FileTree
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
expanded={expandedTrackedDirs}
onExpandedChange={setExpandedTrackedDirs}
selectedPath={diffFile ?? undefined}
onSelect={handleSelectPath}
>
<FileTreeFolder name={folderName} path={folderName}>
{trackedTree.map(renderTrackedNode)}
</FileTreeFolder>
</FileTree>
</section>
)}
{untrackedEntries.length > 0 && (
<section className="space-y-1">
<div className="flex w-full items-center gap-2 px-1 py-0.5 text-[11px] text-muted-foreground">
<button
type="button"
onClick={toggleUntrackedGroup}
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
untrackedAllSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={
untrackedAllSelected
? "取消选择未跟踪文件"
: "选择未跟踪文件"
}
>
{untrackedAllSelected && (
<Check className="h-2.5 w-2.5" />
)}
</button>
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={toggleUntrackedOpen}
>
{untrackedOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
<span> ({untrackedEntries.length})</span>
</button>
</div>
{untrackedOpen && (
<FileTree
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
expanded={expandedUntrackedDirs}
onExpandedChange={setExpandedUntrackedDirs}
selectedPath={diffFile ?? undefined}
onSelect={handleSelectPath}
>
<FileTreeFolder name={folderName} path={folderName}>
{untrackedTree.map(renderUntrackedNode)}
</FileTreeFolder>
</FileTree>
)}
</section>
)}
</div>
)}
</ScrollArea>
</div>
<div className="border-t p-3">
<div className="mb-2 text-xs text-muted-foreground"></div>
<Textarea
key={messageInputKey}
placeholder="输入提交信息..."
defaultValue=""
onChange={handleMessageChange}
className="min-h-[90px] resize-y"
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
</Button>
<Button
disabled={committing || !hasMessage || selected.size === 0}
onClick={handleCommit}
>
{committing && (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
)}
({selected.size})
</Button>
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={100 - leftDefaultSize}
minSize={rightMinSize}
>
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{!diffFile ? (
<>
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
<span className="font-medium">HEAD</span>
<span className="text-muted-foreground/60"></span>
<span className="font-medium">Working Tree</span>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
</div>
</>
) : loadingDiff ? (
<>
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
<span className="font-medium">HEAD</span>
<span className="text-muted-foreground/60"></span>
<span className="font-medium">Working Tree</span>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</div>
</>
) : (
<DiffViewer
key={diffFile}
original={diffOriginal}
modified={diffModified}
originalLabel="HEAD"
modifiedLabel="Working Tree"
language={diffLanguage}
className="h-full [&>div:first-child]:h-9 [&>div:first-child]:py-0"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<AlertDialog
open={confirm.open}
onOpenChange={(open) => {
if (!open) closeConfirm()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirm.title}</AlertDialogTitle>
<AlertDialogDescription>
{confirm.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
variant={
confirm.variant === "destructive" ? "destructive" : "default"
}
onClick={executeConfirmAction}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,139 @@
"use client"
import { useState } from "react"
import { ChevronDown, Folder, FolderOpen, GitBranch } from "lucide-react"
import { open } from "@tauri-apps/plugin-dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
focusFolderWindow,
listOpenFolders,
loadFolderHistory,
openFolderWindow,
} from "@/lib/tauri"
import { useFolderContext } from "@/contexts/folder-context"
import { CloneDialog } from "@/components/welcome/clone-dialog"
import type { FolderHistoryEntry } from "@/lib/types"
export function FolderNameDropdown() {
const { folder } = useFolderContext()
const [openFolders, setOpenFolders] = useState<FolderHistoryEntry[]>([])
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
const [cloneOpen, setCloneOpen] = useState(false)
const folderPath = folder?.path ?? ""
const folderName = folder?.name ?? "文件夹"
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() {
const selected = await open({ directory: true, multiple: false })
if (selected) {
await openFolderWindow(selected)
}
}
async function handleSelect(path: string) {
try {
await openFolderWindow(path)
} 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" />
Open Folder
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setCloneOpen(true)}>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
Clone Repository
</DropdownMenuItem>
{openFolders.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel></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></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} />
</>
)
}

View File

@@ -0,0 +1,309 @@
"use client"
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react"
import { open } from "@tauri-apps/plugin-dialog"
import {
Columns2,
FileCode2,
MessageSquare,
PanelBottom,
PanelLeft,
PanelRight,
Search,
Settings,
} from "lucide-react"
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/tauri"
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"
import { useTerminalContext } from "@/contexts/terminal-context"
import { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useIsMac } from "@/hooks/use-is-mac"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import {
formatShortcutLabel,
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"
const MODE_TABS = [
{
mode: "conversation",
title: "会话模式",
icon: MessageSquare,
},
{
mode: "fusion",
title: "融合模式",
icon: Columns2,
},
{
mode: "files",
title: "文件模式",
icon: FileCode2,
},
] as const
export function FolderTitleBar() {
const { folder } = useFolderContext()
const { isOpen, toggle } = useSidebarContext()
const { isOpen: auxPanelOpen, toggle: toggleAuxPanel } = useAuxPanelContext()
const { isOpen: terminalOpen, toggle: toggleTerminal } = useTerminalContext()
const { openNewConversationTab } = useTabContext()
const { mode, setMode } = useWorkspaceContext()
const isMac = useIsMac()
const { shortcuts } = useShortcutSettings()
const [branch, setBranch] = useState<string | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | undefined>(
undefined
)
const folderPath = folder?.path ?? ""
const handleOpenFolder = useCallback(async () => {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected) return
await openFolderWindow(selected)
} catch (err) {
console.error("[FolderTitleBar] failed to open folder:", err)
}
}, [])
const handleOpenSettings = useCallback(() => {
openSettingsWindow().catch((err) => {
console.error("[FolderTitleBar] failed to open settings:", err)
})
}, [])
useEffect(() => {
if (!folderPath) return
let cancelled = false
async function doFetch() {
if (document.visibilityState !== "visible") return
try {
const b = await getGitBranch(folderPath)
if (!cancelled) setBranch(b)
} catch {
if (!cancelled) setBranch(null)
}
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void doFetch()
}
}
void doFetch()
intervalRef.current = setInterval(() => {
void doFetch()
}, 10_000)
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
cancelled = true
clearInterval(intervalRef.current)
document.removeEventListener("visibilitychange", handleVisibilityChange)
}
}, [folderPath])
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (matchShortcutEvent(e, shortcuts.toggle_search)) {
e.preventDefault()
setSearchOpen((prev) => !prev)
return
}
if (matchShortcutEvent(e, shortcuts.toggle_sidebar)) {
e.preventDefault()
toggle()
return
}
if (matchShortcutEvent(e, shortcuts.toggle_terminal)) {
e.preventDefault()
toggleTerminal()
return
}
if (matchShortcutEvent(e, shortcuts.toggle_aux_panel)) {
e.preventDefault()
toggleAuxPanel()
return
}
if (matchShortcutEvent(e, shortcuts.new_conversation)) {
if (!folderPath) return
e.preventDefault()
openNewConversationTab("codex", folderPath)
return
}
if (matchShortcutEvent(e, shortcuts.open_folder)) {
e.preventDefault()
void handleOpenFolder()
return
}
if (matchShortcutEvent(e, shortcuts.open_settings)) {
e.preventDefault()
handleOpenSettings()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [
folderPath,
handleOpenFolder,
handleOpenSettings,
openNewConversationTab,
shortcuts,
toggle,
toggleAuxPanel,
toggleTerminal,
])
const refreshBranch = useCallback(async () => {
if (!folderPath) return
try {
setBranch(await getGitBranch(folderPath))
} catch {
setBranch(null)
}
}, [folderPath])
const modeIndex = MODE_TABS.findIndex((item) => item.mode === mode)
const indicatorLeft = `${2 + modeIndex * 32}px`
const handleModeKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>, nextMode: typeof mode) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
setMode(nextMode)
}
},
[setMode]
)
return (
<>
<AppTitleBar
centerInteractive
left={
<div className="flex items-center gap-4 min-w-0 pl-4">
<FolderNameDropdown />
<BranchDropdown
branch={branch}
parentBranch={folder?.parent_branch ?? null}
onBranchChange={refreshBranch}
/>
<div data-tauri-drag-region className="h-8 flex-1" />
</div>
}
center={
<div
role="tablist"
aria-label="工作区模式"
className="relative flex h-[27px] items-center rounded-full border border-border/80 bg-background/80 p-0.5"
>
<div
className="pointer-events-none absolute bottom-[2px] top-[2px] w-8 rounded-full bg-accent transition-[left] duration-300 ease-out"
style={{ left: indicatorLeft }}
/>
{MODE_TABS.map((item) => {
const Icon = item.icon
const isActive = mode === item.mode
return (
<div
key={item.mode}
role="tab"
tabIndex={0}
className={`relative z-10 m-0 flex h-[23px] w-8 cursor-pointer select-none items-center justify-center rounded-full border-0 bg-transparent p-0 align-middle leading-none transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 ${
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground/80"
}`}
onClick={() => setMode(item.mode)}
onKeyDown={(event) => handleModeKeyDown(event, item.mode)}
onMouseDown={(event) => event.preventDefault()}
title={item.title}
aria-label={item.title}
aria-selected={isActive}
>
<Icon
className="block h-3 w-3 shrink-0"
shapeRendering="geometricPrecision"
/>
</div>
)
})}
</div>
}
right={
<div className="flex items-center gap-10">
<div className="flex items-center gap-2">
<CommandDropdown />
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={toggle}
title={`${isOpen ? "Hide Sidebar" : "Show Sidebar"} (${formatShortcutLabel(shortcuts.toggle_sidebar, isMac)})`}
>
<PanelLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
onClick={() => toggleTerminal()}
title={`Toggle Terminal (${formatShortcutLabel(shortcuts.toggle_terminal, isMac)})`}
>
<PanelBottom className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
onClick={toggleAuxPanel}
title={`Toggle Auxiliary Panel (${formatShortcutLabel(shortcuts.toggle_aux_panel, isMac)})`}
>
<PanelRight className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={() => setSearchOpen(true)}
title={`Search (${formatShortcutLabel(shortcuts.toggle_search, isMac)})`}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={handleOpenSettings}
title={`Open Settings (${formatShortcutLabel(shortcuts.open_settings, isMac)})`}
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
</div>
}
/>
<SearchCommandDialog open={searchOpen} onOpenChange={setSearchOpen} />
</>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import { useCallback, useRef } from "react"
import { ChevronsDownUp, ChevronsUpDown, Crosshair, Plus } from "lucide-react"
import { useFolderContext } from "@/contexts/folder-context"
import { useTabContext } from "@/contexts/tab-context"
import { useSidebarContext } from "@/contexts/sidebar-context"
import {
SidebarConversationList,
type SidebarConversationListHandle,
} from "@/components/conversations/sidebar-conversation-list"
import { Button } from "@/components/ui/button"
export function Sidebar() {
const { folder } = useFolderContext()
const { openNewConversationTab } = useTabContext()
const { isOpen } = useSidebarContext()
const listRef = useRef<SidebarConversationListHandle>(null)
const handleNewConversation = useCallback(() => {
if (!folder) return
openNewConversationTab("codex", folder.path)
}, [folder, openNewConversationTab])
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">Conversations</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="Locate Active Conversation"
>
<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="Expand All Groups"
>
<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="Collapse All Groups"
>
<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="New Conversation"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<SidebarConversationList ref={listRef} />
</aside>
)
}

View File

@@ -0,0 +1,175 @@
"use client"
import { CircleAlert, X, Trash2 } from "lucide-react"
import {
useAlertContext,
type AlertLevel,
type AlertAction,
} from "@/contexts/alert-context"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useAcpActions } from "@/contexts/acp-connections-context"
import { openUrl } from "@tauri-apps/plugin-opener"
import { openSettingsWindow } from "@/lib/tauri"
import { AGENT_LABELS, type AgentType } from "@/lib/types"
const KNOWN_AGENT_TYPES = new Set<AgentType>(
Object.keys(AGENT_LABELS) as AgentType[]
)
function parseAgentType(value: unknown): AgentType | null {
if (typeof value !== "string") return null
return KNOWN_AGENT_TYPES.has(value as AgentType) ? (value as AgentType) : null
}
function parseOpenAgentsSettingsPayload(payload: string): {
agentType: AgentType | null
} {
const normalized = payload.trim()
if (!normalized || normalized === "agents") {
return { agentType: null }
}
try {
const parsed = JSON.parse(normalized)
if (!parsed || typeof parsed !== "object") {
return { agentType: null }
}
return {
agentType: parseAgentType(
(parsed as { agentType?: unknown }).agentType ?? null
),
}
} catch {
return { agentType: null }
}
}
function AlertLevelIcon({ level }: { level: AlertLevel }) {
if (level === "error") {
return <CircleAlert className="h-3 w-3 shrink-0 text-red-500" />
}
return <CircleAlert className="h-3 w-3 shrink-0 text-yellow-500" />
}
function AlertActionButton({ action }: { action: AlertAction }) {
const { connect } = useAcpActions()
const handleClick = async () => {
switch (action.kind) {
case "open_url":
await openUrl(action.payload)
break
case "retry_connection": {
try {
const data = JSON.parse(action.payload)
await connect(
data.contextKey,
data.agentType,
data.workingDir,
data.sessionId
)
} catch (e) {
console.error("[AlertAction] retry_connection failed:", e)
}
break
}
case "redownload_binary": {
try {
const data = JSON.parse(action.payload)
await connect(
data.contextKey,
data.agentType,
data.workingDir,
data.sessionId
)
} catch (e) {
console.error("[AlertAction] redownload_binary failed:", e)
}
break
}
case "open_agents_settings":
await openSettingsWindow("agents", {
agentType: parseOpenAgentsSettingsPayload(action.payload).agentType,
})
break
}
}
return (
<button
onClick={handleClick}
className="text-[10px] px-1.5 py-0.5 rounded bg-accent hover:bg-accent/80 text-accent-foreground transition-colors"
>
{action.label}
</button>
)
}
export function StatusBarAlerts() {
const { alerts, hasAlerts, dismissAlert, clearAll } = useAlertContext()
return (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1 hover:text-foreground transition-colors">
<CircleAlert
className={`size-3.5 ${hasAlerts ? "text-red-500" : ""}`}
/>
{hasAlerts && <span className="text-red-500">{alerts.length}</span>}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-80 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium"></span>
{hasAlerts && (
<button
onClick={clearAll}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
{!hasAlerts ? (
<div className="text-xs text-muted-foreground py-2"></div>
) : (
<div className="space-y-2 max-h-56 overflow-y-auto">
{alerts.map((alert) => (
<div
key={alert.id}
className="flex items-start gap-2 text-xs group"
>
<AlertLevelIcon level={alert.level} />
<div className="flex-1 min-w-0">
<div className="break-words">{alert.message}</div>
{alert.detail && (
<div className="text-[10px] text-muted-foreground mt-0.5 break-all whitespace-pre-wrap">
{alert.detail}
</div>
)}
{alert.actions && alert.actions.length > 0 && (
<div className="flex items-center gap-1 mt-1">
{alert.actions.map((action, i) => (
<AlertActionButton key={i} action={action} />
))}
</div>
)}
</div>
<button
onClick={() => dismissAlert(alert.id)}
className="shrink-0 opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useCallback, useMemo, useSyncExternalStore } from "react"
import { Unplug } from "lucide-react"
import { useConnectionStore } from "@/contexts/acp-connections-context"
import { useTabContext } from "@/contexts/tab-context"
import { useFolderContext } from "@/contexts/folder-context"
import { AgentIcon } from "@/components/agent-icon"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { AGENT_LABELS } from "@/lib/types"
import { cn } from "@/lib/utils"
const STATUS_STYLE: Record<string, { className: string; title: string }> = {
connected: { className: "opacity-100", title: "Connected" },
connecting: {
className: "opacity-100 animate-pulse",
title: "Connecting...",
},
downloading: {
className: "opacity-100 animate-pulse",
title: "Downloading...",
},
prompting: {
className: "opacity-100 animate-pulse",
title: "Responding...",
},
error: { className: "opacity-50", title: "Connection error" },
}
export function StatusBarConnection() {
const store = useConnectionStore()
const { tabs, activeTabId } = useTabContext()
const { conversations } = useFolderContext()
// Subscribe to activeKey changes
const subscribeActiveKey = useCallback(
(cb: () => void) => store.subscribeActiveKey(cb),
[store]
)
const getActiveKey = useCallback(() => store.getActiveKey(), [store])
const activeKey = useSyncExternalStore(
subscribeActiveKey,
getActiveKey,
getActiveKey
)
// Subscribe to the active connection's changes
const subscribeConn = useCallback(
(cb: () => void) => {
if (!activeKey) return () => {}
return store.subscribeKey(activeKey, cb)
},
[store, activeKey]
)
const getConnSnapshot = useCallback(
() => (activeKey ? store.getConnection(activeKey) : undefined),
[store, activeKey]
)
const activeConn = useSyncExternalStore(
subscribeConn,
getConnSnapshot,
getConnSnapshot
)
const status = activeConn?.status ?? null
const agentType = activeConn?.agentType ?? null
const model = useMemo(() => {
const tab = tabs.find((t) => t.id === activeTabId)
if (!tab || tab.kind !== "conversation") return null
const conv = conversations.find(
(c) => c.id === tab.conversationId && c.agent_type === tab.agentType
)
return conv?.model ?? null
}, [tabs, activeTabId, conversations])
if (!agentType || !status || status === "disconnected") {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Unplug className="h-3.5 w-3.5 text-muted-foreground/50" />
{model && <span>{model}</span>}
</div>
</TooltipTrigger>
<TooltipContent side="top">Disconnected</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const style = STATUS_STYLE[status]
if (!style) return null
const label = AGENT_LABELS[agentType]
const tooltipText =
status === "error" && activeConn?.error
? `${label} - ${activeConn.error}`
: `${label} - ${style.title}`
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<AgentIcon
agentType={agentType}
className={cn("size-3", style.className)}
/>
{model && <span>{model}</span>}
</div>
</TooltipTrigger>
<TooltipContent side="top">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { useMemo } from "react"
import { MessageSquare, GitBranch } from "lucide-react"
import { useTabContext } from "@/contexts/tab-context"
import { useFolderContext } from "@/contexts/folder-context"
export function StatusBarSessionInfo() {
const { tabs, activeTabId } = useTabContext()
const { conversations } = useFolderContext()
const activeTab = useMemo(
() => tabs.find((t) => t.id === activeTabId) ?? null,
[tabs, activeTabId]
)
const summary = useMemo(() => {
if (!activeTab || activeTab.kind !== "conversation") return null
return conversations.find(
(c) =>
c.id === activeTab.conversationId &&
c.agent_type === activeTab.agentType
)
}, [activeTab, conversations])
if (!summary) return null
return (
<div className="flex items-center gap-4">
{summary.git_branch && (
<span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{summary.git_branch}
</span>
)}
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{summary.message_count}
</span>
</div>
)
}

View File

@@ -0,0 +1,62 @@
"use client"
import { useMemo } from "react"
import { BarChart3 } from "lucide-react"
import { useFolderContext } from "@/contexts/folder-context"
import { AGENT_LABELS } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export function StatusBarStats() {
const { stats } = useFolderContext()
const activeAgents = useMemo(
() => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [],
[stats]
)
if (!stats) return null
return (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1.5 hover:text-foreground transition-colors">
<BarChart3 className="h-3 w-3" />
<span>{stats.total_conversations} conversations</span>
<span className="flex items-center gap-1 ml-1">
{activeAgents.map((a) => (
<AgentIcon
key={a.agent_type}
agentType={a.agent_type}
className="w-3 h-3"
/>
))}
</span>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-64 p-3">
<div className="text-xs font-medium mb-2">
{stats.total_conversations} conversations / {stats.total_messages}{" "}
messages
</div>
<div className="space-y-1.5">
{activeAgents.map((a) => (
<div key={a.agent_type} className="flex items-center gap-2 text-xs">
<AgentIcon agentType={a.agent_type} className="w-3.5 h-3.5" />
<span className="text-muted-foreground">
{AGENT_LABELS[a.agent_type]}
</span>
<span className="ml-auto text-muted-foreground">
{a.conversation_count}
</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,75 @@
"use client"
import { Clock } from "lucide-react"
import { useTaskContext } from "@/contexts/task-context"
import { Skeleton } from "@/components/ui/skeleton"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
function Spinner({ className }: { className?: string }) {
return (
<div
className={`animate-spin rounded-full border-[1.5px] border-current border-t-transparent ${className}`}
/>
)
}
export function StatusBarTasks() {
const { tasks } = useTaskContext()
if (tasks.length === 0) return null
const runningTask = tasks.find(
(t) => t.status === "running" || t.status === "pending"
)
return (
<div className="flex items-center gap-2">
{runningTask && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate max-w-40">
{runningTask.label || runningTask.description}
</span>
<Skeleton className="h-1 w-28 rounded bg-primary/80" />
<Spinner className="h-3 w-3 shrink-0" />
</div>
)}
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1 hover:text-foreground transition-colors">
{!runningTask && <Clock className="h-3 w-3" />}
{tasks.length > 1 && <span>{tasks.length}</span>}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-72 p-3">
<div className="text-xs font-medium mb-2"></div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{tasks.map((task) => (
<div key={task.id} className="space-y-1">
<div className="flex items-center gap-2 text-xs">
{task.status === "running" ? (
<Spinner className="h-3 w-3 text-blue-500" />
) : (
<Clock className="h-3 w-3 text-muted-foreground" />
)}
<span className="truncate flex-1">{task.label}</span>
</div>
{task.status === "running" && task.progress != null && (
<div className="h-1 rounded-full bg-muted ml-5">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,178 @@
"use client"
import { Coins } from "lucide-react"
import { useSessionStats } from "@/contexts/session-stats-context"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const ICON_RADIUS = 6
const ICON_CENTER = 8
const ICON_VIEWBOX = 16
const ICON_CIRCUMFERENCE = 2 * Math.PI * ICON_RADIUS
function formatNumber(n: number): string {
return n.toLocaleString()
}
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatPercent(percent: number | null): string {
if (percent == null) return "--"
return `${percent.toFixed(1)}%`
}
export function StatusBarTokens() {
const { sessionStats } = useSessionStats()
const usage = sessionStats?.total_usage
const contextUsed = sessionStats?.context_window_used_tokens ?? null
const contextMax = sessionStats?.context_window_max_tokens ?? null
const contextPercentRaw =
sessionStats?.context_window_usage_percent ??
(contextUsed != null && contextMax != null && contextMax > 0
? (contextUsed / contextMax) * 100
: null)
const contextPercent =
contextPercentRaw == null
? null
: Math.max(0, Math.min(100, contextPercentRaw))
const hasContext = contextPercent != null
const hasUsage = usage != null
const fallbackTotal = hasUsage
? usage.input_tokens +
usage.output_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens
: null
const total = sessionStats?.total_tokens ?? fallbackTotal
const dashOffset = ICON_CIRCUMFERENCE * (1 - (contextPercent ?? 0) / 100)
const rows: { label: string; value: number }[] = []
if (hasUsage) {
rows.push(
{ label: "Input", value: usage.input_tokens },
{ label: "Output", value: usage.output_tokens },
{ label: "Cache Read", value: usage.cache_read_input_tokens },
{ label: "Cache Write", value: usage.cache_creation_input_tokens }
)
}
if (total != null) {
rows.push({ label: "Total", value: total })
}
const hasTokenSection = rows.length > 0
if (!hasContext && !hasTokenSection) return null
return (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1 hover:text-foreground transition-colors">
{hasContext ? (
<>
<svg
aria-label="Context window usage"
className="size-3.5"
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
r={ICON_RADIUS}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
opacity="0.25"
/>
<circle
cx={ICON_CENTER}
cy={ICON_CENTER}
r={ICON_RADIUS}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeDasharray={`${ICON_CIRCUMFERENCE} ${ICON_CIRCUMFERENCE}`}
strokeDashoffset={dashOffset}
style={{
transformOrigin: "center",
transform: "rotate(-90deg)",
}}
opacity="0.75"
/>
</svg>
<span>{formatPercent(contextPercent)}</span>
</>
) : (
<>
<Coins className="size-3.5" />
<span>{formatTokenCount(total ?? 0)}</span>
</>
)}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-56 gap-2 p-3 text-xs">
{hasContext ? (
<div
className={`space-y-1 ${
hasUsage ? "mb-0.5 border-b border-border pb-0.5" : ""
}`}
>
<div className="flex items-center justify-between gap-2 text-xs font-medium whitespace-nowrap">
<span>Context Window</span>
<span className="tabular-nums shrink-0">
{formatPercent(contextPercent)}
</span>
</div>
<div className="relative h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="absolute inset-y-0 left-0 bg-foreground/70"
style={{ width: `${contextPercent ?? 0}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs leading-none text-muted-foreground">
<span>Used / Max</span>
<span className="tabular-nums">
{contextUsed == null || contextMax == null
? "--"
: `${formatNumber(contextUsed)} / ${formatNumber(contextMax)}`}
</span>
</div>
</div>
) : null}
{hasTokenSection ? (
<>
<div className="mb-0 mt-0.5 text-xs leading-none font-medium">
Token Usage
</div>
<div className="space-y-0">
{rows.map((row) => (
<div
key={row.label}
className={`flex items-center justify-between py-0.5 text-xs leading-none ${
row.label === "Total"
? "mt-0.5 border-t border-border pt-0.5 font-medium"
: "text-muted-foreground"
}`}
>
<span>{row.label}</span>
<span className="tabular-nums">
{formatNumber(row.value)}
</span>
</div>
))}
</div>
</>
) : null}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { StatusBarStats } from "@/components/layout/status-bar-stats"
import { StatusBarSessionInfo } from "@/components/layout/status-bar-session-info"
import { StatusBarTasks } from "@/components/layout/status-bar-tasks"
import { StatusBarTokens } from "@/components/layout/status-bar-tokens"
import { StatusBarConnection } from "@/components/layout/status-bar-connection"
import { StatusBarAlerts } from "@/components/layout/status-bar-alerts"
export function StatusBar() {
return (
<div className="h-8 shrink-0 border-t border-border bg-muted px-4 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center">
<StatusBarStats />
</div>
<div className="flex items-center gap-4">
<StatusBarTasks />
<StatusBarSessionInfo />
<StatusBarTokens />
<StatusBarConnection />
<StatusBarAlerts />
</div>
</div>
)
}

View File

@@ -0,0 +1,153 @@
"use client"
import { useEffect, useState } from "react"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { usePlatform } from "@/hooks/use-platform"
import { cn } from "@/lib/utils"
export function WindowControls() {
const { isWindows } = usePlatform()
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!isWindows) return
let disposed = false
let unlistenResize: (() => void) | null = null
let resizeFrame: number | null = null
const appWindow = getCurrentWindow()
const syncMaximized = async () => {
try {
const maximized = await appWindow.isMaximized()
if (!disposed) {
setIsMaximized(maximized)
}
} catch {
if (!disposed) {
setIsMaximized(false)
}
}
}
const scheduleSync = () => {
if (resizeFrame !== null) return
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = null
void syncMaximized()
})
}
void syncMaximized()
appWindow
.onResized(() => {
scheduleSync()
})
.then((unlisten) => {
unlistenResize = unlisten
})
.catch(() => {
unlistenResize = null
})
return () => {
disposed = true
if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame)
}
unlistenResize?.()
}
}, [isWindows])
if (!isWindows) return null
const appWindow = getCurrentWindow()
return (
<div className="flex h-8 items-stretch [-webkit-app-region:no-drag]">
<button
type="button"
className={buttonClass}
onClick={() => {
appWindow.minimize().catch((err) => {
console.error("[WindowControls] failed to minimize:", err)
})
}}
aria-label="Minimize window"
title="Minimize"
>
<MinimizeIcon />
</button>
<button
type="button"
className={buttonClass}
onClick={() => {
appWindow.toggleMaximize().catch((err) => {
console.error("[WindowControls] failed to toggle maximize:", err)
})
}}
aria-label={isMaximized ? "Restore window" : "Maximize window"}
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? <RestoreIcon /> : <MaximizeIcon />}
</button>
<button
type="button"
className={cn(
buttonClass,
"hover:bg-[#e81123] hover:text-white active:bg-[#c50f1f] active:text-white"
)}
onClick={() => {
appWindow.close().catch((err) => {
console.error("[WindowControls] failed to close:", err)
})
}}
aria-label="Close window"
title="Close"
>
<CloseIcon />
</button>
</div>
)
}
const buttonClass =
"flex h-8 w-[46px] items-center justify-center text-foreground/85 transition-colors duration-75 hover:bg-foreground/10 active:bg-foreground/15"
function MinimizeIcon() {
return (
<span
aria-hidden
className="inline-block h-px w-[10px] translate-y-[2px] bg-current"
/>
)
}
function MaximizeIcon() {
return (
<span
aria-hidden
className="inline-block h-[10px] w-[10px] border border-current"
/>
)
}
function RestoreIcon() {
return (
<span aria-hidden className="relative inline-block h-[10px] w-[10px]">
<span className="absolute right-0 top-0 h-[7px] w-[7px] border border-current" />
<span className="absolute bottom-0 left-0 h-[7px] w-[7px] border border-current" />
</span>
)
}
function CloseIcon() {
return (
<span aria-hidden className="relative inline-block h-[10px] w-[10px]">
<span className="absolute left-1/2 top-0 h-[10px] w-px -translate-x-1/2 rotate-45 bg-current" />
<span className="absolute left-1/2 top-0 h-[10px] w-px -translate-x-1/2 -rotate-45 bg-current" />
</span>
)
}