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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user