Initial commit
This commit is contained in:
80
src/components/layout/app-title-bar.tsx
Normal file
80
src/components/layout/app-title-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2436
src/components/layout/aux-panel-file-tree-tab.tsx
Normal file
2436
src/components/layout/aux-panel-file-tree-tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1380
src/components/layout/aux-panel-git-changes-tab.tsx
Normal file
1380
src/components/layout/aux-panel-git-changes-tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1123
src/components/layout/aux-panel-git-log-tab.tsx
Normal file
1123
src/components/layout/aux-panel-git-log-tab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
409
src/components/layout/aux-panel-session-files-tab.tsx
Normal file
409
src/components/layout/aux-panel-session-files-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
src/components/layout/aux-panel.tsx
Normal file
101
src/components/layout/aux-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
752
src/components/layout/branch-dropdown.tsx
Normal file
752
src/components/layout/branch-dropdown.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
365
src/components/layout/command-dropdown.tsx
Normal file
365
src/components/layout/command-dropdown.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
181
src/components/layout/command-manage-dialog.tsx
Normal file
181
src/components/layout/command-manage-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
998
src/components/layout/commit-dialog.tsx
Normal file
998
src/components/layout/commit-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
src/components/layout/folder-name-dropdown.tsx
Normal file
139
src/components/layout/folder-name-dropdown.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
309
src/components/layout/folder-title-bar.tsx
Normal file
309
src/components/layout/folder-title-bar.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
src/components/layout/sidebar.tsx
Normal file
74
src/components/layout/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
src/components/layout/status-bar-alerts.tsx
Normal file
175
src/components/layout/status-bar-alerts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
src/components/layout/status-bar-connection.tsx
Normal file
123
src/components/layout/status-bar-connection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/layout/status-bar-session-info.tsx
Normal file
42
src/components/layout/status-bar-session-info.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/components/layout/status-bar-stats.tsx
Normal file
62
src/components/layout/status-bar-stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
src/components/layout/status-bar-tasks.tsx
Normal file
75
src/components/layout/status-bar-tasks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
src/components/layout/status-bar-tokens.tsx
Normal file
178
src/components/layout/status-bar-tokens.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/components/layout/status-bar.tsx
Normal file
25
src/components/layout/status-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
src/components/layout/window-controls.tsx
Normal file
153
src/components/layout/window-controls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user