Merge pull request 'feat(git stash): add stash listing and push/apply/pop/drop UI' (#13) from feature/stash into master
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -56,6 +56,16 @@ pub struct GitBranch {
|
|||||||
pub remote: bool,
|
pub remote: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitStash {
|
||||||
|
pub selector: String,
|
||||||
|
pub index: u32,
|
||||||
|
pub hash: String,
|
||||||
|
pub branch: Option<String>,
|
||||||
|
pub message: String,
|
||||||
|
pub date: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct GitCommit {
|
pub struct GitCommit {
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
@@ -234,6 +244,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
|||||||
pub struct RepositoryBundle {
|
pub struct RepositoryBundle {
|
||||||
pub status: GitStatus,
|
pub status: GitStatus,
|
||||||
pub branches: Vec<GitBranch>,
|
pub branches: Vec<GitBranch>,
|
||||||
|
pub stashes: Vec<GitStash>,
|
||||||
pub commits: Vec<GitCommit>,
|
pub commits: Vec<GitCommit>,
|
||||||
pub files: Vec<GitRepositoryFile>,
|
pub files: Vec<GitRepositoryFile>,
|
||||||
}
|
}
|
||||||
@@ -252,11 +263,13 @@ pub async fn open_repository_bundle(
|
|||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let status = status_for_repo(&repo)?;
|
let status = status_for_repo(&repo)?;
|
||||||
let branches = branches_for_repo(&repo)?;
|
let branches = branches_for_repo(&repo)?;
|
||||||
|
let stashes = stashes_for_repo(&repo)?;
|
||||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||||
let files = repository_files_with_status(&repo, &status)?;
|
let files = repository_files_with_status(&repo, &status)?;
|
||||||
Ok(RepositoryBundle {
|
Ok(RepositoryBundle {
|
||||||
status,
|
status,
|
||||||
branches,
|
branches,
|
||||||
|
stashes,
|
||||||
commits,
|
commits,
|
||||||
files,
|
files,
|
||||||
})
|
})
|
||||||
@@ -277,6 +290,12 @@ pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
|||||||
branches_for_repo(&repo)
|
branches_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
stashes_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||||
let output = run_git(
|
let output = run_git(
|
||||||
repo,
|
repo,
|
||||||
@@ -316,6 +335,129 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
|||||||
Ok(branches)
|
Ok(branches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stashes_for_repo(repo: &Path) -> Result<Vec<GitStash>, 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<u32> {
|
||||||
|
selector
|
||||||
|
.strip_prefix("stash@{")
|
||||||
|
.and_then(|value| value.strip_suffix('}'))
|
||||||
|
.and_then(|value| value.parse::<u32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_stash_subject(subject: &str) -> (Option<String>, 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<String>,
|
||||||
|
include_untracked: bool,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let trimmed_message = message.unwrap_or_default().trim().to_string();
|
||||||
|
let mut args: Vec<OsString> = 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<GitStatus, String> {
|
||||||
|
run_stash_update(path, "apply", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stash_pop(path: String, selector: String) -> Result<GitStatus, String> {
|
||||||
|
run_stash_update(path, "pop", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stash_drop(path: String, selector: String) -> Result<GitStatus, String> {
|
||||||
|
run_stash_update(path, "drop", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_stash_update(path: String, action: &str, selector: String) -> Result<GitStatus, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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]
|
#[tauri::command]
|
||||||
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ use git::{
|
|||||||
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch,
|
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,
|
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,
|
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,
|
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
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() {
|
fn main() {
|
||||||
@@ -28,12 +29,17 @@ fn main() {
|
|||||||
open_repository_file,
|
open_repository_file,
|
||||||
get_status,
|
get_status,
|
||||||
list_branches,
|
list_branches,
|
||||||
|
list_stashes,
|
||||||
checkout_branch,
|
checkout_branch,
|
||||||
create_branch,
|
create_branch,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
|
stash_push,
|
||||||
|
stash_apply,
|
||||||
|
stash_pop,
|
||||||
|
stash_drop,
|
||||||
restore_files,
|
restore_files,
|
||||||
get_file_patch,
|
get_file_patch,
|
||||||
apply_file_patch,
|
apply_file_patch,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||||
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
||||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||||
|
import StashPanel from "./lib/components/StashPanel.svelte";
|
||||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
fetchRemote,
|
fetchRemote,
|
||||||
getStatus,
|
getStatus,
|
||||||
listBranches,
|
listBranches,
|
||||||
|
listStashes,
|
||||||
listCommits,
|
listCommits,
|
||||||
listFileHistory,
|
listFileHistory,
|
||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
@@ -66,6 +68,10 @@
|
|||||||
searchCodeIntroductions,
|
searchCodeIntroductions,
|
||||||
setSyncBadge,
|
setSyncBadge,
|
||||||
stageFiles,
|
stageFiles,
|
||||||
|
stashApply,
|
||||||
|
stashDrop,
|
||||||
|
stashPop,
|
||||||
|
stashPush,
|
||||||
unstageFiles,
|
unstageFiles,
|
||||||
} from "./lib/git";
|
} from "./lib/git";
|
||||||
|
|
||||||
@@ -83,6 +89,7 @@
|
|||||||
GitFileStatus,
|
GitFileStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
@@ -135,6 +142,7 @@
|
|||||||
let repoSearch = "";
|
let repoSearch = "";
|
||||||
let status: GitStatus | null = null;
|
let status: GitStatus | null = null;
|
||||||
let branches: GitBranchInfo[] = [];
|
let branches: GitBranchInfo[] = [];
|
||||||
|
let stashes: GitStash[] = [];
|
||||||
let commits: GitCommit[] = [];
|
let commits: GitCommit[] = [];
|
||||||
let repoFiles: GitRepositoryFile[] = [];
|
let repoFiles: GitRepositoryFile[] = [];
|
||||||
let selectedExplorerPath = "";
|
let selectedExplorerPath = "";
|
||||||
@@ -298,6 +306,7 @@
|
|||||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||||
const previousHeadHash = lastFileHistoryHeadHash;
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
// File history reflects `git log`, which only changes when HEAD actually moves
|
// File history reflects `git log`, which only changes when HEAD actually moves
|
||||||
@@ -744,6 +753,7 @@
|
|||||||
void setSyncBadge(0, 0, 0).catch(() => {});
|
void setSyncBadge(0, 0, 0).catch(() => {});
|
||||||
}
|
}
|
||||||
branches = [];
|
branches = [];
|
||||||
|
stashes = [];
|
||||||
commits = [];
|
commits = [];
|
||||||
lastFileHistoryHeadHash = "";
|
lastFileHistoryHeadHash = "";
|
||||||
repoFiles = [];
|
repoFiles = [];
|
||||||
@@ -835,6 +845,10 @@
|
|||||||
branches = prefetched ?? (await listBranches(path));
|
branches = prefetched ?? (await listBranches(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||||||
|
stashes = prefetched ?? (await listStashes(path));
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
||||||
commits = prefetched ?? (await listCommits(path, 100));
|
commits = prefetched ?? (await listCommits(path, 100));
|
||||||
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
|
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
|
||||||
@@ -929,6 +943,7 @@
|
|||||||
if (globalSearchBusy) void cancelGlobalSearch();
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
activeView = "repository";
|
activeView = "repository";
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
lastRepoSwitchAt = Date.now();
|
lastRepoSwitchAt = Date.now();
|
||||||
@@ -1006,6 +1021,7 @@
|
|||||||
await runOperation("Refreshing", async () => {
|
await runOperation("Refreshing", async () => {
|
||||||
applyStatus(await getStatus(activeRepoPath));
|
applyStatus(await getStatus(activeRepoPath));
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshStashes(activeRepoPath);
|
||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath);
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
@@ -1301,6 +1317,47 @@
|
|||||||
await startRemoteAction("push");
|
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 ─────────────────────────────────────────────────
|
// ── File staging / restore ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function stageFile(file: GitFileStatus) {
|
async function stageFile(file: GitFileStatus) {
|
||||||
@@ -2020,6 +2077,16 @@
|
|||||||
onRenameBranch={renameLocalBranch}
|
onRenameBranch={renameLocalBranch}
|
||||||
onDeleteBranch={deleteLocalBranch}
|
onDeleteBranch={deleteLocalBranch}
|
||||||
/>
|
/>
|
||||||
|
<StashPanel
|
||||||
|
{stashes}
|
||||||
|
changedCount={changedFiles.length}
|
||||||
|
{hasRepository}
|
||||||
|
{isBusy}
|
||||||
|
onPush={saveStash}
|
||||||
|
onApply={applyStashEntry}
|
||||||
|
onPop={popStashEntry}
|
||||||
|
onDrop={dropStashEntry}
|
||||||
|
/>
|
||||||
<ExplorerPanel
|
<ExplorerPanel
|
||||||
{repoFiles}
|
{repoFiles}
|
||||||
{expandedExplorerPaths}
|
{expandedExplorerPaths}
|
||||||
|
|||||||
+125
-3
@@ -1104,7 +1104,7 @@
|
|||||||
|
|
||||||
.left-sidebar {
|
.left-sidebar {
|
||||||
display: grid;
|
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-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1269,6 +1269,128 @@
|
|||||||
background: var(--color-surface-dim);
|
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 list --- */
|
||||||
|
|
||||||
.branch-head-actions {
|
.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-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); }
|
||||||
.history-resize-handle { display: none; }
|
.history-resize-handle { display: none; }
|
||||||
.shell-body { gap: 6px; }
|
.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; }
|
.section-head { min-height: 40px; padding: 6px 10px; }
|
||||||
.repo-summary { height: 40px; padding: 0 10px; }
|
.repo-summary { height: 40px; padding: 0 10px; }
|
||||||
.repo-branch { max-width: 160px; }
|
.repo-branch { max-width: 160px; }
|
||||||
@@ -3581,7 +3703,7 @@
|
|||||||
.shell-body { min-height: 100%; gap: 6px; }
|
.shell-body { min-height: 100%; gap: 6px; }
|
||||||
.workspace { grid-template-columns: 1fr; 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; }
|
.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); }
|
.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-form { grid-template-columns: 1fr; }
|
||||||
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
@@ -47,6 +48,10 @@ export function listBranches(path: string): Promise<GitBranch[]> {
|
|||||||
return invoke<GitBranch[]>("list_branches", { path });
|
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> {
|
export function checkoutBranch(path: string, branch: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("checkout_branch", { path, branch });
|
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 });
|
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> {
|
export function commitAiStatus(): Promise<CommitAiStatus> {
|
||||||
return invoke<CommitAiStatus>("commit_ai_status");
|
return invoke<CommitAiStatus>("commit_ai_status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ export interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitStash {
|
||||||
|
selector: string;
|
||||||
|
index: number;
|
||||||
|
hash: string;
|
||||||
|
branch: string | null;
|
||||||
|
message: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitCommit {
|
export interface GitCommit {
|
||||||
hash: string;
|
hash: string;
|
||||||
short_hash: string;
|
short_hash: string;
|
||||||
@@ -85,6 +94,7 @@ export interface GitRepositoryFile {
|
|||||||
export interface RepositoryBundle {
|
export interface RepositoryBundle {
|
||||||
status: GitStatus;
|
status: GitStatus;
|
||||||
branches: GitBranch[];
|
branches: GitBranch[];
|
||||||
|
stashes: GitStash[];
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
files: GitRepositoryFile[];
|
files: GitRepositoryFile[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user