diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index efb9a881..64d47e42 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -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) -> IdeResult { + 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, + revision: String, + repositories: Option>, + request: RequestId, +) -> IdeResult { + 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 }); diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 64ec419d..9e1bfb82 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -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" diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 54809482..051984ed 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -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 { + 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 { + 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| { + 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>>| { + 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 { let out = run_raw(root, args, stdin)?; diff --git a/crates/ide-vcs/src/compare.rs b/crates/ide-vcs/src/compare.rs new file mode 100644 index 00000000..d2404185 --- /dev/null +++ b/crates/ide-vcs/src/compare.rs @@ -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, + /// Selected content outside registered repositories; workspace parents expand to their skipped children. + pub skipped_paths: Vec, + 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, + 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, + pub files: Vec, + pub error: Option, + /// 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, + pub skipped_paths: Vec, +} + +fn normalized(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn comparison_children(path: &Path) -> Vec { + 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 { + 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)> { + // 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::>().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::(), 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"); + } +} diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index ac6f18de..cca5669a 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -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; diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 6db9d76f..989037d6 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -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()); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e327a264..f4d94c85 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -271,6 +271,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 1c90bf70..9f3aaf18 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -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, +) -> IdeResult { + 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, + revision: String, + repositories: Option>, + request: RequestId, +) -> IdeResult { + ide_host::ops::vcs::vcs_compare_paths(&state.host, window.label(), paths, revision, repositories, request).await +} diff --git a/src/bindings.ts b/src/bindings.ts index de046238..80ddb8f9 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -509,6 +509,8 @@ export const commands = { vcsRemoveWorktree: (repo: string | null, path: string, force: boolean) => typedError(__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(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })), + vcsCompareScope: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_compare_scope", { paths })), + vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError(__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(__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. */ diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index b1970bb6..622ddf3b 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -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 { diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index c235dbf4..1b35cf48 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -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" }, diff --git a/src/features/vcs/RevisionChooser.tsx b/src/features/vcs/RevisionChooser.tsx index f301e25d..9269e5bb 100644 --- a/src/features/vcs/RevisionChooser.tsx +++ b/src/features/vcs/RevisionChooser.tsx @@ -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 { +/** 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(); + 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 { + return (await chooseRepositoryRevision(title, options))?.rev ?? null; +} + +export function chooseRepositoryRevision( + title: string, + options: ChooseRevisionOptions, +): Promise { return new Promise((resolve) => { const close = openDialog(() => ( void; + onDone: (rev: RevisionChoice | null) => void; }) { - const [repo] = useState(currentRepo); - const [branches, setBranches] = useState([]); - const [tags, setTags] = useState([]); + const [roots] = useState(() => options.repositories ?? [currentRepo()]); + const [repos, setRepos] = useState([]); + const [error, setError] = useState(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([]), + ]); + 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" /> + {error && ( +
+ {error} +
+ )}
`${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) => {i.rev}} focusable={false} hoverSelects rowHeight={24} @@ -128,7 +198,10 @@ function Chooser({ className={styles.grow} renderItem={(i) => ( - + ({ 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(); + } + }); +}); diff --git a/src/features/vcs/__tests__/compareFlow.test.ts b/src/features/vcs/__tests__/compareFlow.test.ts new file mode 100644 index 00000000..50e84ad2 --- /dev/null +++ b/src/features/vcs/__tests__/compareFlow.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/features/vcs/__tests__/folderCompare.test.tsx b/src/features/vcs/__tests__/folderCompare.test.tsx new file mode 100644 index 00000000..c1b80640 --- /dev/null +++ b/src/features/vcs/__tests__/folderCompare.test.tsx @@ -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(); + return { + ...real, + commands: { + ...real.commands, + vcsComparePaths: mocks.compare, + cancelRequest: mocks.cancel, + vcsCompareScope: mocks.scope, + }, + }; +}); +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: unknown }) =>
{JSON.stringify(input)}
, +})); +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( {}} />); + 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( + {}} + />, + ); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( + {}} />, + ); + await waitFor(() => expect(mocks.compare).toHaveBeenCalled()); + const request = mocks.compare.mock.calls[0]![3]; + view.unmount(); + expect(mocks.cancel).toHaveBeenCalledWith(request); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 832583c6..01582c32 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -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); }, }, { diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 3fbd5f58..c18a071e 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -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; + } +} diff --git a/src/features/vcs/comparePaths.ts b/src/features/vcs/comparePaths.ts new file mode 100644 index 00000000..2ebd9a9b --- /dev/null +++ b/src/features/vcs/comparePaths.ts @@ -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 { + 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; +} diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index 3cbe13ce..e0099acd 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -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) => ( + + )); +} + 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(null); + const [files, setFiles] = useState(null); + const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [active, setActive] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [expanded, setExpanded] = useState | null>(null); + const [current, setCurrent] = useState(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 => { + const list = (files ?? []).filter((f) => f.root === r.root); + const prefix = (nodes: FileTreeNode[]): FileTreeNode[] => + 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 ( current && open(current) }, + { + label: "Show Diff", + role: "default", + disabled: !current, + onClick: () => { + if (current) { + void openDiff(diffFor(current)); + onClose(); + } + }, + }, { label: "Close", role: "cancel" }, ]} > -
{files ? `${files.length} file${files.length === 1 ? "" : "s"} differ` : ""}
-
- {error ? ( -
{error}
- ) : !files ? ( -
- -
- ) : ( - f.path} - activeIndex={Math.min(active, Math.max(0, files.length - 1))} - onActiveChange={setActive} - rowHeight={22} - aria-label="Changed files" - className={styles.boxList} - empty={ - - No differences - - } - onActivate={(f, _i, e) => { - if (e.type === "click" && (e as unknown as MouseEvent).detail < 2) return; - open(f); + {error && ( +
+ {error} +
+ )} + {result?.skippedPaths.length ? ( +
{tx`Skipped content outside Git repositories: ${skippedLabel}`}
+ ) : null} + {allMissing ? ( +
+ {tx`No repository in the selection has revision ${from.label}`} +
+ ) : ( + result?.repositories + .filter((r) => r.error || r.missingRevision) + .map((r) => ( +
+ {r.missingRevision + ? tx`${repoName(r.root)} has no revision ${from.label}` + : tx`Cannot compare ${repoName(r.root)} against ${from.label}: ${r.error}`} +
+ )) + )} + {!files && !error ? ( +
+ +
+ ) : null} + {files && !files.length && successful > 0 ? ( +
+ {comparisonEmpty(from.label, successful)} +
+ ) : null} + {files && files.length > 0 ? ( +
+ > + 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) => ( - - + onActivate={(n) => n.file && setCurrent(n.file)} + aria-label="Changed files" + className={styles.comparisonTree} + renderNode={(n) => ( + + - {baseName(f.path)} + {n.name} - {dirName(f.path)} + {n.file ? ( + <> + {tx(n.file.kind)} + {n.file.added !== undefined && +{n.file.added}} + {n.file.removed !== undefined && −{n.file.removed}} + + ) : ( + {n.count} + )} )} /> - )} -
+ {input && ( +
+ +
+ )} +
+ ) : null}
); } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2a06b115..95a21800 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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}" }