From b5d15f91902e40e0397ab141465f13c558960761 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 17 Aug 2026 22:24:22 +0200 Subject: [PATCH] feat(git): add stash push with optional paths and per-file scope The stash push feature now supports limiting the stash to selected files via an optional paths parameter. The UI passes file paths to stash_push and adds a per-file context menu for scoped stash operations. - Extend stash API to accept optional paths for scoped stashes - Implement per-file stash actions via a status panel context menu - Update tests and docs to reflect scoped stash behavior --- CHANGELOG.md | 3 + docs/api-contract.md | 1 + src-tauri/src/git.rs | 74 ++++++++++++--- src/App.svelte | 19 +++- src/app.css | 131 ++++++++++++++++++++++++++ src/lib/components/HelpOverlay.svelte | 2 + src/lib/components/StatusPanel.svelte | 116 ++++++++++++++++++++++- src/lib/git.ts | 2 + 8 files changed, 328 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 281ad6b..b607c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`. - The Changes panel now offers a List/Tree switch. Tree view groups staged and unstaged files into independently collapsible folders while retaining all existing file actions. +- Files and folders in the Changes panel now have context-menu actions for + staging or unstaging their scope and for creating a stash containing only + the selected file or folder. - Gitty can open a repository directly at startup through the `--repo PATH` or `--repo=PATH` command-line argument. diff --git a/docs/api-contract.md b/docs/api-contract.md index 30a0f81..b9dfa71 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -138,6 +138,7 @@ The command list below includes the repository-management and synchronization AP - `stage_files(path: string, files: string[]): Promise` - `unstage_files(path: string, files: string[]): Promise` - `restore_files(path: string, files: string[], staged: boolean): Promise` +- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise`; when `paths` is provided, only matching files are stashed. - `commit(path: string, message: string): Promise` - `fetch(path: string, prune?: boolean, remote?: string): Promise` - `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise` diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index e2fd45d..029f232 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1243,25 +1243,45 @@ pub async fn stash_push( path: String, message: Option, include_untracked: bool, + paths: Option>, ) -> Result { run_git_task("Could not stash changes", move || { let repo = resolve_repo(&path)?; - let trimmed_message = message.unwrap_or_default().trim().to_string(); - let mut args: Vec = vec![OsString::from("stash"), OsString::from("push")]; - if include_untracked { - args.push(OsString::from("--include-untracked")); - } - if !trimmed_message.is_empty() { - args.push(OsString::from("-m")); - args.push(OsString::from(trimmed_message)); - } - - run_git(&repo, args)?; - status_for_repo(&repo) + stash_push_for_repo( + &repo, + message.as_deref(), + include_untracked, + paths.as_deref().unwrap_or_default(), + ) }) .await } +fn stash_push_for_repo( + repo: &Path, + message: Option<&str>, + include_untracked: bool, + paths: &[String], +) -> Result { + validate_files(paths)?; + let trimmed_message = message.unwrap_or_default().trim(); + let mut args: Vec = vec![OsString::from("stash"), OsString::from("push")]; + if include_untracked { + args.push(OsString::from("--include-untracked")); + } + if !trimmed_message.is_empty() { + args.push(OsString::from("-m")); + args.push(OsString::from(trimmed_message)); + } + if !paths.is_empty() { + args.push(OsString::from("--")); + args.extend(paths.iter().map(OsString::from)); + } + + run_git(repo, args)?; + status_for_repo(repo) +} + #[tauri::command] pub async fn stash_apply(path: String, selector: String) -> Result { run_git_task("Could not apply stash", move || { @@ -7075,6 +7095,36 @@ mod tests { assert!(status.files.is_empty()); } + #[test] + fn stash_push_can_be_limited_to_selected_files() { + let repo = init_temp_repo("scoped_stash"); + fs::write(repo.path.join("selected.txt"), "initial selected\n") + .expect("selected file should be written"); + fs::write(repo.path.join("remaining.txt"), "initial remaining\n") + .expect("remaining file should be written"); + run_git_test(&repo.path, ["add", "selected.txt", "remaining.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "initial files"]); + + fs::write(repo.path.join("selected.txt"), "stashed change\n") + .expect("selected change should be written"); + fs::write(repo.path.join("remaining.txt"), "remaining change\n") + .expect("remaining change should be written"); + + let paths = vec!["selected.txt".to_string()]; + let status = stash_push_for_repo(&repo.path, Some("selected file"), false, &paths) + .expect("selected file should be stashed"); + + assert!(status.files.iter().all(|file| file.path != "selected.txt")); + assert!(status.files.iter().any(|file| file.path == "remaining.txt")); + assert_eq!( + git_output_test( + &repo.path, + ["stash", "show", "--name-only", "--format=", "stash@{0}"], + ), + "selected.txt" + ); + } + #[test] fn branches_report_configured_upstream_and_local_only_state() { let repo = init_temp_repo("branch_upstream_state"); diff --git a/src/App.svelte b/src/App.svelte index e3f6e00..b8640bf 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -3852,11 +3852,15 @@ } } - async function saveStash(message: string, includeUntracked: boolean) { - if (!activeRepoPath || changedFiles.length === 0) return; - const stashedFiles = changedFiles.length; + async function saveStash(message: string, includeUntracked: boolean, files?: GitFileStatus[]) { + const targets = files ?? changedFiles; + if (!activeRepoPath || targets.length === 0) return; + const paths = files + ? [...new Set(files.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))] + : undefined; + const stashedFiles = targets.length; await runOperation("Stashing changes", async () => { - applyStatus(await stashPush(activeRepoPath, message, includeUntracked)); + applyStatus(await stashPush(activeRepoPath, message, includeUntracked, paths)); await refreshRepositoryViews(activeRepoPath, { branches: false, stashes: true, @@ -3865,10 +3869,16 @@ trackEvent("stash_saved", { include_untracked: includeUntracked ? 1 : 0, changed_files: stashedFiles, + scoped: files ? 1 : 0, }); }); } + async function stashStatusFiles(files: GitFileStatus[], label: string) { + const suffix = files.length === 1 ? files[0].path : `${label} (${files.length} files)`; + await saveStash(`Gitty: ${suffix}`, true, files); + } + async function applyStashEntry(stash: GitStash) { if (!activeRepoPath) return; await runOperation(`Applying ${stash.selector}`, async () => { @@ -5367,6 +5377,7 @@ onUnstage={unstageFile} onDiscard={discardFiles} onDiscardMany={discardChanges} + onStash={stashStatusFiles} onPatch={openPreferredFileDiff} onStageAll={stageAllFiles} onUnstageAll={unstageAllFiles} diff --git a/src/app.css b/src/app.css index 0237ca3..5ca1a6d 100644 --- a/src/app.css +++ b/src/app.css @@ -2327,6 +2327,7 @@ .branch-context-menu, .history-context-menu, .explorer-context-menu, + .status-context-menu, .repo-tab-context-menu { z-index: 120; display: grid; @@ -2346,11 +2347,102 @@ } .history-context-menu { position: absolute; } .explorer-context-menu, + .status-context-menu, .repo-tab-context-menu { position: fixed; } + .status-context-menu { + width: min(280px, calc(100vw - 16px)); + padding: 6px; + border-color: color-mix(in srgb, var(--color-border) 78%, #5a8cf8); + background: + linear-gradient(180deg, rgba(90,140,248,0.045), transparent 70px), + var(--color-surface-solid); + } + + .status-context-label { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-width: 0; + margin: -1px -1px 5px; + padding: 8px 8px 10px; + border-bottom: 1px solid var(--color-border-subtle); + } + + .status-context-object-icon, + .status-context-action-icon { + display: inline-grid; + place-items: center; + flex: 0 0 auto; + color: #83adff; + background: rgba(90,140,248,0.12); + border: 1px solid rgba(90,140,248,0.2); + } + + .status-context-object-icon { + width: 32px; + height: 32px; + border-radius: 7px; + } + + .status-context-object-copy, + .status-context-action-copy { + display: grid; + min-width: 0; + } + + .status-context-kind { + margin-bottom: 2px; + color: var(--color-ink-faint); + font-size: 8.5px; + font-weight: 800; + letter-spacing: .1em; + line-height: 1; + text-transform: uppercase; + } + + .status-context-object-copy > strong { + overflow: hidden; + color: var(--color-ink); + font-size: 12.5px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; + } + + .status-context-path { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + margin-top: 2px; + overflow: hidden; + color: var(--color-ink-faint); + font: 9.5px/1.2 var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; + } + + .status-context-path svg { flex: 0 0 auto; } + + .status-context-count { + display: inline-grid; + place-items: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + border: 1px solid rgba(90,140,248,0.18); + border-radius: 5px; + color: #83adff; + background: rgba(90,140,248,0.1); + font: 700 9.5px/1 var(--font-mono); + } + .branch-context-menu button, .history-context-menu button, .explorer-context-menu button, + .status-context-menu button, .repo-tab-context-menu button { display: flex; align-items: center; @@ -2368,15 +2460,52 @@ text-align: left; } + .status-context-menu button { + min-height: 42px; + gap: 9px; + padding: 6px 7px; + } + + .status-context-action-icon { + width: 26px; + height: 26px; + border-color: transparent; + border-radius: 6px; + background: transparent; + color: var(--color-ink-dim); + } + + .status-context-action-copy { gap: 1px; } + .status-context-action-copy strong { + color: var(--color-ink-muted); + font-size: 11.5px; + line-height: 1.25; + } + .status-context-action-copy span { + color: var(--color-ink-faint); + font-size: 9.5px; + font-weight: 500; + line-height: 1.25; + } + .branch-context-menu button:hover:not(:disabled), .history-context-menu button:hover:not(:disabled), .explorer-context-menu button:hover:not(:disabled), + .status-context-menu button:hover:not(:disabled), .repo-tab-context-menu button:hover:not(:disabled) { border-color: var(--color-border-subtle); background: rgba(255,255,255,0.06); color: var(--color-ink); } + .status-context-menu button:hover:not(:disabled) .status-context-action-icon { + color: #9bbcff; + background: rgba(90,140,248,0.1); + } + .status-context-menu button:hover:not(:disabled) .status-context-action-copy strong { + color: var(--color-ink); + } + .branch-context-menu .menu-separator, .history-context-menu .menu-separator, .repo-tab-context-menu .menu-separator { @@ -2398,6 +2527,7 @@ .branch-context-menu button:disabled, .history-context-menu button:disabled, .explorer-context-menu button:disabled, + .status-context-menu button:disabled, .repo-tab-context-menu button:disabled { cursor: not-allowed; opacity: 0.48; @@ -7651,6 +7781,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 25px minmax(0, 1fr); } + .status-flow-divider::before { inset-block: auto; inset-inline: 0; diff --git a/src/lib/components/HelpOverlay.svelte b/src/lib/components/HelpOverlay.svelte index d956186..f48c44b 100644 --- a/src/lib/components/HelpOverlay.svelte +++ b/src/lib/components/HelpOverlay.svelte @@ -1530,6 +1530,7 @@ "Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.", "Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.", "Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.", + "Über das Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern.", "Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.", "Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.", ], @@ -1645,6 +1646,7 @@ "After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.", "Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.", "The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.", + "A file or folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash.", "Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.", "Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.", ], diff --git a/src/lib/components/StatusPanel.svelte b/src/lib/components/StatusPanel.svelte index 61d4796..e40abc9 100644 --- a/src/lib/components/StatusPanel.svelte +++ b/src/lib/components/StatusPanel.svelte @@ -1,5 +1,6 @@ + +
@@ -336,7 +410,7 @@
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)} {#if row.kind === "folder"} -
+{#if statusContextTarget} + +{/if} +