feat(vcs): compare folder selections with revisions across repositories (#602)

Compare with Branch or Revision only handled one file, so reviewing a folder meant opening each file. Folders, the project root and multiple selections now open a changes tree against the chosen revision, with per-file diffs on demand. A single file keeps its direct diff.

Each path resolves against its own repository through the root registry. In a multi-repo workspace the picker offers shared revisions such as "main in 3 repositories" as well as per-repository ones, results are grouped by repository, and content outside any repository or a repository missing the revision is stated in the result. Listing is cancellable and off the UI thread, includes worktree and untracked changes, and a rename leaving the selection shows as a delete. Show Diff on folders reuses the same tree.

Verified on macOS, and against a remote host over the server protocol. A remote window, Windows and Linux were not run.

Fixes #567
This commit is contained in:
l0ng-ai
2026-11-01 01:43:15 +08:00
committed by GitHub
parent 0a71f6be88
commit 9e35cfc064
20 changed files with 1404 additions and 121 deletions
+39
View File
@@ -746,6 +746,33 @@ pub async fn vcs_remove_worktree(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
}
pub async fn vcs_compare_scope(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<CompareScope> {
let project = host.project(window)?;
blocking(move || Ok(project.repos().compare_scope(&paths))).await
}
pub async fn vcs_compare_paths(
host: &Host,
window: &str,
paths: Vec<PathBuf>,
revision: String,
repositories: Option<Vec<PathBuf>>,
request: RequestId,
) -> IdeResult<CompareResult> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = blocking(move || {
let settings = project.settings.effective();
if project.is_trusted() {
apply_git_executable(&settings);
}
project.repos().compare_paths(&paths, &revision, repositories.as_deref(), &cancel)
})
.await;
host.end_request(request);
result
}
/// Compare with Local: tracked files that differ between `rev` and the working tree.
pub async fn vcs_diff_with_worktree(
host: &Host,
@@ -1040,6 +1067,18 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_remove_worktree", |c| async move {
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
});
d.add("vcs_compare_scope", |c| async move { vcs_compare_scope(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_compare_paths", |c| async move {
vcs_compare_paths(
&c.host,
&c.window,
c.arg("paths")?,
c.arg("revision")?,
c.arg("repositories")?,
c.arg("request")?,
)
.await
});
d.add("vcs_diff_with_worktree", |c| async move {
vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await
});
+2
View File
@@ -276,6 +276,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_remotes"
| "vcs_worktrees"
| "vcs_diff_with_worktree"
| "vcs_compare_scope"
| "vcs_compare_paths"
| "vcs_diff_revisions"
| "vcs_merge_base"
| "vcs_shelves"
+49
View File
@@ -348,6 +348,55 @@ fn run_raw_env_once(
Ok(Output { stdout: out.stdout, stderr: out.stderr, code: out.status.code() })
}
/// Captures binary/NUL-delimited read output while allowing cancellation to kill git.
pub(crate) fn run_cancellable(root: &Path, args: &[&str], cancel: &CancellationToken) -> IdeResult<Output> {
let out = run_cancellable_raw(root, args, cancel)?;
if out.code == Some(0) { Ok(out) } else { Err(failure(args, &out.stderr, &out.stdout, out.code)) }
}
/// Cancellable capture preserving exit codes for commands with documented non-error states.
pub(crate) fn run_cancellable_raw(root: &Path, args: &[&str], cancel: &CancellationToken) -> IdeResult<Output> {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let mut cmd = command(root)?;
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut session = CommandSession::start(&mut cmd);
let mut child = cmd.spawn().map_err(spawn_error)?;
let capture = |mut pipe: Box<dyn Read + Send>| {
std::thread::spawn(move || {
let mut bytes = Vec::new();
pipe.read_to_end(&mut bytes).map(|_| bytes)
})
};
let stdout = capture(Box::new(child.stdout.take().expect("piped stdout")));
let stderr = capture(Box::new(child.stderr.take().expect("piped stderr")));
let status = loop {
if cancel.is_cancelled() {
kill(&mut child);
let _ = child.wait();
let _ = stdout.join();
let _ = stderr.join();
return Err(IdeError::cancelled());
}
if let Some(status) = child.try_wait()? {
break status;
}
std::thread::sleep(Duration::from_millis(20));
};
let join = |thread: std::thread::JoinHandle<std::io::Result<Vec<u8>>>| {
thread.join().map_err(|_| IdeError::internal("git output reader panicked"))?.map_err(IdeError::from)
};
let out = Output { stdout: join(stdout)?, stderr: join(stderr)?, code: status.code() };
session.finish(status.success(), &String::from_utf8_lossy(&out.stderr));
Ok(out)
}
/// Runs git and maps a non-zero exit status to an error carrying git's message.
pub(crate) fn run(root: &Path, args: &[&str], stdin: Option<&[u8]>) -> IdeResult<Output> {
let out = run_raw(root, args, stdin)?;
+521
View File
@@ -0,0 +1,521 @@
//! Selection-scoped revision → working-tree comparison over the existing root registry.
use crate::{ChangeKind, Repos, cli, ops};
use ide_core::{CancellationToken, IdeError, IdeResult, IpcPath};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CompareScope {
pub repositories: Vec<IpcPath>,
/// Selected content outside registered repositories; workspace parents expand to their skipped children.
pub skipped_paths: Vec<IpcPath>,
pub single_file: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WorkingTreeChange {
pub path: String,
pub kind: ChangeKind,
pub old_path: Option<String>,
pub added: u32,
pub removed: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RepositoryComparison {
pub root: IpcPath,
/// Resolved commit, so opening a diff later uses the same left side as the listing.
pub revision: Option<String>,
pub files: Vec<WorkingTreeChange>,
pub error: Option<String>,
/// The requested revision does not resolve to a commit in this repository.
#[serde(default)]
pub missing_revision: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CompareResult {
pub repositories: Vec<RepositoryComparison>,
pub skipped_paths: Vec<IpcPath>,
}
fn normalized(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn comparison_children(path: &Path) -> Vec<PathBuf> {
std::fs::read_dir(path)
.into_iter()
.flatten()
.flatten()
.filter(|entry| entry.file_name() != ".git" && entry.file_name() != ".openidea")
.map(|entry| entry.path())
.collect()
}
impl Repos {
pub fn compare_scope(&self, paths: &[PathBuf]) -> CompareScope {
let paths: Vec<_> = paths.iter().map(|p| normalized(p)).collect();
let repositories = self
.all()
.iter()
.filter(|r| {
let root = normalized(r.root());
paths.iter().any(|p| {
root.starts_with(p)
|| self.at_root(p).or_else(|| self.for_path(p)).is_some_and(|owner| owner.root() == r.root())
})
})
.map(|r| IpcPath(r.root().to_path_buf()))
.collect();
let mut skipped = std::collections::BTreeSet::new();
for path in &paths {
if self.at_root(path).or_else(|| self.for_path(path)).is_some() {
continue;
}
if self.all().iter().any(|r| r.root().starts_with(path)) {
for child in comparison_children(path) {
if self.has_outside_content(&child) {
skipped.insert(child);
}
}
} else {
skipped.insert(path.clone());
}
}
let skipped_paths = skipped
.into_iter()
.map(|mut path| {
if path.is_dir() {
path.push("");
}
IpcPath(path)
})
.collect();
CompareScope { repositories, skipped_paths, single_file: paths.len() == 1 && !paths[0].is_dir() }
}
fn has_outside_content(&self, path: &Path) -> bool {
if self.at_root(path).or_else(|| self.for_path(path)).is_some() {
return false;
}
!self.all().iter().any(|r| r.root().starts_with(path))
|| comparison_children(path).iter().any(|child| self.has_outside_content(child))
}
pub fn compare_paths(
&self,
paths: &[PathBuf],
revision: &str,
repositories: Option<&[PathBuf]>,
cancel: &CancellationToken,
) -> IdeResult<CompareResult> {
if revision.trim().is_empty() || revision.starts_with('-') {
return Err(IdeError::invalid("invalid revision"));
}
let scope = self.compare_scope(paths);
let paths: Vec<_> = paths.iter().map(|p| normalized(p)).collect();
let mut result = CompareResult { repositories: Vec::new(), skipped_paths: scope.skipped_paths };
for root in scope.repositories {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if repositories.is_some_and(|roots| !roots.iter().any(|r| normalized(r) == normalized(&root.0))) {
continue;
}
let repo = self.at_root(&root.0).expect("scope comes from registry");
let _scope =
crate::ExecutionScope::enter(repo.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
let mut missing_revision = false;
let mut compare = || -> IdeResult<(String, Vec<WorkingTreeChange>)> {
// Worktree comparisons must not run repository-defined clean/process filters
// in Restricted Mode. External diff and textconv are disabled below as well.
let mut overrides = Vec::new();
if !crate::execution_allowed() {
let config = cli::run_cancellable(repo.root(), &["config", "--null", "--list"], cancel)?;
for record in config.stdout_str().split('\0') {
let key = record.split('\n').next().unwrap_or_default();
if key.starts_with("filter.")
&& (key.ends_with(".clean") || key.ends_with(".process") || key.ends_with(".required"))
{
overrides.push("-c".to_owned());
overrides.push(format!("{key}={}", if key.ends_with(".required") { "false" } else { "" }));
}
}
}
let run = |args: &[&str]| {
let mut full: Vec<&str> = overrides.iter().map(String::as_str).collect();
full.extend_from_slice(args);
cli::run_cancellable(repo.root(), &full, cancel)
};
// --quiet documents exit 1 for an unresolved revision; other failures keep their diagnostics.
let resolve_args =
["rev-parse", "--verify", "--quiet", "--end-of-options", &format!("{revision}^{{commit}}")];
let resolved = cli::run_cancellable_raw(repo.root(), &resolve_args, cancel)?;
if resolved.code != Some(0) {
missing_revision = resolved.code == Some(1);
return Err(cli::failure(&resolve_args, &resolved.stderr, &resolved.stdout, resolved.code));
}
let resolved = resolved.stdout_str().trim().to_owned();
// Disable textconv/external diff; these are also safe in untrusted projects.
let names =
run(&["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", &resolved, "--"])?;
let stats = run(&[
"diff",
"--numstat",
"-z",
"--no-renames",
"--no-ext-diff",
"--no-textconv",
&resolved,
"--",
])?;
let stats: std::collections::HashMap<_, _> = ops::parse_numstat(&stats.stdout_str())
.into_iter()
.map(|(path, added, removed)| (path, (added, removed)))
.collect();
let mut files = Vec::new();
let contains = |rel: &str| {
let absolute = repo.root().join(rel);
// Nested clones contribute their own files, never the enclosing repo's entries.
let own = self.at_root(&absolute).or_else(|| self.for_path(&absolute));
own.is_some_and(|r| r.root() == repo.root()) && paths.iter().any(|p| absolute.starts_with(p))
};
for mut file in ops::parse_name_status(&names.stdout_str()) {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let new_in = contains(&file.path);
let old_in = file.old_path.as_deref().is_some_and(contains);
if !new_in && !old_in {
continue;
}
let original = file.path.clone();
if file.old_path.is_some() && new_in != old_in {
if old_in {
file.path = file.old_path.take().unwrap();
file.kind = ChangeKind::Deleted;
} else {
file.old_path = None;
file.kind = ChangeKind::Added;
}
}
let count = |path: &str| stats.get(path).copied().unwrap_or_default();
let (added, removed) = match &file.old_path {
Some(old) => (count(&original).0, count(old).1),
None => count(&file.path),
};
files.push(WorkingTreeChange {
path: file.path,
kind: file.kind,
old_path: file.old_path,
added,
removed,
});
}
if files.iter().any(|f| f.kind == ChangeKind::Renamed) {
let renamed =
run(&["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", &resolved, "--"])?;
let indexes: std::collections::HashMap<_, _> = files
.iter()
.enumerate()
.filter(|(_, f)| f.kind == ChangeKind::Renamed)
.map(|(index, f)| (f.path.clone(), index))
.collect();
let mut records =
renamed.stdout_str().split('\0').map(str::to_owned).collect::<Vec<_>>().into_iter();
while let Some(record) = records.next() {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let fields: Vec<_> = record.splitn(3, '\t').collect();
if fields.len() == 3 && fields[2].is_empty() {
let _old = records.next();
if let Some(path) = records.next()
&& let Some(index) = indexes.get(&path)
{
let file = &mut files[*index];
file.added = fields[0].parse().unwrap_or(0);
file.removed = fields[1].parse().unwrap_or(0);
}
}
}
}
let untracked = run(&["ls-files", "--others", "--exclude-standard", "-z"])?;
for path in untracked.stdout_str().split('\0').filter(|p| !p.is_empty() && contains(p)) {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let absolute = repo.root().join(path);
// Match the existing diff viewer's large/binary-file policy without loading large files for counts.
let added = if std::fs::metadata(&absolute).is_ok_and(|m| m.len() <= 10 * 1024 * 1024) {
let bytes = std::fs::read(&absolute).unwrap_or_default();
if bytes.contains(&0) {
0
} else {
(bytes.iter().filter(|b| **b == b'\n').count()
+ usize::from(!bytes.is_empty() && bytes.last() != Some(&b'\n')))
as u32
}
} else {
0
};
files.push(WorkingTreeChange {
path: path.to_owned(),
kind: ChangeKind::Untracked,
old_path: None,
added,
removed: 0,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok((resolved, files))
};
let comparison = match compare() {
Ok((revision, files)) => {
RepositoryComparison { root, revision: Some(revision), files, error: None, missing_revision: false }
}
Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()),
Err(e) => RepositoryComparison {
root,
revision: None,
files: Vec::new(),
error: (!missing_revision).then_some(e.message),
missing_revision,
},
};
result.repositories.push(comparison);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git").current_dir(root).args(args).output().unwrap();
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
}
fn write(root: &Path, path: &str, text: &str) {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
fn init(root: &Path, branch: &str) {
std::fs::create_dir_all(root).unwrap();
git(root, &["init", "-q", "-b", branch]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "user.email", "test@example.com"]);
write(root, "src/文件 name.txt", "old\n");
write(root, "src/move.txt", "move\n");
write(root, "other/stable.txt", "stable\n");
git(root, &["add", "."]);
git(root, &["commit", "-qm", "initial"]);
}
#[test]
fn subtree_union_worktree_untracked_and_rename_boundaries() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
git(&root, &["switch", "-qc", "feature"]);
git(&root, &["mv", "src/move.txt", "other/moved.txt"]);
git(&root, &["commit", "-qm", "move out"]);
write(&root, "src/文件 name.txt", "old\nnew\n");
write(&root, "src/untracked space 中文.txt", "new\nfile\n");
write(&root, "other/stable.txt", "changed\n");
let repos = Repos::discover(&root, &|_| false).unwrap();
let cancel = CancellationToken::new();
let result = repos.compare_paths(&[root.join("src")], "main", None, &cancel).unwrap();
assert_eq!(result.repositories.len(), 1);
let files = &result.repositories[0].files;
assert_eq!(files.len(), 3, "{files:?}");
let moved = files.iter().find(|f| f.path == "src/move.txt").unwrap();
assert_eq!(moved.kind, ChangeKind::Deleted);
assert_eq!((moved.added, moved.removed), (0, 1));
assert!(moved.old_path.is_none());
let changed = files.iter().find(|f| f.path == "src/文件 name.txt").unwrap();
assert_eq!((changed.added, changed.removed), (1, 0));
let untracked = files.iter().find(|f| f.path == "src/untracked space 中文.txt").unwrap();
assert_eq!((untracked.kind, untracked.added), (ChangeKind::Untracked, 2));
let union = repos
.compare_paths(&[root.join("src"), root.join("other"), root.join("src")], "main", None, &cancel)
.unwrap();
assert_eq!(union.repositories[0].files.len(), 4);
let rename = union.repositories[0].files.iter().find(|f| f.path == "other/moved.txt").unwrap();
assert_eq!(rename.kind, ChangeKind::Renamed);
assert_eq!(rename.old_path.as_deref(), Some("src/move.txt"));
assert_eq!((rename.added, rename.removed), (0, 0));
let into = repos.compare_paths(&[root.join("other")], "main", None, &cancel).unwrap();
assert_eq!(
into.repositories[0].files.iter().find(|f| f.path == "other/moved.txt").unwrap().kind,
ChangeKind::Added
);
}
#[test]
fn workspace_roots_missing_revision_loose_paths_and_empty_results() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
for name in ["client", "server", "admin"] {
init(&root.join(name), if name == "admin" { "trunk" } else { "main" });
}
write(&root, "loose/readme.txt", "not git\n");
write(&root.join("client"), "src/new.txt", "client\n");
write(&root.join("server"), "src/文件 name.txt", "server\n");
let repos = Repos::discover(&root, &|_| false).unwrap();
let scope = repos.compare_scope(std::slice::from_ref(&root));
assert_eq!(scope.repositories.len(), 3);
assert_eq!(scope.skipped_paths, [IpcPath(root.join("loose"))]);
assert!(!scope.single_file);
assert!(repos.compare_scope(&[root.join("client/src/文件 name.txt")]).single_file);
let cancel = CancellationToken::new();
let result = repos.compare_paths(&[root.clone(), root.join("loose")], "main", None, &cancel).unwrap();
assert_eq!(result.repositories.len(), 3);
assert_eq!(result.repositories.iter().filter(|r| r.missing_revision).count(), 1);
assert!(result.repositories[0].missing_revision, "admin lacks main");
assert!(result.repositories[0].error.is_none());
assert_eq!(result.repositories[1].files.len(), 1);
assert_eq!(result.repositories[2].files.len(), 1);
assert_eq!(result.skipped_paths, [IpcPath(root.join("loose"))]);
let filtered =
repos.compare_paths(std::slice::from_ref(&root), "main", Some(&[root.join("server")]), &cancel).unwrap();
assert_eq!(filtered.repositories.len(), 1);
assert_eq!(filtered.repositories[0].root.0, root.join("server"));
let empty = repos.compare_paths(&[root.join("server/other")], "main", None, &cancel).unwrap();
assert!(empty.repositories[0].files.is_empty());
let loose = repos.compare_paths(&[root.join("loose")], "main", None, &cancel).unwrap();
assert!(loose.repositories.is_empty());
assert_eq!(loose.skipped_paths, [IpcPath(root.join("loose"))]);
cancel.cancel();
assert!(repos.compare_paths(&[root], "main", None, &cancel).is_err());
}
#[test]
fn nested_repositories_are_not_reported_twice_and_binary_counts_are_zero() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
init(&root.join("nested"), "main");
write(&root.join("nested"), "src/new.txt", "nested\n");
std::fs::write(root.join("src/image.bin"), [0, 1, 2]).unwrap();
let repos = Repos::discover(&root, &|_| false).unwrap();
let result = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new()).unwrap();
assert_eq!(result.repositories.len(), 2);
assert_eq!(result.repositories[0].files.len(), 1);
assert_eq!(result.repositories[0].files[0].added, 0);
assert_eq!(result.repositories[1].files.len(), 1);
let nested = repos.compare_paths(&[root.join("nested")], "main", None, &CancellationToken::new()).unwrap();
assert_eq!(nested.repositories.len(), 1);
assert_eq!(nested.repositories.iter().map(|r| r.files.len()).sum::<usize>(), 1);
}
#[test]
fn skipped_children_only_report_content_outside_repositories() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root.join("services/server"), "main");
init(&root.join("client"), "main");
write(&root, ".openidea/state.json", "{}");
write(&root, ".git/config", "ignored metadata");
let repos = Repos::discover(&root, &|_| false).unwrap();
assert!(repos.compare_scope(std::slice::from_ref(&root)).skipped_paths.is_empty());
write(&root, "services/notes.txt", "loose nested content");
write(&root, "loose/readme.txt", "loose");
write(&root, "readme.txt", "loose file");
let scope = repos.compare_scope(&[root.clone(), root.join("loose")]);
assert_eq!(
scope.skipped_paths,
[IpcPath(root.join("loose")), IpcPath(root.join("readme.txt")), IpcPath(root.join("services")),]
);
assert!(scope.skipped_paths[0].0.to_string_lossy().ends_with(std::path::MAIN_SEPARATOR));
}
#[test]
fn missing_revision_is_typed_and_other_errors_keep_only_the_message() {
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
let repos = Repos::discover(&root, &|_| false).unwrap();
let cancel = CancellationToken::new();
let result = repos.compare_paths(std::slice::from_ref(&root), "missing-revision", None, &cancel).unwrap();
assert!(result.repositories[0].missing_revision);
assert!(result.repositories[0].error.is_none());
std::fs::write(root.join(".git/config"), "[broken").unwrap();
let result = repos.compare_paths(&[root], "main", None, &cancel).unwrap();
assert!(!result.repositories[0].missing_revision);
let error = result.repositories[0].error.as_deref().unwrap();
assert!(error.contains("bad config"), "{error}");
assert!(!error.starts_with("Internal:"), "{error}");
}
#[cfg(unix)]
#[test]
fn untracked_read_and_metadata_failures_leave_files_with_zero_counts() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
// Reading a directory fails after successful metadata; a dangling link fails metadata.
symlink(root.join("src"), root.join("unreadable")).unwrap();
symlink(root.join("vanished"), root.join("missing")).unwrap();
write(&root, "new.txt", "still counted\n");
let repos = Repos::discover(&root, &|_| false).unwrap();
let result = repos.compare_paths(&[root], "main", None, &CancellationToken::new()).unwrap();
let repo = &result.repositories[0];
assert!(repo.error.is_none(), "{repo:?}");
assert_eq!(repo.files.len(), 3);
for name in ["unreadable", "missing"] {
let file = repo.files.iter().find(|f| f.path == name).unwrap();
assert_eq!((file.kind, file.added, file.removed), (ChangeKind::Untracked, 0, 0));
}
assert_eq!(repo.files.iter().find(|f| f.path == "new.txt").unwrap().added, 1);
}
#[cfg(unix)]
#[test]
fn restricted_comparison_does_not_execute_repository_commands() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().unwrap();
let root = normalized(temp.path());
init(&root, "main");
let script = root.join(".git/marker.sh");
let marker = root.join(".git/executed");
std::fs::write(&script, "#!/bin/sh\ntouch .git/executed\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let script = script.to_str().unwrap();
for key in [
"core.fsmonitor",
"filter.marker.clean",
"filter.marker.process",
"diff.marker.command",
"diff.marker.textconv",
] {
git(&root, &["config", key, script]);
}
git(&root, &["config", "filter.marker.required", "true"]);
write(&root, ".gitattributes", "*.txt filter=marker diff=marker\n");
write(&root, "src/文件 name.txt", "changed\n");
write(&root, "new.txt", "untracked\n");
let repos = Repos::discover(&root, &|_| false).unwrap();
for repo in repos.all() {
repo.set_execution_allowed(false);
}
let result = repos.compare_paths(std::slice::from_ref(&root), "main", None, &CancellationToken::new()).unwrap();
assert!(result.repositories[0].error.is_none(), "{result:?}");
assert_eq!(result.repositories[0].files.len(), 3);
assert!(!marker.exists(), "Restricted Mode executed a repository command");
// Prove the fixture's fsmonitor hook is active when the shared wrapper is bypassed.
git(&root, &["ls-files"]);
assert!(marker.exists(), "control invocation did not run fsmonitor");
}
}
+2
View File
@@ -453,6 +453,7 @@ pub struct ChangelistState {
mod blame;
pub mod changelists;
mod cli;
mod compare;
mod diff;
mod history;
mod ignore;
@@ -464,6 +465,7 @@ mod refs;
mod remotes;
mod repo;
mod roots;
pub use compare::{CompareResult, CompareScope, RepositoryComparison, WorkingTreeChange};
pub mod shelf;
mod status;
pub use index_writes::is_own_index_write;
+8
View File
@@ -1555,6 +1555,14 @@ fn restricted_reads_ignore_executable_repository_configuration() {
assert!(!repo.log(&LogQuery::default()).unwrap().is_empty());
repo.merge_versions(&f.path("a.txt"), encoding_rs::UTF_8).unwrap();
assert!(!f.path("execution.marker").exists());
let repos = ide_vcs::Repos::discover(&f.dir, &|_| false).unwrap();
for r in repos.all() {
r.set_execution_allowed(false);
}
let compared =
repos.compare_paths(std::slice::from_ref(&f.dir), "HEAD", None, &ide_core::CancellationToken::new()).unwrap();
assert!(compared.repositories.iter().all(|r| r.error.is_none()));
assert!(!f.path("execution.marker").exists());
// Positive control: the same filter really executes without restriction.
f.git(&["-c", "core.fsmonitor=false", "diff", "--no-ext-diff", "--no-textconv"]);
assert!(f.path("execution.marker").exists());
+2
View File
@@ -271,6 +271,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_add_worktree,
vcs::vcs_remove_worktree,
vcs::vcs_diff_with_worktree,
vcs::vcs_compare_scope,
vcs::vcs_compare_paths,
vcs::vcs_diff_revisions,
vcs::vcs_merge_base,
vcs::vcs_create_patch,
+23
View File
@@ -680,3 +680,26 @@ pub async fn vcs_shelf_import(
) -> IdeResult<()> {
ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_compare_scope(
window: Window,
state: State<'_, AppState>,
paths: Vec<PathBuf>,
) -> IdeResult<ide_vcs::CompareScope> {
ide_host::ops::vcs::vcs_compare_scope(&state.host, window.label(), paths).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_compare_paths(
window: Window,
state: State<'_, AppState>,
paths: Vec<PathBuf>,
revision: String,
repositories: Option<Vec<PathBuf>>,
request: RequestId,
) -> IdeResult<ide_vcs::CompareResult> {
ide_host::ops::vcs::vcs_compare_paths(&state.host, window.label(), paths, revision, repositories, request).await
}
+32
View File
@@ -509,6 +509,8 @@ export const commands = {
vcsRemoveWorktree: (repo: string | null, path: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remove_worktree", { repo, path, force })),
/** Compare with Local: tracked files that differ between `rev` and the working tree. */
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
vcsCompareScope: (paths: string[]) => typedError<CompareScope, IdeError>(__TAURI_INVOKE("vcs_compare_scope", { paths })),
vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError<CompareResult, IdeError>(__TAURI_INVOKE("vcs_compare_paths", { paths, revision, repositories, request })),
/** Files that differ between two revisions (the "N files updated" of Update Project). */
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
/** The best common ancestor of `a` and `b` (`git merge-base`), if any. */
@@ -1888,6 +1890,18 @@ export type CommitSettings = {
wrapAtMargin?: boolean,
};
export type CompareResult = {
repositories: RepositoryComparison[],
skippedPaths: IpcPath[],
};
export type CompareScope = {
repositories: IpcPath[],
/** Selected content outside registered repositories; workspace parents expand to their skipped children. */
skippedPaths: IpcPath[],
singleFile: boolean,
};
export type CompletionItem = {
label: string,
/** e.g. `(req: CreateOrderRequest)` shown right after the label. */
@@ -5125,6 +5139,16 @@ export type ReplaceTarget = {
only?: Pos[] | null,
};
export type RepositoryComparison = {
root: IpcPath,
/** Resolved commit, so opening a diff later uses the same left side as the listing. */
revision: string | null,
files: WorkingTreeChange[],
error: string | null,
/** The requested revision does not resolve to a commit in this repository. */
missingRevision?: boolean,
};
/** A cancellable, streaming backend request issued by the UI. */
export type RequestId = number;
@@ -6294,6 +6318,14 @@ export type WindowTab = {
current: boolean,
};
export type WorkingTreeChange = {
path: string,
kind: ChangeKind,
oldPath: string | null,
added: number,
removed: number,
};
export type Workspace = Workspace_Serialize | Workspace_Deserialize;
/** Edits to many files, applied atomically from the user's point of view. */
@@ -44,6 +44,7 @@ const OTHER_ACTIONS = [
"vcs.addToVcs",
"vcs.annotate",
"vcs.showDiff",
"vcs.compareWithBranch",
"vcs.rollback",
"vcs.push",
];
@@ -129,6 +130,17 @@ describe("project view context menu", () => {
}
});
it("offers branch comparison for files, folders, their union and the project root", () => {
for (const nodes of [
[node("/p/a.py")],
[node("/p/src", true)],
[node("/p/src", true), node("/p/lib", true)],
[node("/p", true, true)],
]) {
expect(sub(contextMenuItems(nodes), "Git")).toContain("Compare with Branch…");
}
});
it("offers Show History for files, directories, multi-selection and the project root", () => {
const dispose = registerActions([{ id: "vcs.showHistory", title: "Show History", run: () => undefined }]);
try {
+3 -1
View File
@@ -335,7 +335,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
{ kind: "separator" },
singleFile && act("vcs.annotate", singleFile.path, "Annotate with Git Blame"),
act("vcs.showDiff", arg, "Show Diff"),
singleFile && act("vcs.compareWithBranch", { path: singleFile.path }, "Compare with Branch…"),
nodes.length > 0 &&
nodes.every((n) => !n.placeholder) &&
act("vcs.compareWithBranch", arg, "Compare with Branch…"),
nodes.length > 0 && nodes.every((n) => !n.placeholder) && act("vcs.showHistory", arg, "Show History"),
act("vcs.rollback", arg, "Rollback…"),
{ kind: "separator" },
+117 -44
View File
@@ -1,26 +1,100 @@
/** Chooser for "Compare with Branch or Revision…". */
/** Chooser for revision actions, including a selection spanning multiple repositories. */
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { Dialog, HighlightedText, Icon, List, SearchField, fuzzyMatch, listNavigate } from "@/ui";
import { sortLocal, sortRemote } from "./branches";
import { openDialog } from "./dialogs";
import { call, commands } from "@/platform/ipc";
import type { Branch } from "@/bindings";
import { currentRepo } from "./store";
import { currentRepo, repoName } from "./store";
import { tx } from "@/platform/i18n";
import styles from "./common.module.css";
interface Item {
export interface RevisionChoice {
rev: string;
/** null applies an entered revision to every repository in the selection. */
repositories: string[] | null;
}
interface Item extends RevisionChoice {
detail: string;
matches: number[] | null;
section?: boolean;
}
export interface RepositoryRevisions {
root: string | null;
branches: Branch[];
tags: string[];
}
export interface ChooseRevisionOptions {
/** Offer the repository's tags too. */
tags?: boolean;
placeholder?: string;
repositories?: string[];
}
export function chooseRevision(title: string, options: ChooseRevisionOptions = {}): Promise<string | null> {
/** Shared branches first, followed by each repository's revisions. */
export function revisionItems(repos: RepositoryRevisions[], query: string): Item[] {
const all: Item[] = [{ rev: "HEAD", detail: tx("Last commit"), repositories: null, matches: null }];
const multi = repos.length > 1;
if (multi) {
const shared = new Map<string, string[]>();
for (const repo of repos) {
for (const name of new Set(repo.branches.map((b) => b.name))) {
const roots = shared.get(name) ?? [];
if (repo.root) roots.push(repo.root);
shared.set(name, roots);
}
}
for (const [rev, repositories] of shared) {
if (repositories.length > 1)
all.push({ rev, detail: tx`in ${repositories.length} repositories`, repositories, matches: null });
}
}
for (const repo of repos) {
if (multi)
all.push({
rev: repoName(repo.root ?? ""),
detail: repo.root ?? "",
repositories: [],
matches: null,
section: true,
});
const repositories = repo.root ? [repo.root] : null;
all.push(
...sortLocal(repo.branches).map((b) => ({
rev: b.name,
detail: tx(b.current ? "current branch" : "local"),
repositories,
matches: null,
})),
...sortRemote(repo.branches).map((b) => ({ rev: b.name, detail: tx("remote"), repositories, matches: null })),
...repo.tags.map((rev) => ({ rev, detail: tx("tag"), repositories, matches: null })),
);
}
const q = query.trim();
if (!q) return all;
const filtered = all
.flatMap((i) => {
if (i.section) return [i];
const matches = fuzzyMatch(i.rev, q);
return matches ? [{ ...i, matches }] : [];
})
.filter((i, idx, list) => !i.section || (!!list[idx + 1] && !list[idx + 1]!.section));
// Always allow applying a typed revision across the full selection, even when one repo offers it.
if (!filtered.some((i) => !i.section && i.rev === q && i.repositories === null))
filtered.push({ rev: q, detail: tx("revision"), repositories: null, matches: null });
return filtered;
}
export async function chooseRevision(title: string, options: ChooseRevisionOptions = {}): Promise<string | null> {
return (await chooseRepositoryRevision(title, options))?.rev ?? null;
}
export function chooseRepositoryRevision(
title: string,
options: ChooseRevisionOptions,
): Promise<RevisionChoice | null> {
return new Promise((resolve) => {
const close = openDialog(() => (
<Chooser
@@ -42,57 +116,46 @@ function Chooser({
}: {
title: string;
options: ChooseRevisionOptions;
onDone: (rev: string | null) => void;
onDone: (rev: RevisionChoice | null) => void;
}) {
const [repo] = useState(currentRepo);
const [branches, setBranches] = useState<Branch[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [roots] = useState(() => options.repositories ?? [currentRepo()]);
const [repos, setRepos] = useState<RepositoryRevisions[]>([]);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
useEffect(() => {
let alive = true;
void call(commands.vcsBranches(repo))
.then((b) => alive && setBranches(b))
.catch(() => undefined);
if (options.tags) {
void call(commands.vcsTags(repo))
.then((t) => alive && setTags(t))
.catch(() => undefined);
}
void Promise.all(
roots.map(async (root) => {
const [branches, tags] = await Promise.allSettled([
call(commands.vcsBranches(root)),
options.tags ? call(commands.vcsTags(root)) : Promise.resolve<string[]>([]),
]);
for (const outcome of [branches, tags])
if (outcome.status === "rejected" && alive) setError(`${root ?? ""}: ${String(outcome.reason)}`);
return {
root,
branches: branches.status === "fulfilled" ? branches.value : [],
tags: tags.status === "fulfilled" ? tags.value : [],
};
}),
)
.then((r) => alive && setRepos(r))
.catch((e) => alive && setError(String(e)));
return () => {
alive = false;
};
}, [options.tags]);
const items = useMemo(() => {
const all: Item[] = [
{ rev: "HEAD", detail: "Last commit", matches: null },
...sortLocal(branches).map((b) => ({
rev: b.name,
detail: b.current ? "current branch" : "local",
matches: null,
})),
...sortRemote(branches).map((b) => ({ rev: b.name, detail: "remote", matches: null })),
...tags.map((t) => ({ rev: t, detail: "tag", matches: null })),
];
const q = query.trim();
const filtered = q
? all.flatMap((i) => {
const m = fuzzyMatch(i.rev, q);
return m ? [{ ...i, matches: m }] : [];
})
: all;
if (q && !filtered.some((i) => i.rev === q)) filtered.push({ rev: q, detail: "revision", matches: null });
return filtered;
}, [branches, tags, query]);
}, [options.tags, roots]);
const items = useMemo(() => revisionItems(repos, query), [repos, query]);
const idx = Math.min(active, items.length - 1);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
const it = items[idx];
if (it) onDone(it.rev);
if (it && !it.section) onDone(it);
return;
}
const n = listNavigate(e.key, { items, active: idx, wrap: true });
const n = listNavigate(e.key, { items, active: idx, isSection: (i) => !!i.section, wrap: true });
if (n !== null) {
e.preventDefault();
setActive(n);
@@ -114,13 +177,20 @@ function Chooser({
variant="bare"
/>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div style={{ height: 280, display: "flex", flexDirection: "column", padding: "4px 0" }}>
<List
items={items}
getKey={(i) => `${i.detail}:${i.rev}`}
getKey={(i) => `${i.repositories?.join(":")}:${i.detail}:${i.rev}`}
activeIndex={idx}
onActiveChange={setActive}
onActivate={(i) => onDone(i.rev)}
onActivate={(i) => onDone(i)}
isSection={(i) => !!i.section}
renderSection={(i) => <span title={i.detail}>{i.rev}</span>}
focusable={false}
hoverSelects
rowHeight={24}
@@ -128,7 +198,10 @@ function Chooser({
className={styles.grow}
renderItem={(i) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
<Icon name={i.detail === "revision" ? "commit" : i.detail === "tag" ? "tag" : "branch"} size={14} />
<Icon
name={i.detail === tx("revision") ? "commit" : i.detail === tx("tag") ? "tag" : "branch"}
size={14}
/>
<HighlightedText
className={`${styles.fileName} ${styles.mono}`}
text={i.rev}
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAction, isActionEnabled } from "@/platform/actions";
import { provideProjectViewSelection } from "@/workbench/contributions";
import { registerVcsActions } from "../actions";
const mocks = vi.hoisted(() => ({ compare: vi.fn() }));
vi.mock("../comparePaths", () => ({ comparePathsWithRevision: mocks.compare, showDirectoryWorkingDiff: vi.fn() }));
afterEach(() => vi.clearAllMocks());
describe("compare action context", () => {
it("is enabled for Git folder selections and uses project selection in menu/Search Everywhere", async () => {
const unregister = registerVcsActions();
const dispose = provideProjectViewSelection(() => ["/workspace/client", "/workspace/server"]);
try {
const action = getAction("vcs.compareWithBranch")!;
expect(isActionEnabled(action, { vcsAvailable: true, projectViewFocus: true })).toBe(true);
await action.run({ arg: undefined, ctx: { vcsAvailable: true } });
expect(mocks.compare).toHaveBeenCalledWith(["/workspace/client", "/workspace/server"]);
await action.run({ arg: { path: "/workspace/client/a.ts" }, ctx: { vcsAvailable: true } });
expect(mocks.compare).toHaveBeenLastCalledWith(["/workspace/client/a.ts"]);
} finally {
dispose();
unregister();
}
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ scope: vi.fn(), choose: vi.fn(), open: vi.fn(), show: vi.fn(), notify: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, vcsCompareScope: mocks.scope } };
});
vi.mock("../RevisionChooser", () => ({ chooseRepositoryRevision: mocks.choose }));
vi.mock("../diffInput", () => ({ openDiff: mocks.open }));
vi.mock("../operations", () => ({ showPathComparison: mocks.show }));
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: mocks.notify }) }));
import { useProject } from "@/platform/project";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "../comparePaths";
afterEach(() => {
vi.clearAllMocks();
useProject.setState({ project: null });
});
describe("comparison flow", () => {
it("keeps a single file's direct revision → worktree diff and uses its own repository's picker", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: ["/server"], singleFile: true, skippedPaths: [] },
});
mocks.choose.mockResolvedValue({ rev: "main", repositories: ["/server"] });
await comparePathsWithRevision(["/server/a.ts"]);
expect(mocks.choose).toHaveBeenCalledWith("Compare with Branch or Revision", {
repositories: ["/server"],
tags: true,
});
expect(mocks.open).toHaveBeenCalledWith({
path: "/server/a.ts",
left: { type: "rev", rev: "main", label: "main" },
right: { type: "worktree" },
});
expect(mocks.show).not.toHaveBeenCalled();
});
it("applies shared revisions across the selection so missing revisions are reported, and preserves repo-specific choices", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: ["/client", "/server", "/admin"], singleFile: false, skippedPaths: [] },
});
mocks.choose.mockResolvedValue({ rev: "main", repositories: ["/client", "/server"] });
await comparePathsWithRevision(["/workspace"]);
expect(mocks.show).toHaveBeenLastCalledWith(["/workspace"], "main", null);
mocks.choose.mockResolvedValue({ rev: "feature", repositories: ["/server"] });
await comparePathsWithRevision(["/workspace"]);
expect(mocks.show).toHaveBeenLastCalledWith(["/workspace"], "feature", ["/server"]);
});
it("reports non-repository selections before opening a picker", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: [], singleFile: false, skippedPaths: ["/workspace/loose/"] },
});
useProject.setState({ project: { root: "/workspace", name: "workspace" } as never });
await comparePathsWithRevision(["/workspace/loose"]);
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Selection is outside Git repositories", body: "loose/" }),
);
expect(mocks.choose).not.toHaveBeenCalled();
});
it("reuses the tree against HEAD for Show Diff on folders", async () => {
mocks.scope.mockResolvedValue({
status: "ok",
data: { repositories: ["/server"], singleFile: false, skippedPaths: [] },
});
expect(await showDirectoryWorkingDiff(["/server/src"])).toBe(true);
expect(mocks.show).toHaveBeenCalledWith(["/server/src"], "HEAD", null);
});
});
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Branch, CompareResult } from "@/bindings";
const mocks = vi.hoisted(() => ({
compare: vi.fn(),
cancel: vi.fn(),
scope: vi.fn(),
choose: vi.fn(),
open: vi.fn(),
show: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
vcsComparePaths: mocks.compare,
cancelRequest: mocks.cancel,
vcsCompareScope: mocks.scope,
},
};
});
vi.mock("../DiffEditor", () => ({
DiffEditor: ({ input }: { input: unknown }) => <div data-testid="diff">{JSON.stringify(input)}</div>,
}));
import { useProject } from "@/platform/project";
import { ChangesBetweenDialog, comparisonEmpty } from "../operations";
import { revisionItems } from "../RevisionChooser";
const branch = (name: string, remote = false) => ({ name, remote, current: false, lastCommitMs: 0 }) as Branch;
const scope = { paths: ["/workspace"], repositories: null };
const result: CompareResult = {
skippedPaths: ["/workspace/loose/"],
repositories: [
{
root: "/workspace/client",
revision: "aaa",
error: null,
missingRevision: false,
files: [{ path: "src/app.ts", kind: "modified", oldPath: null, added: 3, removed: 1 }],
},
{ root: "/workspace/server", revision: null, error: null, missingRevision: true, files: [] },
],
};
beforeEach(() => {
vi.clearAllMocks();
useProject.setState({ project: { root: "/workspace", name: "workspace" } as never });
mocks.cancel.mockResolvedValue(undefined);
});
afterEach(() => {
cleanup();
useProject.setState({ project: null });
});
describe("directory comparison", () => {
it("offers shared branches plus each repository's local, remote and tag revisions", () => {
const repos = [
{
root: "/workspace/client",
branches: [branch("main"), branch("client-only"), branch("origin/main", true)],
tags: ["v1"],
},
{ root: "/workspace/server", branches: [branch("main"), branch("server-only")], tags: [] },
{ root: "/workspace/admin", branches: [branch("main")], tags: [] },
];
const items = revisionItems(repos, "");
expect(items.find((i) => i.rev === "main" && i.repositories?.length === 3)?.detail).toBe("in 3 repositories");
expect(items.filter((i) => i.section).map((i) => i.rev)).toEqual(["client", "server", "admin"]);
expect(items.find((i) => i.rev === "origin/main")?.repositories).toEqual(["/workspace/client"]);
expect(items.find((i) => i.rev === "v1")?.repositories).toEqual(["/workspace/client"]);
expect(revisionItems(repos, "client-only").some((i) => i.rev === "client-only" && i.repositories === null)).toBe(
true,
);
});
it("shows explicit empty results, skipped paths and repository-specific errors", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: { ...result, repositories: result.repositories.map((r) => ({ ...r, files: [] })) },
});
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("No changes against main in 1 repository")).toBeInTheDocument();
expect(screen.getByText("server has no revision main")).toBeInTheDocument();
expect(screen.getByText(/Skipped content/)).toHaveTextContent("Skipped content outside Git repositories: loose/");
expect(comparisonEmpty("main", 2)).toBe("No changes against main in 2 repositories");
});
it("shows one calm state when every selected repository lacks the revision", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: {
...result,
repositories: result.repositories.map((r) => ({ ...r, files: [], revision: null, missingRevision: true })),
},
});
render(
<ChangesBetweenDialog
from={{ rev: "missing-revision", label: "missing-revision" }}
to={null}
scope={scope}
onClose={() => {}}
/>,
);
expect(await screen.findByText("No repository in the selection has revision missing-revision")).toBeInTheDocument();
expect(screen.queryByText(/has no revision/)).toBeNull();
expect(screen.queryByText(/No changes against/)).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
});
it("keeps other repository errors visible", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: {
...result,
repositories: [{ ...result.repositories[1], missingRevision: false, error: "fatal: bad config" }],
},
});
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByRole("alert")).toHaveTextContent("Cannot compare server against main: fatal: bad config");
expect(screen.queryByText(/No changes against/)).toBeNull();
});
it("caps the list of skipped project-relative children", async () => {
mocks.compare.mockResolvedValue({
status: "ok",
data: {
...result,
skippedPaths: Array.from({ length: 7 }, (_, i) => `/workspace/loose${i}/`),
},
});
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByText(/Skipped content/)).toHaveTextContent(
"Skipped content outside Git repositories: loose0/, loose1/, loose2/, loose3/, loose4/ and 2 more",
);
expect(screen.getByText(/Skipped content/)).not.toHaveTextContent("/workspace");
});
it("groups the existing changes tree by repo, includes counts and loads a diff only on click", async () => {
mocks.compare.mockResolvedValue({ status: "ok", data: result });
render(<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("app.ts")).toBeInTheDocument();
expect(screen.getByText("client")).toBeInTheDocument();
expect(screen.getByText("+3")).toBeInTheDocument();
expect(screen.getByText("−1")).toBeInTheDocument();
expect(screen.queryByTestId("diff")).toBeNull();
fireEvent.mouseDown(screen.getByText("app.ts"), { button: 0 });
expect(screen.getByTestId("diff")).toHaveTextContent('"rev":"aaa"');
expect(screen.getByTestId("diff")).toHaveTextContent("/workspace/client/src/app.ts");
});
it("cancels the backend request when a running comparison is closed", async () => {
mocks.compare.mockReturnValue(new Promise(() => {}));
const view = render(
<ChangesBetweenDialog from={{ rev: "main", label: "main" }} to={null} scope={scope} onClose={() => {}} />,
);
await waitFor(() => expect(mocks.compare).toHaveBeenCalled());
const request = mocks.compare.mock.calls[0]![3];
view.unmount();
expect(mocks.cancel).toHaveBeenCalledWith(request);
});
});
+17 -9
View File
@@ -5,7 +5,13 @@ import { registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { activeEditor, editorsFor } from "@/editor/api";
import { Menu, type MenuItem } from "@/ui";
import { editorService, maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
import {
editorService,
maybeEditorService,
notifications,
projectViewSelection,
toolWindowService,
} from "@/workbench/contributions";
import { annotationsShown, toggleAnnotations } from "./blame";
import { showBranchesPopup } from "./BranchWidget";
import { checkoutRevision, newBranch, newBranchInRepos } from "./branchOps";
@@ -29,7 +35,7 @@ import { openLogTab, revealCommit, selectGitTab, useGitLog } from "./logStore";
import { isModalCommit, openCommit, pushAfterCommit } from "./commitUi";
import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget } from "./remoteUi";
import { chooseRevision } from "./RevisionChooser";
import { comparePathsWithRevision, showDirectoryWorkingDiff } from "./comparePaths";
import { showCloneDialog } from "./clone";
import {
showManageRemotes,
@@ -103,6 +109,7 @@ const OPERATIONS: MenuItem[] = [
{ kind: "action", action: "vcs.showHistory" },
{ kind: "action", action: "vcs.annotate" },
{ kind: "action", action: "vcs.showDiff" },
{ kind: "action", action: "vcs.compareWithBranch" },
{ kind: "separator" },
{ kind: "action", action: "vcs.branches" },
{ kind: "action", action: "vcs.push" },
@@ -311,11 +318,13 @@ export function registerVcsActions(): () => void {
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("/"));
const selection = arg === undefined ? projectViewSelection() : [];
const paths = selection.length ? selection : contextPaths(arg);
if (!paths.length) {
notifications().notify({ group: "vcs", level: "info", title: "Select a changed file to show its diff" });
return;
}
if (await showDirectoryWorkingDiff(paths)) return;
for (const p of paths.slice(0, 5)) await openDiff(workingDiffData(p, line), { preview: paths.length === 1 });
},
},
@@ -326,14 +335,13 @@ export function registerVcsActions(): () => void {
icon: "diff",
when: VCS,
run: async ({ arg }) => {
const path = contextPaths(arg)[0];
if (!path) {
notifications().notify({ group: "vcs", level: "info", title: "Open or select a file to compare" });
const selection = arg === undefined ? projectViewSelection() : [];
const paths = selection.length ? selection : contextPaths(arg);
if (!paths.length) {
notifications().notify({ group: "vcs", level: "info", title: "Open or select a file or folder to compare" });
return;
}
const rev = await chooseRevision("Compare with Branch or Revision");
if (!rev) return;
await openDiff({ path, left: { type: "rev", rev, label: rev }, right: { type: "worktree" } });
await comparePathsWithRevision(paths);
},
},
{
+46
View File
@@ -282,3 +282,49 @@
.manageRowSelected:hover {
background: var(--bg-list-selected);
}
.comparison {
display: flex;
height: min(520px, 65vh);
min-height: 180px;
padding: 0 12px 12px;
gap: 10px;
}
.comparisonTree {
flex: 1;
min-width: 0;
border: 1px solid var(--border);
}
.comparisonWithDiff .comparisonTree {
flex: 0 0 30%;
}
.comparisonDiff {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
}
.compareNotice {
padding: 6px 14px;
color: var(--fg-subtle);
overflow-wrap: anywhere;
}
.compareAdded {
color: var(--vcs-added);
font-variant-numeric: tabular-nums;
}
.compareRemoved {
color: var(--vcs-deleted);
font-variant-numeric: tabular-nums;
}
@media (max-width: 760px) {
.comparisonWithDiff {
flex-direction: column;
height: 65vh;
}
.comparisonWithDiff .comparisonTree {
flex: 0 0 30%;
min-height: 90px;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { call, commands } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { chooseRepositoryRevision } from "./RevisionChooser";
import { openDiff } from "./diffInput";
import { showPathComparison } from "./operations";
import { displayPath } from "./compare";
export async function comparePathsWithRevision(paths: string[], revision?: string) {
const scope = await call(commands.vcsCompareScope(paths));
if (!scope.repositories.length) {
notifications().notify({
group: "vcs",
level: "info",
title: "Selection is outside Git repositories",
body: scope.skippedPaths.map((path) => displayPath(path)).join("\n"),
});
return;
}
const choice = revision
? { rev: revision, repositories: null }
: await chooseRepositoryRevision(tx("Compare with Branch or Revision"), {
repositories: scope.repositories,
tags: true,
});
if (!choice) return;
if (scope.singleFile && !revision) {
await openDiff({
path: paths[0]!,
left: { type: "rev", rev: choice.rev, label: choice.rev },
right: { type: "worktree" },
});
} else {
// A shared branch reviews the selection, including explicit errors for roots lacking it.
const repositories = choice.repositories && choice.repositories.length === 1 ? choice.repositories : null;
showPathComparison(paths, choice.rev, repositories);
}
}
/** Returns false for a single file, whose existing Show Diff behavior is retained. */
export async function showDirectoryWorkingDiff(paths: string[]): Promise<boolean> {
const scope = await call(commands.vcsCompareScope(paths));
if (scope.singleFile) return false;
if (!scope.repositories.length) {
notifications().notify({
group: "vcs",
level: "info",
title: "Selection is outside Git repositories",
body: scope.skippedPaths.map((path) => displayPath(path)).join("\n"),
});
} else showPathComparison(paths, "HEAD", null);
return true;
}
+203 -66
View File
@@ -4,20 +4,23 @@ import { commitKey, commitRepo } from "./multiRoot";
* cherry-pick or revert, and the Git Log commit actions (Cherry-Pick, Revert
* Commit, Reset Current Branch to Here, New Tag, Compare with Local).
*/
import { useEffect, useState } from "react";
import type { ChangedFile, Commit, ResetMode } from "@/bindings";
import { useEffect, useMemo, useState } from "react";
import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, List, Spinner, fileIconName, T } from "@/ui";
import { call, commands, nextRequestId } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, Input, Tree, Spinner, fileIconName, T } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { openDiff, type DiffSide } from "./diffInput";
import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput";
import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName } from "./model";
import { KIND_COLOR, fileCount } from "./model";
import { runGitOperation, runOp } from "./ops";
import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoOf, useVcs } from "./store";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoName, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
import { DiffEditor } from "./DiffEditor";
import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree";
import { tx } from "@/platform/i18n";
import { displayPath } from "./compare";
// ---------------------------------------------------------------------------
// Operation in progress
@@ -479,102 +482,236 @@ export function showCompareRevisions(from: string, to: string, fromLabel = from,
));
}
export function showPathComparison(paths: string[], rev: string, repositories: string[] | null) {
openDialog((close) => (
<ChangesBetweenDialog from={{ rev, label: rev }} to={null} scope={{ paths, repositories }} onClose={close} />
));
}
interface Revision {
rev: string;
label: string;
}
/** Files that differ between `from` and `to` (`null`: the working tree). */
function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revision | null; onClose: () => void }) {
interface ScopedComparison {
paths: string[];
repositories: string[] | null;
}
interface ComparisonFile extends ChangedFile {
root: string;
revision: string;
added?: number;
removed?: number;
}
export function comparisonEmpty(revision: string, repositories: number): string {
return repositories === 1
? tx`No changes against ${revision} in 1 repository`
: tx`No changes against ${revision} in ${repositories} repositories`;
}
/** Shared branch/commit comparison surface, also used for directory selections. */
export function ChangesBetweenDialog({
from,
to,
scope,
onClose,
}: {
from: Revision;
to: Revision | null;
scope?: ScopedComparison;
onClose: () => void;
}) {
const [root] = useState(currentRepoRoot);
const [repo] = useState(currentRepo);
const [files, setFiles] = useState<ChangedFile[] | null>(null);
const [files, setFiles] = useState<ComparisonFile[] | null>(null);
const [result, setResult] = useState<CompareResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState(0);
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [expanded, setExpanded] = useState<ReadonlySet<string> | null>(null);
const [current, setCurrent] = useState<ComparisonFile | null>(null);
const fromRev = from.rev;
const toRev = to?.rev ?? null;
useEffect(() => {
let alive = true;
call(toRev ? commands.vcsDiffRevisions(repo, fromRev, toRev) : commands.vcsDiffWithWorktree(repo, fromRev))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
let pending = true;
const request = nextRequestId();
const load = async () => {
if (scope) {
const r = await call(commands.vcsComparePaths(scope.paths, fromRev, scope.repositories, request));
if (!alive) return;
setResult(r);
setFiles(
r.repositories.flatMap((r) =>
r.files.map((f: WorkingTreeChange) => ({ ...f, root: r.root, revision: r.revision! })),
),
);
} else {
const f = await call(
toRev ? commands.vcsDiffRevisions(repo, fromRev, toRev) : commands.vcsDiffWithWorktree(repo, fromRev),
);
if (alive) setFiles(f.map((f) => ({ ...f, root: root ?? "", revision: fromRev })));
}
};
void load()
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)))
.finally(() => {
pending = false;
});
return () => {
alive = false;
if (scope && pending) void commands.cancelRequest(request);
};
}, [fromRev, toRev]);
const open = (f: ChangedFile) => {
const repoRoot = root;
}, [fromRev, toRev, repo, root, scope]);
const tree = useMemo(() => {
if (!scope) return buildFileTree(files ?? [], (f) => f.path);
return (result?.repositories ?? []).map((r): FileTreeNode<ComparisonFile> => {
const list = (files ?? []).filter((f) => f.root === r.root);
const prefix = (nodes: FileTreeNode<ComparisonFile>[]): FileTreeNode<ComparisonFile>[] =>
nodes.map((n) => ({ ...n, key: `${r.root}:${n.key}`, children: prefix(n.children) }));
return {
key: r.root,
name: repoName(r.root),
file: null,
count: list.length,
children: prefix(buildFileTree(list, (f) => f.path)),
};
});
}, [files, result, scope]);
const diffFor = (f: ComparisonFile): DiffData => {
const missing = (label: string): DiffSide => ({ type: "text", text: "", label });
void openDiff({
path: `${repoRoot}/${f.path}`,
return {
path: `${f.root}/${f.path}`,
left:
f.kind === "added" || f.kind === "untracked"
? missing("(does not exist)")
: { type: "rev", rev: from.rev, label: from.label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined },
right: !to
? { type: "worktree" }
: f.kind === "deleted"
: {
type: "rev",
rev: f.revision,
label: from.label,
path: f.oldPath ? `${f.root}/${f.oldPath}` : undefined,
},
right:
f.kind === "deleted"
? missing("(deleted)")
: { type: "rev", rev: to.rev, label: to.label },
});
: !to
? { type: "worktree" }
: { type: "rev", rev: to.rev, label: to.label },
};
};
const current = files?.[active];
const input = current ? diffInput(diffFor(current)) : null;
const title = to
? tx`Changes between ${from.label} and ${to.label}`
: tx`Changes between ${from.label} and Local Working Tree`;
const count = files ? fileCount(files.length) : "";
const successful = result?.repositories.filter((r) => !r.error && !r.missingRevision).length ?? 1;
const allMissing = !!result?.repositories.length && result.repositories.every((r) => r.missingRevision);
const skipped = result?.skippedPaths.map((path) => displayPath(path)) ?? [];
const skippedNames = skipped.slice(0, 5).join(", ");
const skippedLabel = skipped.length > 5 ? tx`${skippedNames} and ${skipped.length - 5} more` : skippedNames;
return (
<Dialog
open
title={
to
? tx`Changes between ${from.label} and ${to.label}`
: tx`Changes between ${from.label} and Local Working Tree`
}
title={files ? `${title} — ${count}` : title}
onClose={onClose}
width={620}
width={input ? 1100 : 680}
buttons={[
{ label: "Show Diff", role: "default", disabled: !current, onClick: () => current && open(current) },
{
label: "Show Diff",
role: "default",
disabled: !current,
onClick: () => {
if (current) {
void openDiff(diffFor(current));
onClose();
}
},
},
{ label: "Close", role: "cancel" },
]}
>
<div className={styles.section}>{files ? `${files.length} file${files.length === 1 ? "" : "s"} differ` : ""}</div>
<div style={{ height: 300, display: "flex", flexDirection: "column", padding: "0 14px 14px" }}>
{error ? (
<div className={styles.error}>{error}</div>
) : !files ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : (
<List
items={files}
getKey={(f) => f.path}
activeIndex={Math.min(active, Math.max(0, files.length - 1))}
onActiveChange={setActive}
rowHeight={22}
aria-label="Changed files"
className={styles.boxList}
empty={
<span>
<T>No differences</T>
</span>
}
onActivate={(f, _i, e) => {
if (e.type === "click" && (e as unknown as MouseEvent).detail < 2) return;
open(f);
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{result?.skippedPaths.length ? (
<div className={styles.compareNotice}>{tx`Skipped content outside Git repositories: ${skippedLabel}`}</div>
) : null}
{allMissing ? (
<div className={styles.empty} role="status">
{tx`No repository in the selection has revision ${from.label}`}
</div>
) : (
result?.repositories
.filter((r) => r.error || r.missingRevision)
.map((r) => (
<div key={r.root} className={styles.compareNotice} role={r.missingRevision ? "status" : "alert"}>
{r.missingRevision
? tx`${repoName(r.root)} has no revision ${from.label}`
: tx`Cannot compare ${repoName(r.root)} against ${from.label}: ${r.error}`}
</div>
))
)}
{!files && !error ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : null}
{files && !files.length && successful > 0 ? (
<div className={styles.empty} role="status">
{comparisonEmpty(from.label, successful)}
</div>
) : null}
{files && files.length > 0 ? (
<div className={`${styles.comparison} ${input ? styles.comparisonWithDiff : ""}`}>
<Tree<FileTreeNode<ComparisonFile>>
roots={tree}
getKey={(n) => n.key}
getLabel={(n) => n.name}
getChildren={(n) => (n.file ? undefined : n.children)}
isExpandable={(n) => !n.file}
expanded={expanded ?? new Set(dirKeys(tree))}
onExpandedChange={setExpanded}
selected={selected}
onSelectionChange={(sel, lead) => {
setSelected(sel);
if (lead?.file) setCurrent(lead.file);
}}
renderItem={(f) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }}>
<Icon name={fileIconName(baseName(f.path))} size={14} />
onActivate={(n) => n.file && setCurrent(n.file)}
aria-label="Changed files"
className={styles.comparisonTree}
renderNode={(n) => (
<span className={styles.fileRow} style={{ flex: 1, padding: 0 }} title={n.file ? n.file.path : n.name}>
<Icon name={n.file ? fileIconName(n.name) : "folder"} size={14} />
<span
className={styles.fileName}
style={{ color: KIND_COLOR[f.kind === "untracked" ? "added" : f.kind] }}
style={
n.file ? { color: KIND_COLOR[n.file.kind === "untracked" ? "added" : n.file.kind] } : undefined
}
>
{baseName(f.path)}
{n.name}
</span>
<span className={styles.fileDir}>{dirName(f.path)}</span>
{n.file ? (
<>
<span className={styles.fileDir}>{tx(n.file.kind)}</span>
{n.file.added !== undefined && <span className={styles.compareAdded}>+{n.file.added}</span>}
{n.file.removed !== undefined && <span className={styles.compareRemoved}>−{n.file.removed}</span>}
</>
) : (
<span className={styles.fileDir}>{n.count}</span>
)}
</span>
)}
/>
)}
</div>
{input && (
<div className={styles.comparisonDiff}>
<DiffEditor key={input.key} input={input} />
</div>
)}
</div>
) : null}
</Dialog>
);
}
+11 -1
View File
@@ -5348,5 +5348,15 @@
"No debug output available": "暂无可用的调试输出",
"The attached process may not provide standard output to the debugger.": "附加的进程可能不会向调试器提供标准输出。",
"Show in Debug Tool Window": "在调试工具窗口中显示",
"Suspended": "已暂停"
"Suspended": "已暂停",
"Cannot compare {root} against {label}: {error}": "无法将 {root} 与 {label} 比较:{error}",
"Compare with Branch or Revision": "与分支或修订比较",
"No changes against {revision} in 1 repository": "在 1 个仓库中与 {revision} 相比没有更改",
"No changes against {revision} in {repositories} repositories": "在 {repositories} 个仓库中与 {revision} 相比没有更改",
"Open or select a file or folder to compare": "打开或选择要比较的文件或文件夹",
"Selection is outside Git repositories": "所选内容不在 Git 仓库中",
"Skipped content outside Git repositories: {skippedLabel}": "已跳过 Git 仓库之外的内容:{skippedLabel}",
"in {length} repositories": "在 {length} 个仓库中",
"No repository in the selection has revision {label}": "所选内容中的仓库均无版本 {label}",
"{repoName} has no revision {label}": "{repoName} 没有版本 {label}"
}