feat(vcs): show shelved changes and act on single files (#531)

The shelf could only unshelve or delete a whole changelist, and could not show what it contained: file selection lost its scope and shelf data was not connected to the diff and patch workflows.

The context menu now has Show Diff, Show Diff in a New Tab, Compare with Local, Create Patch, Copy as Patch to Clipboard and Import Patches, alongside Unshelve, Rename and Delete. Actions on a file node apply to that file only, including unshelve and delete. Diffs use the base saved with the shelf, and quoted, added, deleted and binary files are handled.

Fixes #522
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-31 12:17:41 +08:00
1 джерело d4b354732c
коміт c0f8557f05
15 files changed
+876 -97

No files matched your search

+33 -9
Переглянути файл
@@ -806,17 +806,25 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
/// merge UI); with `remove`, deletes it from the shelf.
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
pub async fn vcs_unshelve(
host: &Host,
window: &str,
id: String,
remove: bool,
path: Option<String>,
) -> IdeResult<ApplyOutcome> {
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<String>) -> 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<String> {
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<String> {
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<Vec<ide_vcs::shelf::ShelfDiffFile>> {
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<ChangelistState> {
@@ -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(
+1 -1
Переглянути файл
@@ -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<String> {
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<String> {
+63 -9
Переглянути файл
@@ -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<ChangedFile>)> {
pub(crate) fn working_patch(
r: &Repo,
rel_paths: &[String],
reverse: bool,
detect_renames: bool,
) -> IdeResult<(String, Vec<ChangedFile>)> {
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<Vec<String>> {
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<String> {
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<PatchFile> {
}
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<PatchFile> {
} 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;
}
+168 -5
Переглянути файл
@@ -2,7 +2,7 @@
//! `.idea/shelf`. Each shelved change list is `<shelf>/<id>/shelved.patch` plus
//! `<shelf>/<id>/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<Vec<ShelvedChangeList>> {
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<ApplyOutcome> {
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<ApplyOutcome> {
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<String>,
pub after: Option<String>,
}
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<String> {
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<Vec<ShelfDiffFile>> {
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<String> {
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;
+92
Переглянути файл
@@ -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");
}
+2
Переглянути файл
@@ -277,6 +277,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+32 -5
Переглянути файл
@@ -608,14 +608,15 @@ pub async fn vcs_unshelve(
state: State<'_, AppState>,
id: String,
remove: bool,
path: Option<String>,
) -> IdeResult<ApplyOutcome> {
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<String>) -> 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<String> {
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<String>,
) -> IdeResult<String> {
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<Vec<ide_vcs::shelf::ShelfDiffFile>> {
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
}
+12 -3
Переглянути файл
@@ -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<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_unshelve", { id, remove })),
vcsShelfDelete: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_delete", { id })),
vcsUnshelve: (id: string, remove: boolean, path: string | null) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_unshelve", { id, remove, path })),
vcsShelfDelete: (id: string, path: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_delete", { id, path })),
vcsShelfRename: (id: string, name: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_rename", { id, name })),
vcsShelfPatch: (id: string) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_shelf_patch", { id })),
vcsShelfPatch: (id: string, path: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_shelf_patch", { id, path })),
vcsShelfDiff: (id: string) => typedError<ShelfDiffFile[], IdeError>(__TAURI_INVOKE("vcs_shelf_diff", { id })),
vcsShelfImport: (name: string, patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_import", { name, patch })),
vcsChangelistsLoad: () => typedError<ChangelistState, IdeError>(__TAURI_INVOKE("vcs_changelists_load")),
vcsChangelistsSave: (changelists: ChangelistState) => typedError<null, IdeError>(__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". */
+171
Переглянути файл
@@ -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<typeof import("../diffInput")>()),
openDiff: mocks.openDiff,
}));
vi.mock("@/platform/actions", async (original) => ({
...(await original<typeof import("@/platform/actions")>()),
executeAction: mocks.execute,
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
call: mocks.ipc,
commands: {
...(await original<typeof import("@/platform/ipc")>()).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(
<>
<ShelfPanel />
<ContextMenuHost />
</>,
);
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" });
});
});
+3 -1
Переглянути файл
@@ -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("/"));
+1 -1
Переглянути файл
@@ -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;
}
+74 -13
Переглянути файл
@@ -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",
+33 -17
Переглянути файл
@@ -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) => <CreatePatchDialog origin={{ kind: "commits", commits }} onClose={close} />);
}
export function showCreatePatchForShelf(entry: ShelvedChangeList, path?: string) {
openDialog((close) => <CreatePatchDialog origin={{ kind: "shelf", entry, path }} onClose={close} />);
}
async function browseSave(current: string): Promise<string | null> {
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:
</div>
<FileChecklist files={origin.files} selected={selected} onChange={setSelected} label="Files to include" />
</>
) : origin.kind === "shelf" ? (
<div className={styles.section}>{origin.path ?? origin.entry.name}</div>
) : (
<div className={styles.section}>
{origin.commits.length === 1
@@ -167,7 +183,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose:
<T>Copy to clipboard</T>
</span>
</label>
<Checkbox checked={reverse} onChange={setReverse} label="Reverse patch" />
{origin.kind !== "shelf" && <Checkbox checked={reverse} onChange={setReverse} label="Reverse patch" />}
</div>
</Dialog>
);
+179 -31
Переглянути файл
@@ -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<string, ShelfDiffFile[]>;
}
export const useShelf = create<ShelfState>(() => ({ shelves: [], loaded: false }));
export const useShelf = create<ShelfState>(() => ({ shelves: [], loaded: false, diffs: {} }));
export async function loadShelves(): Promise<ShelvedChangeList[]> {
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) => <UnshelveDialog entry={entry} onClose={close} />);
export function unshelve(entry: ShelvedChangeList, path?: string) {
openDialog((close) => <UnshelveDialog entry={entry} path={path} onClose={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:
]}
>
<div className={styles.section}>
{entry.name} · {fileCount(entry.files.length)}
{path ?? entry.name} · {fileCount(path ? 1 : entry.files.length)}
</div>
<div className={styles.form}>
<label className={styles.fieldLabel}>
@@ -244,15 +263,15 @@ function UnshelveDialog({ entry, onClose }: { entry: ShelvedChangeList; onClose:
);
}
export async function deleteShelf(entry: ShelvedChangeList) {
export async function deleteShelf(entry: ShelvedChangeList, path?: string) {
const ok = await confirmDialog({
title: "Delete Shelved Changes",
message: tx`Delete “${entry.name}” from the shelf? This cannot be undone.`,
message: tx`Delete “${path ?? entry.name}” from the shelf? This cannot be undone.`,
okLabel: "Delete",
danger: true,
});
if (!ok) return;
await runOp(`Delete shelved changes “${entry.name}”`, () => call(commands.vcsShelfDelete(entry.id)), {
await runOp(`Delete shelved changes “${entry.name}”`, () => call(commands.vcsShelfDelete(entry.id, path ?? null)), {
refresh: false,
});
void loadShelves().catch(() => undefined);
@@ -270,9 +289,9 @@ export async function renameShelf(entry: ShelvedChangeList) {
void loadShelves().catch(() => undefined);
}
export async function copyShelfPatch(entry: ShelvedChangeList) {
export async function copyShelfPatch(entry: ShelvedChangeList, path?: string) {
try {
await navigator.clipboard.writeText(await call(commands.vcsShelfPatch(entry.id)));
await navigator.clipboard.writeText(await call(commands.vcsShelfPatch(entry.id, path ?? null)));
notifications().notify({ group: "vcs", level: "info", title: "Patch copied to the clipboard" });
} catch (e) {
logConsole("err", errorMessage(e));
@@ -288,24 +307,131 @@ interface ShelfNode {
children?: ShelfNode[];
}
function shelfMenu(entry: ShelvedChangeList) {
export interface ShelfTarget {
id: string;
path?: string;
}
export function shelfTarget(arg?: unknown): { entry: ShelvedChangeList; path?: string } | null {
const key = typeof arg === "string" ? arg : useCommit.getState().selectedShelf;
const target =
arg && typeof arg === "object" && "id" in arg
? (arg as ShelfTarget)
: key
? { id: key.split("\u0000")[0]!, path: key.split("\u0000")[1] }
: null;
const entry = useShelf.getState().shelves.find((s) => s.id === target?.id);
if (!entry || (target?.path && !entry.files.some((f) => f.path === target.path))) return null;
return { entry, path: target?.path };
}
export function shelfDiffs(target: ShelfTarget, local = false): DiffData[] {
const root = useVcs.getState().root;
if (!root) return [];
return (useShelf.getState().diffs[target.id] ?? [])
.filter((f) => (!target.path || f.path === target.path) && f.before !== null && f.after !== null)
.map((f) => ({
path: `${root}/${f.path}`,
left: {
type: "text",
text: (local ? f.after : f.before)!,
label: tx(local ? "Shelved version" : "Shelved base"),
},
right: local
? { type: "worktree", label: tx("Local version") }
: { type: "text", text: f.after!, label: tx("Shelved version") },
context: { kind: "vcs.shelf", id: target.id, path: f.path },
}));
}
export async function showShelfDiff(arg?: unknown, local = false, newTab = false) {
const target = shelfTarget(arg);
if (!target) return;
if (!useShelf.getState().diffs[target.entry.id]) {
const root = useVcs.getState().root;
const files = await call(commands.vcsShelfDiff(target.entry.id));
if (useVcs.getState().root !== root) return;
useShelf.setState((s) => ({ diffs: { ...s.diffs, [target.entry.id]: files } }));
}
let items = shelfDiffs({ id: target.entry.id, path: target.path }, local);
if (local) {
const supported = await Promise.all(
items.map(async (d) => {
try {
const probe = await call(commands.fsProbe(d.path));
return !probe.binary;
} catch {
return false;
}
}),
);
items = items.filter((_, i) => supported[i]);
}
if (items.length) await openDiff(withChain(items, 0), { preview: !newTab });
}
export async function importShelfPatches(paths?: string[]) {
const picked =
paths ??
(await import("@tauri-apps/plugin-dialog").then(({ open }) =>
open({ multiple: true, directory: false, filters: [{ name: "Patch", extensions: ["patch", "diff"] }] }),
));
if (!picked) return;
for (const path of typeof picked === "string" ? [picked] : picked) {
await runOp(
"Import patches",
async () => call(commands.vcsShelfImport(baseName(path), await call(commands.fsReadText(path)))),
{ refresh: false },
);
}
await loadShelves();
}
function shelfMenu(node: ShelfNode, canCompare: boolean) {
const target: ShelfTarget = { id: node.entry.id, path: node.file?.path };
const available =
node.entry.files.length > 0 && (!node.file || node.entry.files.some((f) => f.path === node.file?.path));
const canDiff = shelfDiffs(target).length > 0;
const action = (id: string) => () => void executeAction(id, target);
return [
{ label: "Unshelve…", icon: "unshelve", run: () => unshelve(entry) },
{ label: "Copy Patch to Clipboard", icon: "copy", run: () => void copyShelfPatch(entry) },
{ label: "Unshelve…", icon: "unshelve", disabled: !available, run: action("vcs.unshelve") },
{ kind: "separator" as const },
{ label: "Rename…", run: () => void renameShelf(entry) },
{ label: "Delete", icon: "trash", danger: true, run: () => void deleteShelf(entry) },
{
label: "Show Diff",
icon: "diff",
shortcut: shortcutLabel("vcs.showDiff"),
disabled: !canDiff,
run: action("vcs.shelf.showDiff"),
},
{ label: "Show Diff in a New Tab", disabled: !canDiff, run: action("vcs.shelf.showDiffNewTab") },
{ label: "Compare with Local", disabled: !canCompare, run: action("vcs.shelf.compareLocal") },
{ kind: "separator" as const },
{ label: "Create Patch…", icon: "file-patch", disabled: !available, run: action("vcs.shelf.createPatch") },
{ label: "Copy as Patch to Clipboard", icon: "copy", disabled: !available, run: action("vcs.shelf.copyPatch") },
{ label: "Import Patches…", run: action("vcs.shelf.importPatches") },
{ kind: "separator" as const },
{ label: "Rename…", disabled: !!node.file, run: action("vcs.shelf.rename") },
{ label: "Delete…", icon: "trash", danger: true, disabled: !available, run: action("vcs.shelf.delete") },
];
}
export function createShelfPatch(arg?: unknown) {
const target = shelfTarget(arg);
if (target) showCreatePatchForShelf(target.entry, target.path);
}
/** The Shelf tab of the Commit tool window. */
export function ShelfPanel() {
const shelves = useShelf((s) => s.shelves);
const loaded = useShelf((s) => s.loaded);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const root = useVcs((s) => s.root);
useShelf((s) => s.diffs);
const selectedShelf = useCommit((s) => s.selectedShelf);
const selected = useMemo(() => new Set(selectedShelf ? [selectedShelf] : []), [selectedShelf]);
useEffect(() => {
void loadShelves().catch(() => undefined);
}, []);
return () => setContext("shelfFocus", false);
}, [root]);
const roots = useMemo<ShelfNode[]>(
() =>
shelves.map((entry) => ({
@@ -315,11 +441,18 @@ export function ShelfPanel() {
})),
[shelves],
);
const current = roots.find((r) => selected.has(r.key) || r.children?.some((c) => selected.has(c.key)))?.entry;
const current = shelfTarget([...selected][0]);
return (
<div className={viewStyles.changes}>
<div
className={viewStyles.changes}
data-shelf-panel
onFocusCapture={() => setContext("shelfFocus", true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("shelfFocus", false);
}}
>
<div className={viewStyles.toolbar} role="toolbar" aria-label={tx("Shelf")}>
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void loadShelves()} />
<IconButton icon="refresh" action="vcs.shelf.refresh" size="small" />
<IconButton icon="shelve" action="vcs.shelve" size="small" />
<span className={viewStyles.sep} />
<IconButton
@@ -327,14 +460,16 @@ export function ShelfPanel() {
title="Unshelve…"
size="small"
disabled={!current}
onClick={() => current && unshelve(current)}
onClick={() => current && void executeAction("vcs.unshelve", { id: current.entry.id, path: current.path })}
/>
<IconButton
icon="trash"
title="Delete"
size="small"
disabled={!current}
onClick={() => current && void deleteShelf(current)}
onClick={() =>
current && void executeAction("vcs.shelf.delete", { id: current.entry.id, path: current.path })
}
/>
</div>
<div className={viewStyles.treeWrap}>
@@ -346,15 +481,28 @@ export function ShelfPanel() {
isExpandable={(n) => !n.file}
selected={selected}
onSelectionChange={(sel) => {
setSelected(sel);
const key = [...sel][0];
useCommit.setState({ selectedShelf: key ? key.split("\u0000")[0]! : null });
useCommit.setState({ selectedShelf: key ?? null });
}}
multiSelect={false}
onActivate={(n) => {
if (!n.file) unshelve(n.entry);
void executeAction(n.file ? "vcs.shelf.showDiff" : "vcs.unshelve", { id: n.entry.id, path: n.file?.path });
}}
onContextMenu={(node, _sel, e: MouseEvent) => {
if (!node) return;
e.preventDefault();
const point = { x: e.clientX, y: e.clientY };
const target = { id: node.entry.id, path: node.file?.path };
void Promise.all(
shelfDiffs(target, true).map(async (d) => {
try {
return !(await call(commands.fsProbe(d.path))).binary;
} catch {
return false;
}
}),
).then((supported) => showContextMenu(point, shelfMenu(node, supported.some(Boolean))));
}}
onContextMenu={(node, _sel, e: MouseEvent) => node && showContextMenu(e, shelfMenu(node.entry))}
aria-label="Shelved changes"
empty={<span className={viewStyles.empty}>{loaded ? tx("No shelved changes") : tx("Loading…")}</span>}
renderNode={(n) =>
+12 -2
Переглянути файл
@@ -465,7 +465,6 @@
"Copy Image Name": "复制镜像名称",
"Copy Name": "复制名称",
"Copy Original Text": "复制原始文本",
"Copy Patch to Clipboard": "将补丁复制到剪贴板",
"Copy Path": "复制路径",
"Copy Path from Project Root": "复制项目根目录起的路径",
"Copy Path/Reference…": "复制路径/引用…",
@@ -5254,5 +5253,16 @@
"Multiple Cursors": "多个光标",
"Scroll to Results During Typing": "键入时滚动到结果",
"Find options": "查找选项",
"Occurrences of '{0}'": "“{0}”的出现位置"
"Occurrences of '{0}'": "“{0}”的出现位置",
"Shelved base": "搁置时的基础版本",
"Shelved version": "搁置版本",
"Local version": "本地版本",
"Show Diff in a New Tab": "在新标签页中显示差异",
"Copy as Patch to Clipboard": "以补丁形式复制到剪贴板",
"Import Patches…": "导入补丁…",
"Import patches": "导入补丁",
"Refresh Shelf": "刷新搁置",
"Show Shelved Diff": "显示搁置差异",
"Compare Shelved Changes with Local": "比较搁置更改与本地版本",
"Create Patch from Shelved Changes…": "从搁置更改创建补丁…"
}