增强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

111
src/app/stash/page.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import { Loader2 } from "lucide-react"
import { StashWorkspace } from "@/components/layout/unstash-dialog"
import { AppTitleBar } from "@/components/layout/app-title-bar"
import { AppToaster } from "@/components/ui/app-toaster"
import { getFolder } from "@/lib/tauri"
import type { FolderDetail } from "@/lib/types"
const TOAST_DURATION_MS = 6000
interface FolderLoadState {
loadedId: number | null
folder: FolderDetail | null
error: string | null
}
function StashPageInner() {
const t = useTranslations("Folder.branchDropdown.unstashDialog")
const searchParams = useSearchParams()
const [state, setState] = useState<FolderLoadState>({
loadedId: null,
folder: null,
error: null,
})
const folderId = Number(searchParams.get("folderId") ?? "0")
const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0
const hasValidFolderId = normalizedFolderId > 0
const loading = hasValidFolderId && state.loadedId !== normalizedFolderId
const folder = state.loadedId === normalizedFolderId ? state.folder : null
const error = state.loadedId === normalizedFolderId ? state.error : null
useEffect(() => {
if (!hasValidFolderId) return
let cancelled = false
getFolder(normalizedFolderId)
.then((detail) => {
if (!cancelled) {
setState({
loadedId: normalizedFolderId,
folder: detail,
error: null,
})
}
})
.catch((err) => {
if (!cancelled) {
setState({
loadedId: normalizedFolderId,
folder: null,
error: String(err),
})
}
})
return () => {
cancelled = true
}
}, [hasValidFolderId, normalizedFolderId])
return (
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
<AppTitleBar
center={
<div className="text-sm font-semibold tracking-tight">
{t("title")}
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
</div>
}
/>
<main className="min-h-0 flex-1">
{!hasValidFolderId ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
Invalid folder ID
</div>
) : loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</div>
) : error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : folder ? (
<StashWorkspace folderPath={folder.path} />
) : null}
</main>
<AppToaster
position="bottom-right"
duration={TOAST_DURATION_MS}
closeButton
/>
</div>
)
}
export default function StashPage() {
return (
<Suspense>
<StashPageInner />
</Suspense>
)
}

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>
</>
)
}

View File

@@ -827,7 +827,7 @@
"pushCode": "دفع...",
"newBranch": "فرع جديد...",
"newWorktree": "Worktree جديد...",
"stashChanges": "تخزين التغييرات في stash",
"stashChanges": "...تخبئة التغييرات",
"stashPop": "استرجاع stash...",
"manageRemotes": "إدارة المستودعات البعيدة...",
"localBranches": "الفروع المحلية ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "إتمام الدمج",
"abortSuccess": "تم إلغاء الدمج بنجاح",
"completeSuccess": "تم إتمام الدمج بنجاح"
},
"stashDialog": {
"title": "تخبئة التغييرات",
"description": "حفظ التغييرات الحالية في المخبأ",
"messageLabel": "رسالة",
"messagePlaceholder": "رسالة التخبئة (اختياري)",
"keepIndex": "الاحتفاظ بالفهرس (التغييرات المرحلة تبقى مرحلة)",
"cancel": "إلغاء",
"stash": "تخبئة",
"success": "تم تخبئة التغييرات",
"error": "فشل في تخبئة التغييرات"
},
"unstashDialog": {
"title": "تطبيق المخبأ",
"noStashes": "لا توجد تخبئات",
"selectFile": "اختر ملفاً لعرض الفرق",
"viewDiff": "عرض الفرق",
"original": "الأصلي",
"modified": "المعدل",
"apply": "تطبيق",
"drop": "حذف",
"applySuccess": "تم تطبيق التخبئة",
"dropSuccess": "تم حذف التخبئة",
"confirmApply": "تطبيق التخبئة {ref} على دليل العمل؟",
"cancel": "إلغاء"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "Hochladen...",
"newBranch": "Neuer Branch...",
"newWorktree": "Neuer Worktree...",
"stashChanges": "Änderungen stashen",
"stashChanges": "Änderungen stashen...",
"stashPop": "Stash anwenden...",
"manageRemotes": "Remotes verwalten...",
"localBranches": "Lokale Branches ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "Merge abschließen",
"abortSuccess": "Merge erfolgreich abgebrochen",
"completeSuccess": "Merge erfolgreich abgeschlossen"
},
"stashDialog": {
"title": "Änderungen stashen",
"description": "Aktuelle Änderungen im Stash speichern",
"messageLabel": "Nachricht",
"messagePlaceholder": "Stash-Nachricht (optional)",
"keepIndex": "Index beibehalten (gestagete Änderungen bleiben erhalten)",
"cancel": "Abbrechen",
"stash": "Stashen",
"success": "Änderungen wurden gestasht",
"error": "Stash fehlgeschlagen"
},
"unstashDialog": {
"title": "Stash anwenden",
"noStashes": "Keine Stashes vorhanden",
"selectFile": "Datei auswählen um Diff anzuzeigen",
"viewDiff": "Diff anzeigen",
"original": "Original",
"modified": "Geändert",
"apply": "Anwenden",
"drop": "Löschen",
"applySuccess": "Stash angewendet",
"dropSuccess": "Stash gelöscht",
"confirmApply": "Stash {ref} auf das Arbeitsverzeichnis anwenden?",
"cancel": "Abbrechen"
}
},
"commitDialog": {

View File

@@ -827,8 +827,8 @@
"pushCode": "Push...",
"newBranch": "New branch...",
"newWorktree": "New worktree...",
"stashChanges": "Stash changes",
"stashPop": "Pop stash...",
"stashChanges": "Stash changes...",
"stashPop": "Unstash...",
"manageRemotes": "Manage Remotes...",
"localBranches": "Local branches ({count, plural, one {#} other {#}})",
"noLocalBranches": "No local branches",
@@ -859,6 +859,31 @@
"completeMerge": "Complete Merge",
"abortSuccess": "Merge aborted successfully",
"completeSuccess": "Merge completed successfully"
},
"stashDialog": {
"title": "Stash Changes",
"description": "Save your current changes to a stash",
"messageLabel": "Message",
"messagePlaceholder": "Stash message (optional)",
"keepIndex": "Keep index (staged changes remain staged)",
"cancel": "Cancel",
"stash": "Stash",
"success": "Changes stashed successfully",
"error": "Failed to stash changes"
},
"unstashDialog": {
"title": "Unstash Changes",
"noStashes": "No stashes found",
"selectFile": "Select a file to view diff",
"viewDiff": "View Diff",
"original": "Original",
"modified": "Modified",
"apply": "Apply",
"drop": "Drop",
"applySuccess": "Stash applied successfully",
"dropSuccess": "Stash dropped",
"confirmApply": "Apply stash {ref} to working directory?",
"cancel": "Cancel"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "Enviar...",
"newBranch": "Nueva rama...",
"newWorktree": "Nuevo worktree...",
"stashChanges": "Guardar cambios en stash",
"stashChanges": "Guardar cambios...",
"stashPop": "Aplicar stash...",
"manageRemotes": "Gestionar remotos...",
"localBranches": "Ramas locales ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "Completar fusión",
"abortSuccess": "Fusión abortada correctamente",
"completeSuccess": "Fusión completada correctamente"
},
"stashDialog": {
"title": "Guardar cambios en stash",
"description": "Guardar los cambios actuales en el stash",
"messageLabel": "Mensaje",
"messagePlaceholder": "Mensaje del stash (opcional)",
"keepIndex": "Mantener índice (los cambios preparados permanecen preparados)",
"cancel": "Cancelar",
"stash": "Guardar",
"success": "Cambios guardados en stash",
"error": "Error al guardar en stash"
},
"unstashDialog": {
"title": "Aplicar stash",
"noStashes": "No hay stashes",
"selectFile": "Selecciona un archivo para ver diferencias",
"viewDiff": "Ver diferencias",
"original": "Original",
"modified": "Modificado",
"apply": "Aplicar",
"drop": "Eliminar",
"applySuccess": "Stash aplicado",
"dropSuccess": "Stash eliminado",
"confirmApply": "¿Aplicar stash {ref} al directorio de trabajo?",
"cancel": "Cancelar"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "Pousser...",
"newBranch": "Nouvelle branche...",
"newWorktree": "Nouveau worktree...",
"stashChanges": "Stash des changements",
"stashChanges": "Remiser les changements...",
"stashPop": "Appliquer le stash...",
"manageRemotes": "Gérer les dépôts distants...",
"localBranches": "Branches locales ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "Terminer la fusion",
"abortSuccess": "Fusion abandonnée avec succès",
"completeSuccess": "Fusion terminée avec succès"
},
"stashDialog": {
"title": "Remiser les changements",
"description": "Sauvegarder les changements actuels dans la remise",
"messageLabel": "Message",
"messagePlaceholder": "Message de remise (optionnel)",
"keepIndex": "Conserver l'index (les changements indexés restent indexés)",
"cancel": "Annuler",
"stash": "Remiser",
"success": "Changements remisés",
"error": "Échec de la remise"
},
"unstashDialog": {
"title": "Appliquer la remise",
"noStashes": "Aucune remise trouvée",
"selectFile": "Sélectionner un fichier pour voir le diff",
"viewDiff": "Voir le diff",
"original": "Original",
"modified": "Modifié",
"apply": "Appliquer",
"drop": "Supprimer",
"applySuccess": "Remise appliquée",
"dropSuccess": "Remise supprimée",
"confirmApply": "Appliquer la remise {ref} au répertoire de travail ?",
"cancel": "Annuler"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "プッシュ...",
"newBranch": "新規ブランチ...",
"newWorktree": "新規ワークツリー...",
"stashChanges": "変更を stash",
"stashChanges": "スタッシュ...",
"stashPop": "stash を pop...",
"manageRemotes": "リモート管理...",
"localBranches": "ローカルブランチ ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "マージ完了",
"abortSuccess": "マージが中止されました",
"completeSuccess": "マージが完了しました"
},
"stashDialog": {
"title": "変更をスタッシュ",
"description": "現在の変更をスタッシュに保存",
"messageLabel": "メッセージ",
"messagePlaceholder": "スタッシュメッセージ(任意)",
"keepIndex": "インデックスを保持(ステージ済みの変更はそのまま)",
"cancel": "キャンセル",
"stash": "スタッシュ",
"success": "変更がスタッシュされました",
"error": "スタッシュに失敗しました"
},
"unstashDialog": {
"title": "スタッシュを適用",
"noStashes": "スタッシュがありません",
"selectFile": "ファイルを選択して差分を表示",
"viewDiff": "差分を表示",
"original": "元",
"modified": "変更後",
"apply": "適用",
"drop": "削除",
"applySuccess": "スタッシュを適用しました",
"dropSuccess": "スタッシュを削除しました",
"confirmApply": "スタッシュ {ref} を作業ディレクトリに適用しますか?",
"cancel": "キャンセル"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "푸시...",
"newBranch": "새 브랜치...",
"newWorktree": "새 워크트리...",
"stashChanges": "변경 사항 stash",
"stashChanges": "스태시...",
"stashPop": "stash pop...",
"manageRemotes": "원격 관리...",
"localBranches": "로컬 브랜치 ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "병합 완료",
"abortSuccess": "병합이 중단되었습니다",
"completeSuccess": "병합이 완료되었습니다"
},
"stashDialog": {
"title": "변경 사항 스태시",
"description": "현재 변경 사항을 스태시에 저장",
"messageLabel": "메시지",
"messagePlaceholder": "스태시 메시지 (선택사항)",
"keepIndex": "인덱스 유지 (스테이지된 변경 사항 유지)",
"cancel": "취소",
"stash": "스태시",
"success": "변경 사항이 스태시되었습니다",
"error": "스태시 실패"
},
"unstashDialog": {
"title": "스태시 적용",
"noStashes": "스태시가 없습니다",
"selectFile": "파일을 선택하여 차이 보기",
"viewDiff": "차이 보기",
"original": "원본",
"modified": "수정됨",
"apply": "적용",
"drop": "삭제",
"applySuccess": "스태시가 적용되었습니다",
"dropSuccess": "스태시가 삭제되었습니다",
"confirmApply": "스태시 {ref}을(를) 작업 디렉토리에 적용하시겠습니까?",
"cancel": "취소"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "Enviar...",
"newBranch": "Nova branch...",
"newWorktree": "Novo worktree...",
"stashChanges": "Fazer stash das alterações",
"stashChanges": "Guardar alterações...",
"stashPop": "Aplicar stash...",
"manageRemotes": "Gerenciar remotos...",
"localBranches": "Branches locais ({count, plural, one {#} other {#}})",
@@ -859,6 +859,31 @@
"completeMerge": "Concluir merge",
"abortSuccess": "Merge abortado com sucesso",
"completeSuccess": "Merge concluído com sucesso"
},
"stashDialog": {
"title": "Guardar alterações no stash",
"description": "Guardar as alterações atuais no stash",
"messageLabel": "Mensagem",
"messagePlaceholder": "Mensagem do stash (opcional)",
"keepIndex": "Manter índice (alterações preparadas permanecem preparadas)",
"cancel": "Cancelar",
"stash": "Guardar",
"success": "Alterações guardadas no stash",
"error": "Erro ao guardar no stash"
},
"unstashDialog": {
"title": "Aplicar stash",
"noStashes": "Nenhum stash encontrado",
"selectFile": "Selecione um ficheiro para ver diferenças",
"viewDiff": "Ver diferenças",
"original": "Original",
"modified": "Modificado",
"apply": "Aplicar",
"drop": "Eliminar",
"applySuccess": "Stash aplicado",
"dropSuccess": "Stash eliminado",
"confirmApply": "Aplicar stash {ref} ao diretório de trabalho?",
"cancel": "Cancelar"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "推送...",
"newBranch": "新建分支...",
"newWorktree": "新建工作树...",
"stashChanges": "贮藏更改",
"stashChanges": "贮藏更改...",
"stashPop": "取消贮藏...",
"manageRemotes": "管理远程...",
"localBranches": "本地分支 ({count})",
@@ -859,6 +859,31 @@
"completeMerge": "完成合并",
"abortSuccess": "合并已中止",
"completeSuccess": "合并完成"
},
"stashDialog": {
"title": "贮藏更改",
"description": "将当前更改保存到贮藏区",
"messageLabel": "消息",
"messagePlaceholder": "贮藏消息(可选)",
"keepIndex": "保留暂存区(已暂存的更改保持不变)",
"cancel": "取消",
"stash": "贮藏",
"success": "更改已贮藏",
"error": "贮藏更改失败"
},
"unstashDialog": {
"title": "取消贮藏",
"noStashes": "没有贮藏记录",
"selectFile": "选择文件查看差异",
"viewDiff": "查看差异",
"original": "原始",
"modified": "修改后",
"apply": "应用",
"drop": "删除",
"applySuccess": "贮藏已应用",
"dropSuccess": "贮藏已删除",
"confirmApply": "将贮藏 {ref} 应用到工作目录?",
"cancel": "取消"
}
},
"commitDialog": {

View File

@@ -827,7 +827,7 @@
"pushCode": "推送...",
"newBranch": "新增分支...",
"newWorktree": "新增工作樹...",
"stashChanges": "暫存變更",
"stashChanges": "貯藏更改...",
"stashPop": "取消暫存...",
"manageRemotes": "管理遠端...",
"localBranches": "本地分支 ({count})",
@@ -859,6 +859,31 @@
"completeMerge": "完成合併",
"abortSuccess": "合併已中止",
"completeSuccess": "合併完成"
},
"stashDialog": {
"title": "貯藏更改",
"description": "將當前更改保存到貯藏區",
"messageLabel": "訊息",
"messagePlaceholder": "貯藏訊息(可選)",
"keepIndex": "保留暫存區(已暫存的更改保持不變)",
"cancel": "取消",
"stash": "貯藏",
"success": "更改已貯藏",
"error": "貯藏更改失敗"
},
"unstashDialog": {
"title": "取消貯藏",
"noStashes": "沒有貯藏記錄",
"selectFile": "選擇檔案查看差異",
"viewDiff": "查看差異",
"original": "原始",
"modified": "修改後",
"apply": "套用",
"drop": "刪除",
"applySuccess": "貯藏已套用",
"dropSuccess": "貯藏已刪除",
"confirmApply": "將貯藏 {ref} 套用到工作目錄?",
"cancel": "取消"
}
},
"commitDialog": {

View File

@@ -28,6 +28,7 @@ import type {
GitConflictFileVersions,
GitCommitResult,
GitRemote,
GitStashEntry,
PreflightResult,
FolderCommand,
TerminalInfo,
@@ -582,12 +583,56 @@ export async function openMergeWindow(
})
}
export async function gitStash(path: string): Promise<string> {
return invoke("git_stash", { path })
export async function openStashWindow(folderId: number): Promise<void> {
return invoke("open_stash_window", { folderId })
}
export async function gitStashPop(path: string): Promise<string> {
return invoke("git_stash_pop", { path })
export async function gitStashPush(
path: string,
message?: string,
keepIndex?: boolean
): Promise<string> {
return invoke("git_stash_push", {
path,
message: message ?? null,
keepIndex: keepIndex ?? false,
})
}
export async function gitStashPop(
path: string,
stashRef?: string
): Promise<string> {
return invoke("git_stash_pop", { path, stashRef: stashRef ?? null })
}
export async function gitStashList(path: string): Promise<GitStashEntry[]> {
return invoke("git_stash_list", { path })
}
export async function gitStashApply(
path: string,
stashRef: string
): Promise<string> {
return invoke("git_stash_apply", { path, stashRef })
}
export async function gitStashDrop(
path: string,
stashRef: string
): Promise<string> {
return invoke("git_stash_drop", { path, stashRef })
}
export async function gitStashClear(path: string): Promise<string> {
return invoke("git_stash_clear", { path })
}
export async function gitStashShow(
path: string,
stashRef: string
): Promise<GitStatusEntry[]> {
return invoke("git_stash_show", { path, stashRef })
}
export async function gitListRemotes(path: string): Promise<GitRemote[]> {

View File

@@ -709,6 +709,14 @@ export interface GitRemote {
url: string
}
export interface GitStashEntry {
index: number
message: string
branch: string
date: string
ref_name: string
}
export type FileTreeNode =
| { kind: "file"; name: string; path: string }
| { kind: "dir"; name: string; path: string; children: FileTreeNode[] }