diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 5eaa490ab..964b130f6 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -806,17 +806,25 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult IdeResult { +pub async fn vcs_unshelve( + host: &Host, + window: &str, + id: String, + remove: bool, + path: Option, +) -> IdeResult { let project = host.project(window)?; blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove)) + project.with_repo_recorded(None, Some("Unshelve"), |r| { + ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref()) + }) }) .await } -pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> { +pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option) -> IdeResult<()> { let project = host.project(window)?; - ide_vcs::shelf::delete(&shelf_dir(&project.root), &id) + ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref()) } pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> { @@ -824,9 +832,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name) } -pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult { +pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option) -> IdeResult { let project = host.project(window)?; - ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id) + ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref()) +} + +pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult> { + let project = host.project(window)?; + blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await +} + +pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> { + let project = host.project(window)?; + blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await } pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult { @@ -1004,10 +1022,16 @@ pub(crate) fn register(d: &mut Dispatcher) { ); d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await }); d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) }); - d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await }); - d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_unshelve", |c| async move { + vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await + }); + d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) }); - d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) }); + d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await }); + d.add("vcs_shelf_import", |c| async move { + vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await + }); + d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) }); d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) }); d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) }); d.add( diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 1c3509bc3..6059b2e9c 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -897,7 +897,7 @@ impl Repo { /// Patch of the local changes of `paths` against HEAD (untracked files included). pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult { let rel = self.rel_paths(paths)?; - Ok(patch::working_patch(self, &rel, reverse)?.0) + Ok(patch::working_patch(self, &rel, reverse, true)?.0) } /// Patch of the changes made by `commits` (oldest first). pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult { diff --git a/crates/ide-vcs/src/patch.rs b/crates/ide-vcs/src/patch.rs index 15610cf8a..28f3869e8 100644 --- a/crates/ide-vcs/src/patch.rs +++ b/crates/ide-vcs/src/patch.rs @@ -36,7 +36,12 @@ impl Drop for TempIndex { /// Binary-safe patch of the working-tree changes (staged or not, untracked files /// included) of `rel_paths` against HEAD, plus the files it touches (renames split /// into delete + add). The real index is left untouched. -pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> IdeResult<(String, Vec)> { +pub(crate) fn working_patch( + r: &Repo, + rel_paths: &[String], + reverse: bool, + detect_renames: bool, +) -> IdeResult<(String, Vec)> { if rel_paths.is_empty() { return Err(IdeError::invalid("no files selected")); } @@ -48,8 +53,16 @@ pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> Id run_env(r.root(), &["read-tree", "--empty"], None, &env)?; } cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], rel_paths, None, &env)?; - let mut diff = - vec!["diff", "--cached", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff", "--no-textconv"]; + let mut diff = vec![ + "diff", + "--cached", + "--binary", + "--full-index", + if detect_renames { "-M" } else { "--no-renames" }, + "--no-color", + "--no-ext-diff", + "--no-textconv", + ]; if reverse { diff.push("-R"); } @@ -163,13 +176,49 @@ fn unmerged(r: &Repo) -> IdeResult> { Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect()) } +fn git_path(p: &str) -> String { + let Some(p) = p.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else { return p.to_string() }; + let mut bytes = Vec::new(); + let mut chars = p.bytes().peekable(); + while let Some(c) = chars.next() { + if c != b'\\' { + bytes.push(c); + continue; + } + let Some(c) = chars.next() else { break }; + bytes.push(match c { + b'n' => b'\n', + b't' => b'\t', + b'r' => b'\r', + b'a' => 7, + b'b' => 8, + b'v' => 11, + b'f' => 12, + b'0'..=b'7' => { + let mut n = c - b'0'; + for _ in 0..2 { + if let Some(d @ b'0'..=b'7') = chars.peek().copied() { + chars.next(); + n = n.wrapping_mul(8).wrapping_add(d - b'0'); + } else { + break; + } + } + n + } + c => c, + }); + } + String::from_utf8_lossy(&bytes).into_owned() +} + fn strip_prefix(p: &str) -> Option { - let p = p.split('\t').next().unwrap_or(p).trim_end(); + let p = if p.starts_with('"') { p } else { p.split('\t').next().unwrap_or(p) }; if p == "/dev/null" { return None; } - let p = p.strip_prefix("\"").and_then(|q| q.strip_suffix('"')).unwrap_or(p); - Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(p).to_string()) + let p = git_path(p); + Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(&p).to_string()) } /// Files changed by a unified diff (git extended headers or plain `---`/`+++`). @@ -210,7 +259,12 @@ pub(crate) fn parse_patch(patch: &str) -> Vec { } if let Some(rest) = line.strip_prefix("diff --git ") { out.extend(cur.take()); - let path = rest.rsplit_once(" b/").map(|(_, b)| b.to_string()).unwrap_or_else(|| rest.to_string()); + let path = rest + .rsplit_once(" \"b/") + .map(|(_, b)| git_path(&format!("\"{b}"))) + .or_else(|| rest.rsplit_once(" b/").map(|(_, b)| b.to_string())) + .unwrap_or_else(|| rest.to_string()); + let path = path.strip_prefix("b/").unwrap_or(&path).to_string(); cur = Some(new_file(path)); fresh_header = true; } else if let Some(rest) = line.strip_prefix("--- ") { @@ -249,10 +303,10 @@ pub(crate) fn parse_patch(patch: &str) -> Vec { } else if line.starts_with("deleted file mode") { c.kind = ChangeKind::Deleted; } else if let Some(p) = line.strip_prefix("rename from ") { - c.old_path = Some(p.to_string()); + c.old_path = Some(git_path(p)); c.kind = ChangeKind::Renamed; } else if let Some(p) = line.strip_prefix("rename to ") { - c.path = p.to_string(); + c.path = git_path(p); } else if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") { c.binary = true; } diff --git a/crates/ide-vcs/src/shelf.rs b/crates/ide-vcs/src/shelf.rs index 867a65d06..502867130 100644 --- a/crates/ide-vcs/src/shelf.rs +++ b/crates/ide-vcs/src/shelf.rs @@ -2,7 +2,7 @@ //! `.idea/shelf`. Each shelved change list is `//shelved.patch` plus //! `//shelf.json` with its name, date and files. -use crate::cli::run_paths; +use crate::cli::{run_env, run_paths}; use crate::{ApplyOutcome, ChangeKind, Repo, ShelvedChangeList}; use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; @@ -81,7 +81,10 @@ pub fn list(shelf: &Path) -> IdeResult> { name: info.name, time_ms: info.time_ms, branch: info.branch, - files: info.files, + files: crate::patch::parse_patch(&std::fs::read_to_string(dir.join(PATCH))?) + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(), }); } out.sort_by(|a, b| b.time_ms.total_cmp(&a.time_ms).then_with(|| a.name.cmp(&b.name))); @@ -117,7 +120,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) - if name.is_empty() { return Err(IdeError::invalid("name must not be empty")); } - let (patch, files) = crate::patch::working_patch(r, rel_paths, false)?; + let (patch, files) = crate::patch::working_patch(r, rel_paths, false, false)?; if files.is_empty() || patch.trim().is_empty() { return Err(IdeError::invalid("no changes to shelve")); } @@ -173,14 +176,174 @@ fn revert_files(r: &Repo, files: &[crate::ChangedFile]) -> IdeResult<()> { /// Applies a shelved change list; with `remove`, deletes it from the shelf once it /// applied without conflicts (conflicts are left for the merge UI and the entry is kept). pub(crate) fn unshelve(r: &Repo, shelf: &Path, id: &str, remove: bool) -> IdeResult { - let patch = patch_of(shelf, id)?; + unshelve_path(r, shelf, id, remove, None) +} + +pub fn unshelve_path(r: &Repo, shelf: &Path, id: &str, remove: bool, path: Option<&str>) -> IdeResult { + let patch = selected_patch(shelf, id, path)?; let outcome = crate::patch::apply(r, &patch)?; if remove && outcome.conflicts.is_empty() { - delete(shelf, id)?; + delete_path(shelf, id, path)?; } Ok(outcome) } +#[derive(Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ShelfDiffFile { + pub path: String, + pub patch: String, + pub before: Option, + pub after: Option, +} + +fn sections(patch: &str) -> Vec<(&str, crate::PatchFile)> { + let mut starts: Vec<_> = patch + .match_indices("diff --git ") + .filter(|(i, _)| *i == 0 || patch.as_bytes()[i - 1] == b'\n') + .map(|(i, _)| i) + .collect(); + if starts.is_empty() { + let (mut old, mut new) = (0u32, 0u32); + let mut offset = 0; + let mut lines = patch.split_inclusive('\n').peekable(); + while let Some(line) = lines.next() { + if old > 0 || new > 0 { + match line.as_bytes().first() { + Some(b'-') => old = old.saturating_sub(1), + Some(b'+') => new = new.saturating_sub(1), + Some(b' ') => { + old = old.saturating_sub(1); + new = new.saturating_sub(1); + } + _ => {} + } + } else if line.starts_with("--- ") && lines.peek().is_some_and(|l| l.starts_with("+++ ")) { + starts.push(offset); + } + if let Some(range) = line.strip_prefix("@@ ") { + let mut parts = range.split_whitespace(); + let count = |s: &str| s.split_once(',').map_or(1, |(_, n)| n.parse().unwrap_or(0)); + old = parts.next().map(count).unwrap_or(0); + new = parts.next().map(count).unwrap_or(0); + } + offset += line.len(); + } + } + starts + .iter() + .enumerate() + .filter_map(|(n, &start)| { + let section = &patch[start..starts.get(n + 1).copied().unwrap_or(patch.len())]; + crate::patch::parse_patch(section).into_iter().next().map(|file| (section, file)) + }) + .collect() +} + +pub fn selected_patch(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult { + let patch = patch_of(shelf, id)?; + let Some(path) = path else { return Ok(patch) }; + let selected: String = sections(&patch).into_iter().filter(|(_, f)| f.path == path).map(|(p, _)| p).collect(); + if selected.is_empty() { + return Err(IdeError::not_found("shelved file not found")); + } + Ok(selected) +} + +pub fn delete_path(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult<()> { + let Some(path) = path else { return delete(shelf, id) }; + selected_patch(shelf, id, Some(path))?; + let patch = patch_of(shelf, id)?; + let remaining: String = sections(&patch).into_iter().filter(|(_, f)| f.path != path).map(|(p, _)| p).collect(); + if remaining.is_empty() { + return delete(shelf, id); + } + let dir = entry_dir(shelf, id)?; + let mut info = read_info(&dir).ok_or_else(|| IdeError::invalid("shelf metadata is missing"))?; + info.files = crate::patch::parse_patch(&remaining) + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(); + std::fs::write(dir.join(PATCH), remaining)?; + std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; + Ok(()) +} + +/// Reconstruct each side in a throwaway index; neither HEAD nor the working files are used. +pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult> { + let patch = patch_of(shelf, id)?; + let mut out = Vec::new(); + for (section, file) in sections(&patch) { + let mut item = ShelfDiffFile { path: file.path.clone(), patch: section.to_string(), before: None, after: None }; + if !file.binary { + let reconstruct = || -> IdeResult<(String, String)> { + let temp = tempfile::tempdir()?; + let index = temp.path().join("index"); + let env = [("GIT_INDEX_FILE", index.as_os_str())]; + run_env( + r.root(), + &["apply", &format!("--build-fake-ancestor={}", index.display()), "-"], + Some(section.as_bytes()), + &env, + )?; + let read = |path: &str| -> IdeResult { + let bytes = run_env(r.root(), &["show", &format!(":{path}")], None, &env)?.stdout; + if bytes.contains(&0) { + return Err(IdeError::invalid("binary file")); + } + String::from_utf8(bytes).map_err(|_| IdeError::invalid("non-UTF-8 file")) + }; + let before = if file.kind == ChangeKind::Added { + String::new() + } else { + read(file.old_path.as_deref().unwrap_or(&file.path))? + }; + run_env( + r.root(), + &["apply", "--cached", "--binary", "--whitespace=nowarn", "-"], + Some(section.as_bytes()), + &env, + )?; + let after = if file.kind == ChangeKind::Deleted { String::new() } else { read(&file.path)? }; + Ok((before, after)) + }; + if let Ok((before, after)) = reconstruct() { + item.before = Some(before); + item.after = Some(after); + } + } + out.push(item); + } + Ok(out) +} + +/// Import into the shelf without applying to the working tree. +pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> IdeResult<()> { + let files = crate::patch::parse_patch(patch); + if files.is_empty() || sections(patch).len() != files.len() { + return Err(IdeError::invalid("no file changes found in the patch")); + } + std::fs::create_dir_all(shelf)?; + let mut id = slug(name); + while shelf.join(&id).exists() { + id.push('_'); + } + let dir = shelf.join(id); + std::fs::create_dir(&dir)?; + let info = Info { + name: name.to_string(), + time_ms: now_ms(), + branch: None, + files: files + .into_iter() + .map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path }) + .collect(), + }; + std::fs::write(dir.join(PATCH), patch)?; + std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::slug; diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index 0ef3d0b78..39b035572 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -567,3 +567,95 @@ fn update_branch_fast_forwards_a_branch_not_checked_out() { // The checked-out branch and branches without upstream are refused. assert!(repo.update_branch("work", &cancel, &out).is_err()); } + +#[test] +fn shelf_diff_and_scoped_operations_preserve_siblings() { + let fx = Fixture::new(); + for path in ["a.txt", "b.txt", "quote\"\t.txt", "gone.txt"] { + fx.write(path, "base\n"); + } + fx.commit_all("base"); + for path in ["a.txt", "b.txt", "quote\"\t.txt"] { + fx.write(path, "shelved\n"); + } + std::fs::remove_file(fx.path("gone.txt")).unwrap(); + fx.write("new.txt", "new\n"); + std::fs::write(fx.path("binary.dat"), [0, 1, 255]).unwrap(); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + let paths = ["a.txt", "b.txt", "quote\"\t.txt", "gone.txt", "new.txt", "binary.dat"].map(|p| fx.path(p)); + let entry = repo.shelve(&shelf, "work", &paths).unwrap(); + fx.write("a.txt", "new head\n"); + fx.commit_all("advance head"); + fx.write("a.txt", "local edits\n"); + let status = fx.git(&["status", "--porcelain"]); + let index = fx.git(&["ls-files", "--stage"]); + let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap(); + for path in ["a.txt", "b.txt", "quote\"\t.txt"] { + let f = diffs.iter().find(|f| f.path == path).unwrap(); + assert_eq!(f.before.as_deref(), Some("base\n"), "{path}"); + assert_eq!(f.after.as_deref(), Some("shelved\n"), "{path}"); + } + assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().before.as_deref(), Some("")); + assert_eq!(diffs.iter().find(|f| f.path == "gone.txt").unwrap().after.as_deref(), Some("")); + assert!(diffs.iter().find(|f| f.path == "binary.dat").unwrap().before.is_none()); + assert_eq!(fx.git(&["status", "--porcelain"]), status); + assert_eq!(fx.git(&["ls-files", "--stage"]), index); + assert_eq!(fx.read("a.txt"), "local edits\n"); + let patch = shelf::selected_patch(&shelf, &entry.id, Some("b.txt")).unwrap(); + assert!(patch.contains("b/b.txt")); + assert!(!patch.contains("b/a.txt")); + assert!(shelf::selected_patch(&shelf, &entry.id, Some("missing")).is_err()); + shelf::unshelve_path(&repo, &shelf, &entry.id, true, Some("b.txt")).unwrap(); + assert_eq!(fx.read("b.txt"), "shelved\n"); + assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt")); + shelf::delete_path(&shelf, &entry.id, Some("quote\"\t.txt")).unwrap(); + assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok()); + shelf::import_patch(&shelf, "imported", &patch).unwrap(); + assert_eq!(shelf::list(&shelf).unwrap().len(), 2); + assert_eq!(fx.read("a.txt"), "local edits\n"); +} + +#[test] +fn shelf_imported_rename_diff_and_last_file_delete() { + let fx = Fixture::new(); + fx.write("old.txt", "same\n"); + fx.commit_all("base"); + fx.git(&["mv", "old.txt", "new.txt"]); + let patch = fx.git(&["diff", "--cached", "--binary", "--full-index", "-M"]) + "\n"; + fx.git(&["reset", "--hard", "HEAD"]); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + shelf::import_patch(&shelf, "rename", &patch).unwrap(); + let id = &shelf::list(&shelf).unwrap()[0].id; + let diffs = shelf::diff_files(&repo, &shelf, id).unwrap(); + assert_eq!(diffs.len(), 1); + assert_eq!(diffs[0].path, "new.txt"); + // A rename-only imported patch has no embedded blob ids: do not guess from HEAD. + assert!(diffs[0].before.is_none()); + assert!(diffs[0].patch.contains("rename from old.txt")); + shelf::delete_path(&shelf, id, Some("new.txt")).unwrap(); + assert!(shelf::list(&shelf).unwrap().is_empty()); +} + +#[test] +fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() { + let fx = Fixture::new(); + fx.write("old.txt", "renamed content\n"); + fx.commit_all("base"); + fx.git(&["mv", "old.txt", "new.txt"]); + let repo = fx.repo(); + let shelf = fx._tmp.path().join("shelf"); + let entry = repo.shelve(&shelf, "rename", &[fx.path("old.txt"), fx.path("new.txt")]).unwrap(); + let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap(); + assert_eq!(diffs.len(), 2); + assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n")); + assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n")); + let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n"; + shelf::import_patch(&shelf, "plain", plain).unwrap(); + let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap(); + assert!(selected.contains("--- removed\n+++ added")); + assert!(!selected.contains("two.txt")); + shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap(); + assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt"); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f7ff09bd5..739271c7b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -277,6 +277,8 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_shelf_delete, vcs::vcs_shelf_rename, vcs::vcs_shelf_patch, + vcs::vcs_shelf_diff, + vcs::vcs_shelf_import, vcs::vcs_changelists_load, vcs::vcs_changelists_save, vcs::vcs_write_patch, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index cfde2042c..efe148c05 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -608,14 +608,15 @@ pub async fn vcs_unshelve( state: State<'_, AppState>, id: String, remove: bool, + path: Option, ) -> IdeResult { - ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove).await + ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove, path).await } #[tauri::command] #[specta::specta] -pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> { - ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id) +pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String, path: Option) -> IdeResult<()> { + ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id, path) } #[tauri::command] @@ -626,8 +627,13 @@ pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, #[tauri::command] #[specta::specta] -pub fn vcs_shelf_patch(window: Window, state: State<'_, AppState>, id: String) -> IdeResult { - ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id) +pub fn vcs_shelf_patch( + window: Window, + state: State<'_, AppState>, + id: String, + path: Option, +) -> IdeResult { + ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id, path) } #[tauri::command] @@ -648,3 +654,24 @@ pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changeli pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> { ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await } + +#[tauri::command] +#[specta::specta] +pub async fn vcs_shelf_diff( + window: Window, + state: State<'_, AppState>, + id: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_shelf_diff(&state.host, window.label(), id).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_shelf_import( + window: Window, + state: State<'_, AppState>, + name: String, + patch: String, +) -> IdeResult<()> { + ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await +} diff --git a/src/bindings.ts b/src/bindings.ts index 0ce6fda5f..22c09faed 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -517,10 +517,12 @@ export const commands = { * Unshelve: applies the shelved patch (3-way merging; conflicts are left for the * merge UI); with `remove`, deletes it from the shelf. */ - vcsUnshelve: (id: string, remove: boolean) => typedError(__TAURI_INVOKE("vcs_unshelve", { id, remove })), - vcsShelfDelete: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_delete", { id })), + vcsUnshelve: (id: string, remove: boolean, path: string | null) => typedError(__TAURI_INVOKE("vcs_unshelve", { id, remove, path })), + vcsShelfDelete: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_delete", { id, path })), vcsShelfRename: (id: string, name: string) => typedError(__TAURI_INVOKE("vcs_shelf_rename", { id, name })), - vcsShelfPatch: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id })), + vcsShelfPatch: (id: string, path: string | null) => typedError(__TAURI_INVOKE("vcs_shelf_patch", { id, path })), + vcsShelfDiff: (id: string) => typedError(__TAURI_INVOKE("vcs_shelf_diff", { id })), + vcsShelfImport: (name: string, patch: string) => typedError(__TAURI_INVOKE("vcs_shelf_import", { name, patch })), vcsChangelistsLoad: () => typedError(__TAURI_INVOKE("vcs_changelists_load")), vcsChangelistsSave: (changelists: ChangelistState) => typedError(__TAURI_INVOKE("vcs_changelists_save", { changelists })), /** Create Patch › Save to file. */ @@ -5530,6 +5532,13 @@ export type Settings_Serialize = { export type Severity = "error" | "warning" | "info" | "hint"; +export type ShelfDiffFile = { + path: string, + patch: string, + before: string | null, + after: string | null, +}; + /** Version Control › Shelf. */ export type ShelfSettings = { /** Initial state of Unshelve's "Remove successfully applied files from the shelf". */ diff --git a/src/features/vcs/__tests__/shelf.test.tsx b/src/features/vcs/__tests__/shelf.test.tsx new file mode 100644 index 000000000..69ce6d02a --- /dev/null +++ b/src/features/vcs/__tests__/shelf.test.tsx @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; +import type { ShelvedChangeList } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ openDiff: vi.fn(), execute: vi.fn(), ipc: vi.fn(), patch: vi.fn() })); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff: mocks.openDiff, +})); +vi.mock("@/platform/actions", async (original) => ({ + ...(await original()), + executeAction: mocks.execute, +})); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + call: mocks.ipc, + commands: { + ...(await original()).commands, + vcsShelves: () => Promise.resolve(null), + vcsShelfDiff: () => Promise.resolve(null), + fsProbe: () => Promise.resolve(null), + vcsShelfPatch: mocks.patch, + }, +})); +import { ShelfPanel, copyShelfPatch, shelfDiffs, shelfTarget, showShelfDiff, useShelf } from "../shelf"; +import { useCommit } from "../commitStore"; +import { useVcs } from "../store"; +import { ContextMenuHost } from "@/ui"; +import { registerVcsActions } from "../actions"; +import { getAction, isActionKeyEnabled } from "@/platform/actions"; +import { intellijWindows } from "@/platform/keymaps/intellij-windows"; +import { intellijMacos } from "@/platform/keymaps/intellij-macos"; + +const entry: ShelvedChangeList = { + id: "work", + name: "Work", + timeMs: 0, + branch: null, + files: [ + { path: "a.txt", kind: "modified", oldPath: null }, + { path: "b.txt", kind: "added", oldPath: null }, + { path: "binary.dat", kind: "added", oldPath: null }, + ], +}; +const diffs = [ + { path: "a.txt", before: "base", after: "shelved", patch: "a patch" }, + { path: "b.txt", before: "", after: "added", patch: "b patch" }, + { path: "binary.dat", before: null, after: null, patch: "binary patch" }, +]; +beforeEach(() => { + vi.clearAllMocks(); + mocks.patch.mockResolvedValue(null); + useVcs.setState({ root: "/repo" }); + useCommit.setState({ selectedShelf: "work\u0000a.txt" }); + useShelf.setState({ shelves: [entry], loaded: true, diffs: { work: diffs } }); + mocks.ipc.mockResolvedValueOnce([entry]).mockResolvedValue(diffs); +}); +afterEach(cleanup); + +describe("shelf scope and diffs", () => { + it("keeps file scope from selection and explicit targets; rejects stale files", () => { + expect(shelfTarget()).toEqual({ entry, path: "a.txt" }); + expect(shelfTarget({ id: "work", path: "b.txt" })).toEqual({ entry, path: "b.txt" }); + expect(shelfTarget("work")).toEqual({ entry, path: undefined }); + expect(shelfTarget({ id: "work", path: "missing" })).toBeNull(); + expect(shelfTarget({ id: "missing" })).toBeNull(); + }); + it("compares saved sides, keeps binary files out of text diffs, and scopes chains", async () => { + expect(shelfDiffs({ id: "work" })).toHaveLength(2); + expect(shelfDiffs({ id: "work", path: "a.txt" })).toMatchObject([ + { path: "/repo/a.txt", left: { type: "text", text: "base" }, right: { type: "text", text: "shelved" } }, + ]); + expect(shelfDiffs({ id: "work", path: "a.txt" }, true)).toMatchObject([ + { left: { text: "shelved" }, right: { type: "worktree" } }, + ]); + await showShelfDiff(); + expect(mocks.openDiff).toHaveBeenLastCalledWith( + expect.objectContaining({ path: "/repo/a.txt", chain: undefined }), + { preview: true }, + ); + await showShelfDiff("work", false, true); + expect(mocks.openDiff).toHaveBeenLastCalledWith( + expect.objectContaining({ chain: expect.arrayContaining([expect.objectContaining({ path: "/repo/b.txt" })]) }), + { preview: false }, + ); + await showShelfDiff({ id: "work", path: "binary.dat" }); + expect(mocks.openDiff).toHaveBeenCalledTimes(2); + }); + it("compares shelf content with the live working file and skips missing or binary files", async () => { + mocks.ipc.mockReset().mockResolvedValue({ binary: false }); + await showShelfDiff({ id: "work", path: "a.txt" }, true); + expect(mocks.openDiff).toHaveBeenCalledWith( + expect.objectContaining({ + left: expect.objectContaining({ type: "text", text: "shelved" }), + right: expect.objectContaining({ type: "worktree" }), + }), + { preview: true }, + ); + mocks.ipc.mockResolvedValue({ binary: true }); + await showShelfDiff({ id: "work", path: "a.txt" }, true); + mocks.ipc.mockRejectedValue(new Error("missing")); + await showShelfDiff({ id: "work", path: "a.txt" }, true); + expect(mocks.openDiff).toHaveBeenCalledTimes(1); + }); + it("copies only the selected file patch through the scoped patch command", async () => { + const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + mocks.ipc.mockReset().mockResolvedValue("selected file patch"); + try { + await copyShelfPatch(entry, "a.txt"); + expect(mocks.patch).toHaveBeenCalledWith("work", "a.txt"); + expect(writeText).toHaveBeenCalledWith("selected file patch"); + } finally { + if (previous) Object.defineProperty(navigator, "clipboard", previous); + else Reflect.deleteProperty(navigator, "clipboard"); + } + }); + it("routes the configured Show Diff shortcut to the focused shelf selection", async () => { + const off = registerVcsActions(); + try { + const action = getAction("vcs.showDiff")!; + expect(intellijWindows[action.id]).toEqual(["Ctrl+D"]); + expect(intellijMacos[action.id]).toEqual(["Meta+D"]); + expect(isActionKeyEnabled(action, { vcsAvailable: true, shelfFocus: true })).toBe(true); + expect(isActionKeyEnabled(action, { vcsAvailable: true, editorTextFocus: true })).toBe(false); + await action.run({ ctx: { shelfFocus: true } }); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: "/repo/a.txt" }), { preview: true }); + } finally { + off(); + } + }); + it("preserves file targets through selection, menus, toolbar, and activation", async () => { + render( + <> + + + , + ); + await waitFor(() => expect(screen.getByText("Work")).toBeTruthy()); + fireEvent.doubleClick(screen.getByText("Work")); + // Expand explicitly through the accessible tree keyboard navigation. + const tree = screen.getByRole("tree"); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + const file = await screen.findByText("a.txt"); + fireEvent.click(file); + expect(useCommit.getState().selectedShelf).toBe("work\u0000a.txt"); + fireEvent.click(screen.getByRole("button", { name: "Unshelve…" })); + expect(mocks.execute).toHaveBeenCalledWith("vcs.unshelve", { id: "work", path: "a.txt" }); + fireEvent.doubleClick(file); + expect(mocks.execute).toHaveBeenCalledWith("vcs.shelf.showDiff", { id: "work", path: "a.txt" }); + await act(async () => useShelf.setState({ diffs: { work: diffs } })); + fireEvent.contextMenu(file); + await screen.findByRole("menuitem", { name: "Copy as Patch to Clipboard" }); + const labels = screen.getAllByRole("menuitem").map((e) => e.textContent); + for (const label of [ + "Show Diff", + "Show Diff in a New Tab", + "Compare with Local", + "Create Patch…", + "Copy as Patch to Clipboard", + "Import Patches…", + "Delete…", + ]) + expect(labels.some((s) => s?.includes(label))).toBe(true); + expect(screen.getByRole("menuitem", { name: "Rename…" }).getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(screen.getByRole("menuitem", { name: "Copy as Patch to Clipboard" })); + expect(mocks.execute).toHaveBeenCalledWith("vcs.shelf.copyPatch", { id: "work", path: "a.txt" }); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 8b5107e16..832583c65 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -1,3 +1,4 @@ +import { showShelfDiff } from "./shelf"; import { commitRepo } from "./multiRoot"; /** VCS actions (ids match keymaps/intellij-macos.ts where bound). */ import { registerActions, type Action } from "@/platform/actions"; @@ -306,7 +307,8 @@ export function registerVcsActions(): () => void { when: VCS, // ⌘D duplicates the line inside a text editor; Git › Show Diff still works there. keyWhen: "!editorTextFocus", - run: async ({ arg }) => { + run: async ({ arg, ctx }) => { + if (ctx.shelfFocus && arg === undefined) return showShelfDiff(); const line = arg && typeof arg === "object" && "line" in arg ? Number((arg as { line: unknown }).line) : undefined; const paths = contextPaths(arg).filter((p) => !p.endsWith("/")); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index a0d3d682c..e609cec90 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -29,7 +29,7 @@ interface CommitState { tab: CommitTab; /** Change list of the selected tree node (list actions without an argument). */ selectedList: string | null; - /** Selected entry of the Shelf tab. */ + /** Selected shelf tree key: shelf id, optionally followed by NUL and a file path. */ selectedShelf: string | null; } diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 92a599f52..a45f689b0 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -45,7 +45,19 @@ import { undoCommit, } from "./rebase"; import { showApplyPatch, showCreatePatchForChanges, showCreatePatchForCommits } from "./patches"; -import { deleteShelf, renameShelf, shelveChanges, showShelf, unshelve, useShelf } from "./shelf"; +import { + copyShelfPatch, + createShelfPatch, + deleteShelf, + importShelfPatches, + loadShelves, + renameShelf, + shelveChanges, + shelfTarget, + showShelf, + showShelfDiff, + unshelve, +} from "./shelf"; import { currentRepo, focusRepo, useVcs } from "./store"; const VCS = "vcsAvailable"; @@ -123,12 +135,6 @@ function listArg(arg: unknown): string | null { return useCommit.getState().selectedList ?? null; } -function shelfArg(arg: unknown) { - const shelves = useShelf.getState().shelves; - const id = typeof arg === "string" ? arg : useCommit.getState().selectedShelf; - return shelves.find((s) => s.id === id) ?? null; -} - async function checkoutCommit(c: Commit) { await checkoutDetached(c.id, c.shortId); } @@ -496,8 +502,8 @@ export function registerHistoryActions(): () => void { icon: "unshelve", when: VCS, run: ({ arg }) => { - const entry = shelfArg(arg); - if (entry) unshelve(entry); + const target = shelfTarget(arg); + if (target) unshelve(target.entry, target.path); else showShelf(); }, }, @@ -508,8 +514,8 @@ export function registerHistoryActions(): () => void { when: VCS, hidden: true, run: ({ arg }) => { - const entry = shelfArg(arg); - if (entry) void deleteShelf(entry); + const target = shelfTarget(arg); + if (target) void deleteShelf(target.entry, target.path); }, }, { @@ -519,11 +525,66 @@ export function registerHistoryActions(): () => void { when: VCS, hidden: true, run: ({ arg }) => { - const entry = shelfArg(arg); - if (entry) void renameShelf(entry); + const target = shelfTarget(arg); + if (target && !target.path) void renameShelf(target.entry); }, }, + { + id: "vcs.shelf.refresh", + title: "Refresh Shelf", + category: "Git", + when: VCS, + run: async () => { + await loadShelves(); + }, + }, + { + id: "vcs.shelf.showDiff", + title: "Show Shelved Diff", + category: "Git", + when: VCS, + run: ({ arg }) => showShelfDiff(arg), + }, + { + id: "vcs.shelf.showDiffNewTab", + title: "Show Diff in a New Tab", + category: "Git", + when: VCS, + run: ({ arg }) => showShelfDiff(arg, false, true), + }, + { + id: "vcs.shelf.compareLocal", + title: "Compare Shelved Changes with Local", + category: "Git", + when: VCS, + run: ({ arg }) => showShelfDiff(arg, true), + }, + { + id: "vcs.shelf.createPatch", + title: "Create Patch from Shelved Changes…", + category: "Git", + when: VCS, + run: ({ arg }) => createShelfPatch(arg), + }, + { + id: "vcs.shelf.copyPatch", + title: "Copy as Patch to Clipboard", + category: "Git", + when: VCS, + run: async ({ arg }) => { + const t = shelfTarget(arg); + if (t) await copyShelfPatch(t.entry, t.path); + }, + }, + { + id: "vcs.shelf.importPatches", + title: "Import Patches…", + category: "Git", + when: VCS, + run: ({ arg }) => importShelfPatches(Array.isArray(arg) ? arg : undefined), + }, + // --- patches --- { id: "vcs.createPatch", diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index 16ef29057..ab3a58830 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -1,7 +1,7 @@ import { commitRepo } from "./multiRoot"; /** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */ import { useEffect, useState } from "react"; -import type { Commit, FileStatus, PatchFile, PatchSource } from "@/bindings"; +import type { Commit, FileStatus, PatchFile, PatchSource, ShelvedChangeList } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { Button, Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -17,7 +17,10 @@ import { currentRepoRoot, useVcs } from "./store"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; -export type PatchOrigin = { kind: "changes"; files: FileStatus[] } | { kind: "commits"; commits: Commit[] }; +export type PatchOrigin = + | { kind: "changes"; files: FileStatus[] } + | { kind: "commits"; commits: Commit[] } + | { kind: "shelf"; entry: ShelvedChangeList; path?: string }; /** File name IDEA suggests: the change list or commit subject, sanitised. */ export function suggestedPatchName(origin: PatchOrigin, listName: string): string { @@ -26,7 +29,9 @@ export function suggestedPatchName(origin: PatchOrigin, listName: string): strin ? origin.commits.length === 1 ? origin.commits[0]!.summary : `${origin.commits.length} commits` - : listName; + : origin.kind === "shelf" + ? origin.entry.name + : listName; const clean = base .replace(/[^\p{L}\p{N}._-]+/gu, "_") .replace(/^_+|_+$/g, "") @@ -48,6 +53,10 @@ export function showCreatePatchForCommits(commits: Commit[]) { openDialog((close) => ); } +export function showCreatePatchForShelf(entry: ShelvedChangeList, path?: string) { + openDialog((close) => ); +} + async function browseSave(current: string): Promise { try { const { save } = await import("@tauri-apps/plugin-dialog"); @@ -92,22 +101,27 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: if (empty || (toFile && !file.trim())) return; onClose(); await saveAll(); - const source: PatchSource = - origin.kind === "changes" - ? { - from: "changes", - paths: origin.files.filter((f) => selected.has(f.path)).map((f) => stripSlash(f.path)), - reverse, - } - : { - from: "commits", - commits: [...origin.commits].sort((a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0)).map((c) => c.id), - reverse, - }; + const source: PatchSource | null = + origin.kind === "shelf" + ? null + : origin.kind === "changes" + ? { + from: "changes", + paths: origin.files.filter((f) => selected.has(f.path)).map((f) => stripSlash(f.path)), + reverse, + } + : { + from: "commits", + commits: [...origin.commits].sort((a, b) => (a.timeMs ?? 0) - (b.timeMs ?? 0)).map((c) => c.id), + reverse, + }; const done = await runOp( "Create patch", async () => { - const patch = await call(commands.vcsCreatePatch(repo, source)); + const patch = + origin.kind === "shelf" + ? await call(commands.vcsShelfPatch(origin.entry.id, origin.path ?? null)) + : await call(commands.vcsCreatePatch(repo, source!)); if (toFile) await call(commands.vcsWritePatch(file.trim(), patch)); else await navigator.clipboard.writeText(patch); return true; @@ -134,6 +148,8 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: + ) : origin.kind === "shelf" ? ( +
{origin.path ?? origin.entry.name}
) : (
{origin.commits.length === 1 @@ -167,7 +183,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: Copy to clipboard - + {origin.kind !== "shelf" && }
); diff --git a/src/features/vcs/shelf.tsx b/src/features/vcs/shelf.tsx index 67b43ad3d..0979e2b49 100644 --- a/src/features/vcs/shelf.tsx +++ b/src/features/vcs/shelf.tsx @@ -5,7 +5,11 @@ */ import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { create } from "zustand"; -import type { ApplyOutcome, FileStatus, ShelvedChangeList } from "@/bindings"; +import type { ApplyOutcome, FileStatus, ShelvedChangeList, ShelfDiffFile } from "@/bindings"; +import { setContext } from "@/platform/context"; +import { shortcutLabel } from "@/platform/keymap"; +import { openDiff, withChain, type DiffData } from "./diffInput"; +import { showCreatePatchForShelf } from "./patches"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -26,13 +30,24 @@ import { tx } from "@/platform/i18n"; interface ShelfState { shelves: ShelvedChangeList[]; loaded: boolean; + diffs: Record; } -export const useShelf = create(() => ({ shelves: [], loaded: false })); +export const useShelf = create(() => ({ shelves: [], loaded: false, diffs: {} })); export async function loadShelves(): Promise { + const root = useVcs.getState().root; const shelves = await call(commands.vcsShelves()); - useShelf.setState({ shelves, loaded: true }); + if (useVcs.getState().root !== root) return shelves; + useShelf.setState({ shelves, loaded: true, diffs: {} }); + for (const entry of shelves) { + void call(commands.vcsShelfDiff(entry.id)) + .then((files) => { + if (useShelf.getState().shelves !== shelves) return; + useShelf.setState((state) => ({ diffs: { ...state.diffs, [entry.id]: files } })); + }) + .catch(() => undefined); + } return shelves; } @@ -189,11 +204,11 @@ export async function reportApply(outcome: ApplyOutcome, what: string) { notifications().notify({ group: "vcs", level: "info", title: `${what}: ${n} file${n === 1 ? "" : "s"} changed` }); } -export function unshelve(entry: ShelvedChangeList) { - openDialog((close) => ); +export function unshelve(entry: ShelvedChangeList, path?: string) { + openDialog((close) => ); } -function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: () => void }) { +function UnshelveDialog({ entry, path, onClose }: { entry: ShelvedChangeList; path?: string; onClose: () => void }) { const lists = useChangelists((s) => s.state); const [target, setTarget] = useState(lists.active); const [remove, setRemove] = useState(() => workflowSettings().vcs.shelf.removeApplied); @@ -201,9 +216,13 @@ function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: const run = async () => { onClose(); await saveAll(); - const outcome = await runOp(`Unshelve “${entry.name}”`, () => call(commands.vcsUnshelve(entry.id, remove)), { - refresh: false, - }); + const outcome = await runOp( + `Unshelve “${entry.name}”`, + () => call(commands.vcsUnshelve(entry.id, remove, path ?? null)), + { + refresh: false, + }, + ); if (!outcome) return; await refreshNow(); const rels = [...outcome.applied, ...outcome.conflicts].map((p) => relOf(`${root}/${p}`)); @@ -223,7 +242,7 @@ function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose: ]} >
- {entry.name} · {fileCount(entry.files.length)} + {path ?? entry.name} · {fileCount(path ? 1 : entry.files.length)}