mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 00:07:29 +00:00
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:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user