From d03be55c6bd82338b6336da73dabd9f9ad028155 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 15 Mar 2026 22:09:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BAgit=E8=B4=AE=E8=97=8F?= =?UTF-8?q?=EF=BC=88stash=EF=BC=89=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=AF=E8=A7=86=E5=8C=96=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/capabilities/default.json | 2 +- src-tauri/src/commands/folders.rs | 185 +++++++- src-tauri/src/commands/windows.rs | 39 ++ src-tauri/src/lib.rs | 8 +- src/app/stash/page.tsx | 111 +++++ src/components/layout/branch-dropdown.tsx | 29 +- src/components/layout/stash-dialog.tsx | 117 +++++ src/components/layout/unstash-dialog.tsx | 541 ++++++++++++++++++++++ src/i18n/messages/ar.json | 27 +- src/i18n/messages/de.json | 27 +- src/i18n/messages/en.json | 29 +- src/i18n/messages/es.json | 27 +- src/i18n/messages/fr.json | 27 +- src/i18n/messages/ja.json | 27 +- src/i18n/messages/ko.json | 27 +- src/i18n/messages/pt.json | 27 +- src/i18n/messages/zh-CN.json | 27 +- src/i18n/messages/zh-TW.json | 27 +- src/lib/tauri.ts | 53 ++- src/lib/types.ts | 8 + 20 files changed, 1335 insertions(+), 30 deletions(-) create mode 100644 src/app/stash/page.tsx create mode 100644 src/components/layout/stash-dialog.tsx create mode 100644 src/components/layout/unstash-dialog.tsx diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 432675c..bddbbb4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["welcome", "folder-*", "commit-*", "merge-*", "settings"], + "windows": ["welcome", "folder-*", "commit-*", "merge-*", "stash-*", "settings"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 26e2e31..43c37a1 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -78,6 +78,15 @@ pub struct GitCommitResult { pub committed_files: usize, } +#[derive(Debug, Serialize)] +pub struct GitStashEntry { + pub index: usize, + pub message: String, + pub branch: String, + pub date: String, + pub ref_name: String, +} + #[derive(Debug, Serialize)] pub struct GitRemote { pub name: String, @@ -867,24 +876,47 @@ pub async fn git_list_branches(path: String) -> Result, AppCommandEr } #[tauri::command] -pub async fn git_stash(path: String) -> Result { +pub async fn git_stash_push( + path: String, + message: Option, + keep_index: bool, +) -> Result { + let mut args = vec!["stash".to_string(), "push".to_string()]; + if let Some(msg) = message { + if !msg.is_empty() { + args.push("-m".to_string()); + args.push(msg); + } + } + if keep_index { + args.push("--keep-index".to_string()); + } let output = crate::process::tokio_command("git") - .args(["stash"]) + .args(&args) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; if !output.status.success() { - return Err(git_command_error("stash", &output.stderr)); + return Err(git_command_error("stash push", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } #[tauri::command] -pub async fn git_stash_pop(path: String) -> Result { +pub async fn git_stash_pop( + path: String, + stash_ref: Option, +) -> Result { + let mut args = vec!["stash", "pop"]; + let stash_ref_val; + if let Some(ref r) = stash_ref { + stash_ref_val = r.clone(); + args.push(&stash_ref_val); + } let output = crate::process::tokio_command("git") - .args(["stash", "pop"]) + .args(&args) .current_dir(&path) .output() .await @@ -896,6 +928,149 @@ pub async fn git_stash_pop(path: String) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[tauri::command] +pub async fn git_stash_list(path: String) -> Result, AppCommandError> { + let output = crate::process::tokio_command("git") + .args(["stash", "list", "--format=%gd||%gs||%ci"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error("stash list", &output.stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let entries = stdout + .lines() + .filter(|l| !l.is_empty()) + .enumerate() + .filter_map(|(i, line)| { + let parts: Vec<&str> = line.splitn(3, "||").collect(); + if parts.len() < 3 { + return None; + } + let ref_name = parts[0].to_string(); + let subject = parts[1]; + let date = parts[2].to_string(); + + // Parse branch and message from subject like "On branch: message" or "WIP on branch: hash" + let (branch, message) = if let Some(rest) = subject.strip_prefix("On ") { + if let Some(colon_pos) = rest.find(": ") { + let branch = rest[..colon_pos].to_string(); + let msg = rest[colon_pos + 2..].to_string(); + (branch, msg) + } else { + (String::new(), subject.to_string()) + } + } else if let Some(rest) = subject.strip_prefix("WIP on ") { + if let Some(colon_pos) = rest.find(": ") { + let branch = rest[..colon_pos].to_string(); + let msg = rest[colon_pos + 2..].to_string(); + (branch, msg) + } else { + (String::new(), subject.to_string()) + } + } else { + (String::new(), subject.to_string()) + }; + + Some(GitStashEntry { + index: i, + message, + branch, + date, + ref_name, + }) + }) + .collect(); + + Ok(entries) +} + +#[tauri::command] +pub async fn git_stash_apply( + path: String, + stash_ref: String, +) -> Result { + let output = crate::process::tokio_command("git") + .args(["stash", "apply", &stash_ref]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error("stash apply", &output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[tauri::command] +pub async fn git_stash_drop( + path: String, + stash_ref: String, +) -> Result { + let output = crate::process::tokio_command("git") + .args(["stash", "drop", &stash_ref]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error("stash drop", &output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[tauri::command] +pub async fn git_stash_clear(path: String) -> Result { + let output = crate::process::tokio_command("git") + .args(["stash", "clear"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error("stash clear", &output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[tauri::command] +pub async fn git_stash_show( + path: String, + stash_ref: String, +) -> Result, AppCommandError> { + let output = crate::process::tokio_command("git") + .args(["stash", "show", "--name-status", &stash_ref]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error("stash show", &output.stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let entries = stdout + .lines() + .filter(|l| !l.is_empty()) + .filter_map(|line| { + let mut parts = line.splitn(2, '\t'); + let status = parts.next()?.trim().to_string(); + let file = parts.next()?.trim().to_string(); + Some(GitStatusEntry { status, file }) + }) + .collect(); + + Ok(entries) +} + #[tauri::command] pub async fn git_status(path: String) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 95249d2..716e18b 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -558,3 +558,42 @@ pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> { ensure_windows_undecorated(&welcome_window); Ok(()) } + +#[tauri::command] +pub async fn open_stash_window( + app: AppHandle, + db: tauri::State<'_, AppDatabase>, + folder_id: i32, +) -> Result<(), AppCommandError> { + let label = format!("stash-{folder_id}"); + + if let Some(existing) = app.get_webview_window(&label) { + ensure_windows_undecorated(&existing); + let _ = existing.unminimize(); + existing + .set_focus() + .map_err(|e| AppCommandError::window("Failed to focus stash window", e.to_string()))?; + return Ok(()); + } + + let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| { + AppCommandError::not_found(format!("Folder {folder_id} not found")) + .with_detail(format!("folder_id={folder_id}")) + })?; + + let url = WebviewUrl::App(format!("stash?folderId={folder_id}").into()); + let builder = WebviewWindowBuilder::new(&app, &label, url) + .title(format!("Stash - {}", folder.name)) + .inner_size(1100.0, 700.0) + .min_inner_size(800.0, 500.0) + .center(); + let stash_window = apply_platform_window_style(builder) + .build() + .map_err(|e| AppCommandError::window("Failed to open stash window", e.to_string()))?; + ensure_windows_undecorated(&stash_window); + + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9953b34..646d046 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -209,8 +209,13 @@ pub fn run() { folders::git_worktree_add, folders::git_checkout, folders::git_list_branches, - folders::git_stash, + folders::git_stash_push, folders::git_stash_pop, + folders::git_stash_list, + folders::git_stash_apply, + folders::git_stash_drop, + folders::git_stash_clear, + folders::git_stash_show, folders::git_status, folders::git_is_tracked, folders::git_diff, @@ -254,6 +259,7 @@ pub fn run() { windows::list_open_folders, windows::focus_folder_window, windows::open_merge_window, + windows::open_stash_window, system_settings::get_system_proxy_settings, system_settings::update_system_proxy_settings, system_settings::get_system_language_settings, diff --git a/src/app/stash/page.tsx b/src/app/stash/page.tsx new file mode 100644 index 0000000..08f1aaf --- /dev/null +++ b/src/app/stash/page.tsx @@ -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({ + 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 ( +
+ + {t("title")} + {hasValidFolderId && folder ? ` · ${folder.name}` : ""} +
+ } + /> + +
+ {!hasValidFolderId ? ( +
+ Invalid folder ID +
+ ) : loading ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : folder ? ( + + ) : null} +
+ + + + ) +} + +export default function StashPage() { + return ( + + + + ) +} diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index f33a567..f5197b1 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -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(null) const taskSeq = useRef(0) const worktreeBranchSet = useMemo( @@ -761,18 +762,23 @@ export function BranchDropdown({ - runGitTask(t("tasks.stashChanges"), () => gitStash(folderPath)) - } + onSelect={() => { + setDropdownOpen(false) + setStashDialogOpen(true) + }} > {t("stashChanges")} - runGitTask(t("tasks.stashPop"), () => gitStashPop(folderPath)) - } + onSelect={() => { + if (!folder) return + openStashWindow(folder.id).catch((err) => { + const msg = toErrorMessage(err) + pushAlert("error", t("stashPop"), msg) + }) + }} > {t("stashPop")} @@ -1002,6 +1008,13 @@ export function BranchDropdown({ onClose={() => setConflictInfo(null)} onResolved={onBranchChange} /> + + setStashDialogOpen(false)} + onStashed={onBranchChange} + /> ) } diff --git a/src/components/layout/stash-dialog.tsx b/src/components/layout/stash-dialog.tsx new file mode 100644 index 0000000..7c07045 --- /dev/null +++ b/src/components/layout/stash-dialog.tsx @@ -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 ( + !v && handleClose()}> + + + {t("title")} + {t("description")} + + +
+
+ + setMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !loading) { + handleStash() + } + }} + disabled={loading} + autoFocus + /> +
+ +
+ + +
+
+ + + + + +
+
+ ) +} diff --git a/src/components/layout/unstash-dialog.tsx b/src/components/layout/unstash-dialog.tsx new file mode 100644 index 0000000..976f93d --- /dev/null +++ b/src/components/layout/unstash-dialog.tsx @@ -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 + 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() + 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([]) + const [expandedStash, setExpandedStash] = useState(null) + const [stashFiles, setStashFiles] = useState< + Record + >({}) + const [filesLoading, setFilesLoading] = useState(null) + + const [selectedFile, setSelectedFile] = useState(null) + const [selectedStashRef, setSelectedStashRef] = useState(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 ( + + {node.children.map((child) => renderNode(child, stashRef))} + + ) + } + + return ( + + + + + {node.name} + + + {node.entry.status.charAt(0)} + + + + + handleSelectFile(stashRef, node.path)} + > + {t("viewDiff")} + + + + ) + } + + return ( + + {/* Left panel: stash cards */} + + + {listLoading ? ( +
+ +
+ ) : stashes.length === 0 ? ( +
+ {t("noStashes")} +
+ ) : ( +
+ {stashes.map((stash) => ( + 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} + /> + ))} +
+ )} +
+
+ + + + {/* Right panel: diff viewer */} + + {diffLoading ? ( +
+ +
+ ) : selectedFile && selectedStashRef ? ( + + ) : ( +
+ {t("selectFile")} +
+ )} +
+
+ ) +} + +// --- 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 +} + +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()), + [files] + ) + + return ( + <> + + + +
+
+ + + + +
+ + +
+ {/* File tree */} + {isLoadingFiles ? ( +
+ +
+ ) : tree.length > 0 ? ( +
+ + {tree.map(renderNode)} + +
+ ) : null} +
+
+
+
+
+ + setConfirmApplyOpen(true)}> + {t("apply")} + + + {t("drop")} + + +
+ + + + + {t("apply")} + + {t("confirmApply", { ref: stash.ref_name })} + + + + {t("cancel")} + { + setConfirmApplyOpen(false) + onApply() + }} + > + {t("apply")} + + + + + + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index dccbe5b..3686ce2 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -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": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4b59df5..67d188a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index df464fd..0e0d350 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index d5bc74f..9f5f100 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -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": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 49638b5..2c03c3b 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -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": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e4f9541..0ae4340 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -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": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index c1274e0..dbed1cf 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -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": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index bf1c72f..3104955 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -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": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index d2b5951..5cbfb64 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -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": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 86f2b62..ce9871c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -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": { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 8ba6594..92c18eb 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -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 { - return invoke("git_stash", { path }) +export async function openStashWindow(folderId: number): Promise { + return invoke("open_stash_window", { folderId }) } -export async function gitStashPop(path: string): Promise { - return invoke("git_stash_pop", { path }) +export async function gitStashPush( + path: string, + message?: string, + keepIndex?: boolean +): Promise { + return invoke("git_stash_push", { + path, + message: message ?? null, + keepIndex: keepIndex ?? false, + }) +} + +export async function gitStashPop( + path: string, + stashRef?: string +): Promise { + return invoke("git_stash_pop", { path, stashRef: stashRef ?? null }) +} + +export async function gitStashList(path: string): Promise { + return invoke("git_stash_list", { path }) +} + +export async function gitStashApply( + path: string, + stashRef: string +): Promise { + return invoke("git_stash_apply", { path, stashRef }) +} + +export async function gitStashDrop( + path: string, + stashRef: string +): Promise { + return invoke("git_stash_drop", { path, stashRef }) +} + +export async function gitStashClear(path: string): Promise { + return invoke("git_stash_clear", { path }) +} + +export async function gitStashShow( + path: string, + stashRef: string +): Promise { + return invoke("git_stash_show", { path, stashRef }) } export async function gitListRemotes(path: string): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index fe4240c..d618c3e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[] }