дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
d4b354732c
коміт
c0f8557f05
15 files changed
+876
-97
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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("/"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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…": "从搁置更改创建补丁…"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача