增强git贮藏(stash)功能,支持可视化操作

This commit is contained in:
xintaofei
2026-03-15 22:09:05 +08:00
parent 344565b1c8
commit d03be55c6b
20 changed files with 1335 additions and 30 deletions

View File

@@ -75,16 +75,16 @@ import {
gitMerge,
gitRebase,
gitDeleteBranch,
gitStash,
gitStashPop,
openFolderWindow,
openCommitWindow,
setFolderParentBranch,
gitListConflicts,
gitHasMergeHead,
openStashWindow,
} from "@/lib/tauri"
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
import { ConflictDialog } from "@/components/layout/conflict-dialog"
import { StashDialog } from "@/components/layout/stash-dialog"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { toErrorMessage } from "@/lib/app-error"
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
@@ -138,6 +138,7 @@ export function BranchDropdown({
const [worktreeBranchName, setWorktreeBranchName] = useState("")
const [worktreePath, setWorktreePath] = useState("")
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
const [stashDialogOpen, setStashDialogOpen] = useState(false)
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
@@ -761,18 +762,23 @@ export function BranchDropdown({
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.stashChanges"), () => gitStash(folderPath))
}
onSelect={() => {
setDropdownOpen(false)
setStashDialogOpen(true)
}}
>
<Archive className="h-3.5 w-3.5" />
{t("stashChanges")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.stashPop"), () => gitStashPop(folderPath))
}
onSelect={() => {
if (!folder) return
openStashWindow(folder.id).catch((err) => {
const msg = toErrorMessage(err)
pushAlert("error", t("stashPop"), msg)
})
}}
>
<ArchiveRestore className="h-3.5 w-3.5" />
{t("stashPop")}
@@ -1002,6 +1008,13 @@ export function BranchDropdown({
onClose={() => setConflictInfo(null)}
onResolved={onBranchChange}
/>
<StashDialog
open={stashDialogOpen}
folderPath={folderPath}
onClose={() => setStashDialogOpen(false)}
onStashed={onBranchChange}
/>
</>
)
}

View File

@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { gitStashPush } from "@/lib/tauri"
import { toErrorMessage } from "@/lib/app-error"
interface StashDialogProps {
open: boolean
folderPath: string
onClose: () => void
onStashed: () => void
}
export function StashDialog({
open,
folderPath,
onClose,
onStashed,
}: StashDialogProps) {
const t = useTranslations("Folder.branchDropdown.stashDialog")
const [message, setMessage] = useState("")
const [keepIndex, setKeepIndex] = useState(false)
const [loading, setLoading] = useState(false)
function handleClose() {
if (loading) return
setMessage("")
setKeepIndex(false)
onClose()
}
async function handleStash() {
setLoading(true)
try {
await gitStashPush(folderPath, message.trim() || undefined, keepIndex)
toast.success(t("success"))
setMessage("")
setKeepIndex(false)
onStashed()
onClose()
} catch (err) {
toast.error(t("error"), { description: toErrorMessage(err) })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="stash-message">{t("messageLabel")}</Label>
<Input
id="stash-message"
placeholder={t("messagePlaceholder")}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !loading) {
handleStash()
}
}}
disabled={loading}
autoFocus
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="keep-index"
checked={keepIndex}
onCheckedChange={setKeepIndex}
disabled={loading}
/>
<Label htmlFor="keep-index">{t("keepIndex")}</Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleClose}
disabled={loading}
>
{t("cancel")}
</Button>
<Button size="sm" onClick={handleStash} disabled={loading}>
{loading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t("stash")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,541 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Archive, ArchiveRestore, ChevronRight, Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import {
FileTree,
FileTreeFile,
FileTreeFolder,
} from "@/components/ai-elements/file-tree"
import { DiffViewer } from "@/components/diff/diff-viewer"
import {
gitStashList,
gitStashShow,
gitStashApply,
gitStashDrop,
gitShowFile,
} from "@/lib/tauri"
import { toErrorMessage } from "@/lib/app-error"
import { languageFromPath } from "@/lib/language-detect"
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
import { cn } from "@/lib/utils"
// --- File tree types & builder (same pattern as commit-dialog) ---
interface TreeFileNode {
kind: "file"
name: string
path: string
entry: GitStatusEntry
}
interface TreeDirNode {
kind: "dir"
name: string
path: string
children: TreeNode[]
}
type TreeNode = TreeFileNode | TreeDirNode
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)
}
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 p = ""
for (let i = 0; i < parts.length - 1; i += 1) {
p = p ? `${p}/${parts[i]}` : parts[i]
paths.add(p)
}
}
return paths
}
function statusColor(status: string) {
switch (status.charAt(0).toUpperCase()) {
case "A":
return "text-green-500"
case "D":
return "text-red-500"
case "M":
return "text-blue-500"
default:
return "text-muted-foreground"
}
}
// --- Main component ---
interface StashWorkspaceProps {
folderPath: string
}
export function StashWorkspace({ folderPath }: StashWorkspaceProps) {
const t = useTranslations("Folder.branchDropdown.unstashDialog")
const [stashes, setStashes] = useState<GitStashEntry[]>([])
const [expandedStash, setExpandedStash] = useState<string | null>(null)
const [stashFiles, setStashFiles] = useState<
Record<string, GitStatusEntry[]>
>({})
const [filesLoading, setFilesLoading] = useState<string | null>(null)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [selectedStashRef, setSelectedStashRef] = useState<string | null>(null)
const [originalContent, setOriginalContent] = useState("")
const [modifiedContent, setModifiedContent] = useState("")
const [listLoading, setListLoading] = useState(false)
const [diffLoading, setDiffLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const loadStashes = useCallback(async () => {
setListLoading(true)
try {
const list = await gitStashList(folderPath)
setStashes(list)
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setListLoading(false)
}
}, [folderPath])
useEffect(() => {
loadStashes()
}, [loadStashes])
async function handleToggleStash(stashRef: string) {
if (expandedStash === stashRef) {
setExpandedStash(null)
return
}
setExpandedStash(stashRef)
if (!stashFiles[stashRef]) {
setFilesLoading(stashRef)
try {
const fileList = await gitStashShow(folderPath, stashRef)
setStashFiles((prev) => ({ ...prev, [stashRef]: fileList }))
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setFilesLoading(null)
}
}
}
async function handleSelectFile(stashRef: string, file: string) {
setSelectedFile(file)
setSelectedStashRef(stashRef)
setDiffLoading(true)
try {
const [orig, mod] = await Promise.all([
gitShowFile(folderPath, file, stashRef + "^").catch(() => ""),
gitShowFile(folderPath, file, stashRef).catch(() => ""),
])
setOriginalContent(orig)
setModifiedContent(mod)
} catch {
setOriginalContent("")
setModifiedContent("")
} finally {
setDiffLoading(false)
}
}
async function handleApply(stashRef: string) {
setActionLoading(true)
try {
await gitStashApply(folderPath, stashRef)
toast.success(t("applySuccess"))
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setActionLoading(false)
}
}
async function handleDrop(stashRef: string) {
setActionLoading(true)
try {
await gitStashDrop(folderPath, stashRef)
toast.success(t("dropSuccess"))
if (expandedStash === stashRef) {
setExpandedStash(null)
}
if (selectedStashRef === stashRef) {
setSelectedFile(null)
setSelectedStashRef(null)
setOriginalContent("")
setModifiedContent("")
}
setStashFiles((prev) => {
const next = { ...prev }
delete next[stashRef]
return next
})
await loadStashes()
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setActionLoading(false)
}
}
// Render file tree nodes
function renderNode(node: TreeNode, stashRef: string): React.ReactNode {
if (node.kind === "dir") {
return (
<FileTreeFolder key={node.path} name={node.name} path={node.path}>
{node.children.map((child) => renderNode(child, stashRef))}
</FileTreeFolder>
)
}
return (
<ContextMenu key={node.path}>
<ContextMenuTrigger>
<FileTreeFile
name={node.name}
path={node.path}
className="gap-1 px-1.5 py-1"
>
<span className="flex-1 truncate text-left" title={node.path}>
{node.name}
</span>
<span
className={cn(
"w-5 shrink-0 text-right text-xs font-bold",
statusColor(node.entry.status)
)}
>
{node.entry.status.charAt(0)}
</span>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => handleSelectFile(stashRef, node.path)}
>
{t("viewDiff")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
return (
<ResizablePanelGroup direction="horizontal" className="h-full">
{/* Left panel: stash cards */}
<ResizablePanel defaultSize={35} minSize={25}>
<ScrollArea className="h-full">
{listLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : stashes.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("noStashes")}
</div>
) : (
<div className="flex flex-col gap-2 p-2">
{stashes.map((stash) => (
<StashCard
key={stash.ref_name}
stash={stash}
isExpanded={expandedStash === stash.ref_name}
isLoadingFiles={filesLoading === stash.ref_name}
actionLoading={actionLoading}
files={stashFiles[stash.ref_name]}
selectedFile={
selectedStashRef === stash.ref_name ? selectedFile : null
}
onToggle={() => handleToggleStash(stash.ref_name)}
onApply={() => handleApply(stash.ref_name)}
onDrop={() => handleDrop(stash.ref_name)}
onSelectFile={(file) =>
handleSelectFile(stash.ref_name, file)
}
renderNode={(node) => renderNode(node, stash.ref_name)}
t={t}
/>
))}
</div>
)}
</ScrollArea>
</ResizablePanel>
<ResizableHandle />
{/* Right panel: diff viewer */}
<ResizablePanel defaultSize={65} minSize={40}>
{diffLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : selectedFile && selectedStashRef ? (
<DiffViewer
original={originalContent}
modified={modifiedContent}
originalLabel={`${selectedStashRef}^ (${t("original")})`}
modifiedLabel={`${selectedStashRef} (${t("modified")})`}
language={languageFromPath(selectedFile)}
className="h-full"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("selectFile")}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)
}
// --- Stash Card Component ---
interface StashCardProps {
stash: GitStashEntry
isExpanded: boolean
isLoadingFiles: boolean
actionLoading: boolean
files?: GitStatusEntry[]
selectedFile: string | null
onToggle: () => void
onApply: () => void
onDrop: () => void
onSelectFile: (file: string) => void
renderNode: (node: TreeNode) => React.ReactNode
t: ReturnType<typeof useTranslations>
}
function StashCard({
stash,
isExpanded,
isLoadingFiles,
actionLoading,
files,
selectedFile,
onToggle,
onApply,
onDrop,
onSelectFile,
renderNode,
t,
}: StashCardProps) {
const [confirmApplyOpen, setConfirmApplyOpen] = useState(false)
const tree = useMemo(() => (files ? buildFileTree(files) : []), [files])
const defaultExpanded = useMemo(
() => (files ? collectDirPaths(files) : new Set<string>()),
[files]
)
return (
<>
<ContextMenu>
<ContextMenuTrigger>
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<div className="group rounded-lg border bg-card">
<div className="relative flex items-center">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 rounded-t-lg px-3 py-2 text-left text-sm transition-colors group-hover:bg-muted/50"
disabled={actionLoading}
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90"
)}
/>
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-muted-foreground">
{stash.ref_name}
</span>
<span className="truncate font-medium">
{stash.message}
</span>
</div>
<div className="flex gap-2 text-[10px] text-muted-foreground/70">
<span>{stash.branch}</span>
<span>{stash.date}</span>
</div>
</div>
</button>
</CollapsibleTrigger>
<button
type="button"
className="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
title={t("apply") as string}
onClick={() => setConfirmApplyOpen(true)}
disabled={actionLoading}
>
<ArchiveRestore className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
<CollapsibleContent>
<div className="border-t">
{/* File tree */}
{isLoadingFiles ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : tree.length > 0 ? (
<div className="px-2 pb-2">
<FileTree
defaultExpanded={defaultExpanded}
selectedPath={selectedFile ?? undefined}
onSelect={onSelectFile}
className="border-0 bg-transparent"
>
{tree.map(renderNode)}
</FileTree>
</div>
) : null}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setConfirmApplyOpen(true)}>
{t("apply")}
</ContextMenuItem>
<ContextMenuItem variant="destructive" onClick={onDrop}>
{t("drop")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<AlertDialog open={confirmApplyOpen} onOpenChange={setConfirmApplyOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("apply")}</AlertDialogTitle>
<AlertDialogDescription>
{t("confirmApply", { ref: stash.ref_name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmApplyOpen(false)
onApply()
}}
>
{t("apply")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}