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
This commit is contained in:
Christoph Brandau
2026-08-17 22:24:22 +02:00
parent e7fbaadf5e
commit b5d15f9190
8 changed files with 328 additions and 20 deletions
+112 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import {
Archive,
ArrowLeft,
ArrowRight,
FileDiff,
@@ -25,6 +26,7 @@
onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
@@ -52,6 +54,13 @@
type StatusTreeNode = StatusFolderNode | StatusFileNode;
interface StatusContextTarget {
lane: StatusLaneKind;
kind: "file" | "folder";
label: string;
files: GitFileStatus[];
}
let {
changedFiles = [],
stagedCount = 0,
@@ -66,6 +75,7 @@
onUnstage = () => {},
onDiscard = () => {},
onDiscardMany = () => {},
onStash = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
@@ -175,6 +185,9 @@
let selectionAnchorKey = $state("");
let statusView = $state<StatusViewMode>("list");
let collapsedStatusFolders = $state<Set<string>>(new Set());
let statusContextTarget = $state<StatusContextTarget | null>(null);
let statusContextMenuX = $state(0);
let statusContextMenuY = $state(0);
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
const next = new Set(collapsedStatusFolders);
@@ -184,6 +197,65 @@
collapsedStatusFolders = next;
}
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
return files.filter((file) => {
const currentPath = file.path.replace(/\\/g, "/");
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
});
}
function openStatusContextMenu(
event: MouseEvent,
lane: StatusLaneKind,
kind: "file" | "folder",
label: string,
files: GitFileStatus[],
) {
event.preventDefault();
event.stopPropagation();
if (files.length === 0) return;
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files };
}
function statusContextName(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
return normalized.split("/").pop() || label;
}
function statusContextParent(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
}
function closeStatusContextMenu() {
statusContextTarget = null;
}
function runStatusContextStageAction() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
if (target.lane === "unstaged") onStage(target.files);
else onUnstage(target.files);
}
function runStatusContextStashAction() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStash(target.files, target.label);
}
function handleStatusWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeStatusContextMenu();
}
function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file));
}
@@ -277,6 +349,8 @@
});
</script>
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
<div class="section-head">
<div>
@@ -336,7 +410,7 @@
<div class="status-file-list">
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{#if row.kind === "folder"}
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
<strong>{row.name}</strong>
@@ -345,7 +419,7 @@
{:else}
{@const file = row.file}
{@const stageTargets = selectedStageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
@@ -393,7 +467,7 @@
<div class="status-file-list">
{#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{#if row.kind === "folder"}
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
<strong>{row.name}</strong>
@@ -402,7 +476,7 @@
{:else}
{@const file = row.file}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
@@ -442,6 +516,40 @@
{/if}
</section>
{#if statusContextTarget}
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
</span>
<span class="status-context-object-copy">
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
</span>
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
{statusContextTarget.files.length}
</span>
</div>
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true">
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
</span>
</button>
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
<span class="status-context-action-copy">
<strong>Stash {statusContextTarget.kind}</strong>
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
</span>
</button>
</div>
{/if}
<style>
.status-panel-overlay {
position: absolute;