diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index b14c74a..b04df0c 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -56,6 +56,16 @@ pub struct GitBranch { pub remote: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitStash { + pub selector: String, + pub index: u32, + pub hash: String, + pub branch: Option, + pub message: String, + pub date: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitCommit { pub hash: String, @@ -234,6 +244,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> { pub struct RepositoryBundle { pub status: GitStatus, pub branches: Vec, + pub stashes: Vec, pub commits: Vec, pub files: Vec, } @@ -252,11 +263,13 @@ pub async fn open_repository_bundle( let repo = resolve_repo(&path)?; let status = status_for_repo(&repo)?; let branches = branches_for_repo(&repo)?; + let stashes = stashes_for_repo(&repo)?; let commits = commits_for_repo(&repo, commit_limit)?; let files = repository_files_with_status(&repo, &status)?; Ok(RepositoryBundle { status, branches, + stashes, commits, files, }) @@ -277,6 +290,12 @@ pub fn list_branches(path: String) -> Result, String> { branches_for_repo(&repo) } +#[tauri::command] +pub fn list_stashes(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + stashes_for_repo(&repo) +} + fn branches_for_repo(repo: &Path) -> Result, String> { let output = run_git( repo, @@ -316,6 +335,129 @@ fn branches_for_repo(repo: &Path) -> Result, String> { Ok(branches) } +fn stashes_for_repo(repo: &Path) -> Result, String> { + let output = run_git(repo, ["stash", "list", "--format=%gd%x00%H%x00%cr%x00%gs"])?; + let text = String::from_utf8_lossy(&output); + let mut stashes = Vec::new(); + + for line in text.lines() { + let mut parts = line.splitn(4, '\0'); + let selector = parts.next().unwrap_or_default().trim(); + let hash = parts.next().unwrap_or_default().trim(); + let date = parts.next().unwrap_or_default().trim(); + let subject = parts.next().unwrap_or_default().trim(); + if selector.is_empty() { + continue; + } + + let index = stash_index_from_selector(selector).unwrap_or(stashes.len() as u32); + let (branch, message) = parse_stash_subject(subject); + stashes.push(GitStash { + selector: selector.to_string(), + index, + hash: hash.to_string(), + branch, + message, + date: date.to_string(), + }); + } + + Ok(stashes) +} + +fn stash_index_from_selector(selector: &str) -> Option { + selector + .strip_prefix("stash@{") + .and_then(|value| value.strip_suffix('}')) + .and_then(|value| value.parse::().ok()) +} + +fn parse_stash_subject(subject: &str) -> (Option, String) { + for prefix in ["WIP on ", "On "] { + if let Some(value) = subject.strip_prefix(prefix) { + if let Some((branch, rest)) = value.split_once(": ") { + let message = if prefix == "WIP on " { + rest.split_once(' ').map(|(_, msg)| msg).unwrap_or(rest) + } else { + rest + }; + return (Some(branch.to_string()), message.to_string()); + } + } + } + + (None, subject.to_string()) +} + +#[tauri::command] +pub fn stash_push( + path: String, + message: Option, + include_untracked: bool, +) -> Result { + 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) +} + +#[tauri::command] +pub fn stash_apply(path: String, selector: String) -> Result { + run_stash_update(path, "apply", selector) +} + +#[tauri::command] +pub fn stash_pop(path: String, selector: String) -> Result { + run_stash_update(path, "pop", selector) +} + +#[tauri::command] +pub fn stash_drop(path: String, selector: String) -> Result { + run_stash_update(path, "drop", selector) +} + +fn run_stash_update(path: String, action: &str, selector: String) -> Result { + let repo = resolve_repo(&path)?; + let selector = validate_stash_selector(&selector)?; + let output = git_command() + .arg("-C") + .arg(&repo) + .args(["stash", action, selector.as_str()]) + .output() + .map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?; + + if output.status.success() { + return status_for_repo(&repo); + } + + let status = status_for_repo(&repo)?; + if has_unresolved_conflicts(&status) { + return Ok(status); + } + + Err(format!( + "Git command failed: {}", + command_output_details(&output) + )) +} + +fn validate_stash_selector(selector: &str) -> Result { + let selector = selector.trim(); + let Some(index) = stash_index_from_selector(selector) else { + return Err("Invalid stash selector.".to_string()); + }; + Ok(format!("stash@{{{index}}}")) +} + #[tauri::command] pub fn checkout_branch(path: String, branch: String) -> Result { let repo = resolve_repo(&path)?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index cbd57ed..7a59acd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,10 +10,11 @@ use git::{ commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch, diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status, list_branches, list_commits, list_file_history, - list_repository_files, merge_branch, open_repo_in_explorer, open_repository, + list_repository_files, list_stashes, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, - restore_to_commit, search_code_introductions, stage_files, unstage_files, + restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop, + stash_push, unstage_files, }; fn main() { @@ -28,12 +29,17 @@ fn main() { open_repository_file, get_status, list_branches, + list_stashes, checkout_branch, create_branch, rename_branch, delete_branch, stage_files, unstage_files, + stash_push, + stash_apply, + stash_pop, + stash_drop, restore_files, get_file_patch, apply_file_patch, diff --git a/src/App.svelte b/src/App.svelte index ef27d3f..cec715e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} /> + + 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; + } + + +
+
+
+ Stash +

Shelved changes

+
+ {stashes.length} +
+ + {#if !hasRepository} +
No repository loaded.
+ {:else} +
+ { + if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush(); + }} + /> + + +
+ + {#if stashes.length === 0} +
No stashes saved.
+ {:else} +
+ {#each stashes as stash (stash.selector)} +
+
+ {stashTitle(stash)} + + {stash.selector} + {#if stash.branch} + on {stash.branch} + {/if} + {#if stash.date} + - {stash.date} + {/if} + +
+
+ + + +
+
+ {/each} +
+ {/if} + {/if} +
diff --git a/src/lib/git.ts b/src/lib/git.ts index 19d8db1..fb85de7 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -10,6 +10,7 @@ import type { GitCommitComparison, GitRepositoryFile, GitSearchHit, + GitStash, GitStatus, LocalModelOption, PatchApplyAction, @@ -47,6 +48,10 @@ export function listBranches(path: string): Promise { return invoke("list_branches", { path }); } +export function listStashes(path: string): Promise { + return invoke("list_stashes", { path }); +} + export function checkoutBranch(path: string, branch: string): Promise { return invoke("checkout_branch", { path, branch }); } @@ -104,6 +109,30 @@ export function commit(path: string, message: string): Promise { return invoke("commit", { path, message }); } +export function stashPush( + path: string, + message?: string, + includeUntracked = true, +): Promise { + return invoke("stash_push", { + path, + message: message?.trim() ? message.trim() : null, + includeUntracked, + }); +} + +export function stashApply(path: string, selector: string): Promise { + return invoke("stash_apply", { path, selector }); +} + +export function stashPop(path: string, selector: string): Promise { + return invoke("stash_pop", { path, selector }); +} + +export function stashDrop(path: string, selector: string): Promise { + return invoke("stash_drop", { path, selector }); +} + export function commitAiStatus(): Promise { return invoke("commit_ai_status"); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 3bf4063..f885424 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[]; }