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:
@@ -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<String>,
|
||||
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<GitBranch>,
|
||||
pub stashes: Vec<GitStash>,
|
||||
pub commits: Vec<GitCommit>,
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
}
|
||||
@@ -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<Vec<GitBranch>, String> {
|
||||
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> {
|
||||
let output = run_git(
|
||||
repo,
|
||||
@@ -316,6 +335,129 @@ fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
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]
|
||||
pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
|
||||
Reference in New Issue
Block a user