feat(git stash): add stash listing and push/apply/pop/drop UI

This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
This commit is contained in:
Christoph Brandau
2026-07-04 00:16:27 +02:00
parent 201bb90bf7
commit 1d67312ee4
7 changed files with 497 additions and 5 deletions
+67
View File
@@ -21,6 +21,7 @@
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
@@ -42,6 +43,7 @@
fetchRemote,
getStatus,
listBranches,
listStashes,
listCommits,
listFileHistory,
listRepositoryFiles,
@@ -66,6 +68,10 @@
searchCodeIntroductions,
setSyncBadge,
stageFiles,
stashApply,
stashDrop,
stashPop,
stashPush,
unstageFiles,
} from "./lib/git";
@@ -83,6 +89,7 @@
GitFileStatus,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
LocalModelOption,
PatchApplyAction,
@@ -135,6 +142,7 @@
let repoSearch = "";
let status: GitStatus | null = null;
let branches: GitBranchInfo[] = [];
let stashes: GitStash[] = [];
let commits: GitCommit[] = [];
let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = "";
@@ -298,6 +306,7 @@
const bundle = await openRepositoryBundle(activeRepoPath, 100);
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves
@@ -744,6 +753,7 @@
void setSyncBadge(0, 0, 0).catch(() => {});
}
branches = [];
stashes = [];
commits = [];
lastFileHistoryHeadHash = "";
repoFiles = [];
@@ -835,6 +845,10 @@
branches = prefetched ?? (await listBranches(path));
}
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path));
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
@@ -929,6 +943,7 @@
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
@@ -1006,6 +1021,7 @@
await runOperation("Refreshing", async () => {
applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshStashes(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
@@ -1301,6 +1317,47 @@
await startRemoteAction("push");
}
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function applyStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Applying ${stash.selector}`, async () => {
applyStatus(await stashApply(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function popStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Popping ${stash.selector}`, async () => {
applyStatus(await stashPop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function dropStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`);
if (!confirmed) return;
await runOperation(`Dropping ${stash.selector}`, async () => {
applyStatus(await stashDrop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
});
}
// ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) {
@@ -2020,6 +2077,16 @@
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
/>
<StashPanel
{stashes}
changedCount={changedFiles.length}
{hasRepository}
{isBusy}
onPush={saveStash}
onApply={applyStashEntry}
onPop={popStashEntry}
onDrop={dropStashEntry}
/>
<ExplorerPanel
{repoFiles}
{expandedExplorerPaths}
+125 -3
View File
@@ -1104,7 +1104,7 @@
.left-sidebar {
display: grid;
grid-template-rows: minmax(200px, 0.9fr) minmax(240px, 1.1fr);
grid-template-rows: minmax(170px, 0.75fr) minmax(150px, 0.55fr) minmax(220px, 1fr);
min-width: 0;
min-height: 0;
gap: 8px;
@@ -1269,6 +1269,128 @@
background: var(--color-surface-dim);
}
/* --- Stash panel --- */
.stash-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
}
.stash-create {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 6px;
padding: 7px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.stash-input {
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid var(--color-border-subtle);
border-radius: 7px;
color: var(--color-ink);
background: rgba(255,255,255,0.035);
font-size: 12px;
}
.stash-input:focus {
border-color: rgba(65,209,255,0.42);
outline: none;
box-shadow: 0 0 0 2px rgba(65,209,255,0.1);
}
.stash-check {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-ink-dim);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.stash-check input {
width: 13px;
height: 13px;
accent-color: var(--color-accent);
}
.stash-save-button {
border-color: rgba(65,209,255,0.18);
color: var(--color-ink);
background: rgba(65,209,255,0.075);
}
.stash-list {
display: grid;
align-content: start;
gap: 6px;
min-height: 0;
padding: 7px;
overflow: auto;
}
.stash-empty {
min-height: 48px;
}
.stash-row {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.stash-row-main {
display: grid;
gap: 2px;
min-width: 0;
}
.stash-row-main strong,
.stash-row-main span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stash-row-main strong {
color: var(--color-ink);
font-size: 12px;
font-weight: 750;
}
.stash-row-main span {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 10.5px;
}
.stash-actions {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.btn-sm.danger {
border-color: rgba(232,96,96,0.2);
color: #ef9b9b;
background: rgba(232,96,96,0.08);
}
.btn-sm.danger:hover:not(:disabled) {
border-color: rgba(232,96,96,0.38);
color: #ffd2d2;
background: rgba(232,96,96,0.14);
}
/* --- Branch list --- */
.branch-head-actions {
@@ -3564,7 +3686,7 @@
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); }
.history-resize-handle { display: none; }
.shell-body { gap: 6px; }
.left-sidebar { gap: 6px; grid-template-rows: minmax(180px, 0.8fr) minmax(200px, 1.2fr); }
.left-sidebar { gap: 6px; grid-template-rows: minmax(150px, 0.7fr) minmax(145px, 0.55fr) minmax(190px, 1fr); }
.section-head { min-height: 40px; padding: 6px 10px; }
.repo-summary { height: 40px; padding: 0 10px; }
.repo-branch { max-width: 160px; }
@@ -3581,7 +3703,7 @@
.shell-body { min-height: 100%; gap: 6px; }
.workspace { grid-template-columns: 1fr; gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
.left-sidebar { grid-template-rows: minmax(180px, 0.9fr) minmax(150px, 0.55fr) minmax(220px, 1fr); min-height: 560px; }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
.repo-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { Archive, Download, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
interface Props {
stashes: GitStash[];
changedCount: number;
hasRepository: boolean;
isBusy: boolean;
onPush: (message: string, includeUntracked: boolean) => void;
onApply: (stash: GitStash) => void;
onPop: (stash: GitStash) => void;
onDrop: (stash: GitStash) => void;
}
let {
stashes = [],
changedCount = 0,
hasRepository = false,
isBusy = false,
onPush = () => {},
onApply = () => {},
onPop = () => {},
onDrop = () => {},
}: Props = $props();
let message = $state("");
let includeUntracked = $state(true);
function submitPush() {
onPush(message, includeUntracked);
message = "";
}
function stashTitle(stash: GitStash): string {
return stash.message || stash.selector;
}
</script>
<section class="panel stash-panel overflow-hidden" aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<span class="pill pill-count">{stashes.length}</span>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
<div class="stash-create">
<input
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
}}
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
>
<Archive size={14} aria-hidden="true" />
Stash
</button>
</div>
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
<article class="stash-row">
<div class="stash-row-main">
<strong title={stashTitle(stash)}>{stashTitle(stash)}</strong>
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{/if}
{#if stash.date}
- {stash.date}
{/if}
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<Download size={13} aria-hidden="true" />
Apply
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<Upload size={13} aria-hidden="true" />
Pop
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<Trash2 size={13} aria-hidden="true" />
Drop
</button>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+29
View File
@@ -10,6 +10,7 @@ import type {
GitCommitComparison,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
LocalModelOption,
PatchApplyAction,
@@ -47,6 +48,10 @@ export function listBranches(path: string): Promise<GitBranch[]> {
return invoke<GitBranch[]>("list_branches", { path });
}
export function listStashes(path: string): Promise<GitStash[]> {
return invoke<GitStash[]>("list_stashes", { path });
}
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("checkout_branch", { path, branch });
}
@@ -104,6 +109,30 @@ export function commit(path: string, message: string): Promise<GitStatus> {
return invoke<GitStatus>("commit", { path, message });
}
export function stashPush(
path: string,
message?: string,
includeUntracked = true,
): Promise<GitStatus> {
return invoke<GitStatus>("stash_push", {
path,
message: message?.trim() ? message.trim() : null,
includeUntracked,
});
}
export function stashApply(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_apply", { path, selector });
}
export function stashPop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_pop", { path, selector });
}
export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
+10
View File
@@ -58,6 +58,15 @@ export interface GitBranch {
remote: boolean;
}
export interface GitStash {
selector: string;
index: number;
hash: string;
branch: string | null;
message: string;
date: string;
}
export interface GitCommit {
hash: string;
short_hash: string;
@@ -85,6 +94,7 @@ export interface GitRepositoryFile {
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];
}