From 1716f3e062fbadab5b7e5dc7408077d8dedba2f5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:14:13 +0800 Subject: [PATCH] feat(diff): compare directories and synchronize entries (#661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two folders can now be compared from the project view (Compare Directories with two folders selected, or Compare With… on one folder, also in Find Action). The result tree marks entries as only on left, only on right, contents differ or identical, with a name filter and a show-identical toggle; double-click opens the existing diff editor, and files or whole subdirectories can be copied to either side (overwrite and delete ask first, extra files in the target are kept). Comparison runs in the background with progress and cancel, honours the project's exclusions, and works for remote projects through the same file commands. The comparison tree and diff preview are shared with the Git folder/revision comparison. Verified on macOS (subdirectories, Chinese names, binary files, copy both ways, delete, cancel on large files); Windows and Linux not run. Fixes #520 --- crates/ide-host/src/ops/fs.rs | 67 ++- crates/ide-host/src/rpc.rs | 23 + crates/ide-host/tests/rpc.rs | 33 ++ crates/ide-vfs/src/compare.rs | 505 ++++++++++++++++++ crates/ide-vfs/src/lib.rs | 4 + src-tauri/src/commands/fs.rs | 32 ++ src-tauri/src/commands/mod.rs | 3 + src/bindings.ts | 22 + src/features/project-tree/contextMenu.test.ts | 11 +- src/features/project-tree/contextMenu.ts | 16 +- src/features/vcs/ComparisonTree.tsx | 78 +++ src/features/vcs/DiffEditor.tsx | 23 +- src/features/vcs/DirectoryCompare.tsx | 291 ++++++++++ .../vcs/__tests__/diffToolbar.test.tsx | 15 + .../vcs/__tests__/directoryCompare.test.tsx | 179 +++++++ .../__tests__/directoryCompareActions.test.ts | 95 ++++ src/features/vcs/__tests__/fileTree.test.ts | 17 + src/features/vcs/common.module.css | 11 + src/features/vcs/compare.tsx | 72 ++- src/features/vcs/compareData.ts | 16 + src/features/vcs/fileTree.ts | 18 +- src/features/vcs/operations.tsx | 75 +-- src/platform/locales/text/zh.json | 33 +- 23 files changed, 1552 insertions(+), 87 deletions(-) create mode 100644 crates/ide-vfs/src/compare.rs create mode 100644 src/features/vcs/ComparisonTree.tsx create mode 100644 src/features/vcs/DirectoryCompare.tsx create mode 100644 src/features/vcs/__tests__/directoryCompare.test.tsx create mode 100644 src/features/vcs/__tests__/directoryCompareActions.test.ts create mode 100644 src/features/vcs/compareData.ts diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index 10681ca3..fb55f6f0 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -1,8 +1,73 @@ +use super::Channel; use crate::Host; -use ide_core::IdeResult; +use ide_core::{IdeResult, RequestId}; use ide_vfs::FileEntry; use std::path::PathBuf; +pub async fn entry_kind(host: &Host, path: PathBuf) -> IdeResult { + host.blocking(move || { + let meta = std::fs::metadata(path)?; + Ok(if meta.is_dir() { ide_vfs::EntryKind::Dir } else { ide_vfs::EntryKind::File }) + }) + .await +} + +pub async fn compare_directories( + host: &Host, + window: &str, + left: PathBuf, + right: PathBuf, + request: RequestId, + on_progress: Channel, +) -> IdeResult> { + let project = host.project(window)?; + let cancel = host.begin_request(request); + let result = host + .blocking(move || { + let last = std::cell::Cell::new(std::time::Instant::now() - std::time::Duration::from_secs(1)); + ide_vfs::compare_directories(&left, &right, Some(&project.root), &project.exclude_globs(), &cancel, |p| { + // Bound IPC traffic even when hashing multi-gigabyte files. + if last.get().elapsed() >= std::time::Duration::from_millis(80) || p.total == Some(p.compared) { + let _ = on_progress.send(p); + last.set(std::time::Instant::now()); + } + }) + }) + .await; + host.end_request(request); + result +} + +pub async fn copy_merge( + host: &Host, + window: &str, + source_root: PathBuf, + destination_root: PathBuf, + path: PathBuf, + overwrite: bool, +) -> IdeResult<()> { + if path.as_os_str().is_empty() || path.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { + return Err(ide_core::IdeError::invalid("expected a relative comparison entry path")); + } + let project = host.project(window)?; + host.blocking(move || { + let from = source_root.join(&path); + let to = destination_root.join(&path); + if overwrite { + project.history.record_deletion(&to, DELETE_SNAPSHOT_LIMIT); + } + ide_vfs::copy_merge( + &from, + &to, + overwrite, + Some(&project.root), + &project.exclude_globs(), + (&source_root, &destination_root), + ) + }) + .await +} + /// Files of a deleted folder snapshotted in Local History. const DELETE_SNAPSHOT_LIMIT: usize = 2000; diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index e34db1e4..78605b1e 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -179,6 +179,7 @@ impl Dispatcher { }) .add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::("id")?)) }) .add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) }) + .add("fs_entry_kind", |c| async move { ops::fs::entry_kind(&c.host, c.arg("path")?).await }) .add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) }) .add("fs_create_file_bytes", |c| async move { ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await @@ -190,6 +191,28 @@ impl Dispatcher { }) .add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await }) .add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await }) + .add("fs_copy_merge", |c| async move { + ops::fs::copy_merge( + &c.host, + &c.window, + c.arg("sourceRoot")?, + c.arg("destinationRoot")?, + c.arg("path")?, + c.arg("overwrite")?, + ) + .await + }) + .add("fs_compare_directories", |c| async move { + ops::fs::compare_directories( + &c.host, + &c.window, + c.arg("left")?, + c.arg("right")?, + c.arg("request")?, + c.sender("onProgress")?, + ) + .await + }) .add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await }) .add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await }) .add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) }) diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index d16f408f..58e4ede3 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -94,6 +94,39 @@ async fn serves_projects_files_documents_search_and_runs() { let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap(); assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}"); + // Directory comparisons, streamed progress and confirmed copies share the remote filesystem. + let left = project.join("compare-left"); + let right = project.join("compare-right"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::create_dir_all(&right).unwrap(); + std::fs::write(left.join("中文.bin"), [0, 255, 1]).unwrap(); + std::fs::write(right.join("中文.bin"), [0, 254, 1]).unwrap(); + assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left})).await.unwrap(), "dir"); + assert_eq!(invoke(&peer, "fs_entry_kind", json!({"path": left.join("中文.bin")})).await.unwrap(), "file"); + let compare_args = + |request| json!({"left": left, "right": right, "request": request, "onProgress": {"$channel": 520}}); + let rows = invoke(&peer, "fs_compare_directories", compare_args(520)).await.unwrap(); + assert_eq!(rows[0]["status"], "different"); + assert!(client.channels.lock().iter().any(|c| c.id == 520 && c.data["compared"] == 1)); + let copy_args = + |overwrite| json!({"sourceRoot": left, "destinationRoot": right, "path": "中文.bin", "overwrite": overwrite}); + let error = invoke(&peer, "fs_copy_merge", copy_args(false)).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::AlreadyExists); + invoke(&peer, "fs_copy_merge", copy_args(true)).await.unwrap(); + let rows = invoke(&peer, "fs_compare_directories", compare_args(521)).await.unwrap(); + assert_eq!(rows[0]["status"], "equal"); + invoke(&peer, "cancel_request", json!({"id": 522})).await.unwrap(); + let error = invoke(&peer, "fs_compare_directories", compare_args(522)).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Cancelled); + let error = invoke( + &peer, + "fs_copy_merge", + json!({"sourceRoot": left, "destinationRoot": right, "path": "../escape", "overwrite": true}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + // JVM icons use the same read-only protocol as local windows. let java = project.join("src/Foo.java"); let kotlin = project.join("src/Singleton.kt"); diff --git a/crates/ide-vfs/src/compare.rs b/crates/ide-vfs/src/compare.rs new file mode 100644 index 00000000..8f29b100 --- /dev/null +++ b/crates/ide-vfs/src/compare.rs @@ -0,0 +1,505 @@ +//! Disk comparisons use the same excludes and layered ignore files as the project tree. +use crate::{EntryKind, ExcludeMatcher, rules::GitignoreStack}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum DirectoryCompareStatus { + LeftOnly, + RightOnly, + Different, + Equal, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryCompareEntry { + pub path: String, + pub status: DirectoryCompareStatus, + pub left_kind: Option, + pub right_kind: Option, + #[specta(optional)] + pub left_link: Option, + #[specta(optional)] + pub right_link: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryCompareProgress { + pub scanned: u32, + pub compared: u32, + pub total: Option, + pub bytes_read: f64, + pub path: String, +} + +struct Entry { + kind: EntryKind, + size: u64, + link: Option, +} + +fn check(cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { Err(IdeError::cancelled()) } else { Ok(()) } +} + +struct Rules { + base: PathBuf, + excludes: ExcludeMatcher, + ignore: GitignoreStack, +} + +impl Rules { + fn new(root: &Path, project: Option<&Path>, excludes: &[String]) -> Self { + Self { + base: project.filter(|p| root.starts_with(p)).unwrap_or(root).to_path_buf(), + excludes: ExcludeMatcher::new(excludes), + ignore: GitignoreStack::new(root), + } + } + + fn excluded(&self, path: &Path, dir: bool) -> bool { + path.file_name().is_some_and(|n| n == ".git") + || self.excludes.is_excluded(path.strip_prefix(&self.base).unwrap_or(path)) + || self.ignore.is_ignored(path, dir) + } +} + +fn scan( + root: &Path, + rules: &Rules, + cancel: &CancellationToken, + progress: &mut DirectoryCompareProgress, + emit: &impl Fn(DirectoryCompareProgress), +) -> IdeResult> { + if !fs::metadata(root)?.is_dir() { + return Err(IdeError::invalid(format!("{} is not a directory", root.display()))); + } + let mut result = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + check(cancel)?; + for item in fs::read_dir(dir)? { + check(cancel)?; + let path = item?.path(); + let meta = fs::symlink_metadata(&path)?; + if rules.excluded(&path, meta.is_dir()) { + continue; + } + let kind = if meta.file_type().is_symlink() { + EntryKind::Symlink + } else if meta.is_dir() { + pending.push(path.clone()); + EntryKind::Dir + } else if meta.is_file() { + EntryKind::File + } else { + continue; + }; + let rel = path.strip_prefix(root).map_err(|e| IdeError::internal(e.to_string()))?; + let rel = ide_core::ipc_path::to_ipc(rel); + let link = if kind == EntryKind::Symlink { Some(fs::read_link(&path)?) } else { None }; + result.insert(rel.clone(), Entry { kind, size: meta.len(), link }); + progress.scanned += 1; + progress.path = rel; + emit(progress.clone()); + } + } + Ok(result) +} + +fn hash( + path: &Path, + cancel: &CancellationToken, + progress: &mut DirectoryCompareProgress, + emit: &impl Fn(DirectoryCompareProgress), +) -> IdeResult { + let mut file = fs::File::open(path)?; + let mut hash = gix::hash::hasher(gix::hash::Kind::Sha256); + let mut buffer = [0; 64 * 1024]; + loop { + check(cancel)?; + let len = file.read(&mut buffer)?; + if len == 0 { + break; + } + hash.update(&buffer[..len]); + progress.bytes_read += len as f64; + emit(progress.clone()); + } + hash.try_finalize().map_err(|e| IdeError::internal(e.to_string())) +} + +/// Size checks avoid reading unequal files. Equal-sized files are hashed in bounded, +/// cancellable chunks; symlinks compare their link text and are never traversed. +pub fn compare_directories( + left: &Path, + right: &Path, + project: Option<&Path>, + excludes: &[String], + cancel: &CancellationToken, + emit: impl Fn(DirectoryCompareProgress), +) -> IdeResult> { + check(cancel)?; + let mut progress = DirectoryCompareProgress::default(); + emit(progress.clone()); + let l = scan(left, &Rules::new(left, project, excludes), cancel, &mut progress, &emit)?; + let r = scan(right, &Rules::new(right, project, excludes), cancel, &mut progress, &emit)?; + let mut paths: Vec<_> = l.keys().chain(r.keys()).cloned().collect(); + paths.sort(); + paths.dedup(); + progress.total = Some(paths.len() as u32); + let mut rows = BTreeMap::new(); + for path in paths { + check(cancel)?; + progress.path.clone_from(&path); + let status = match (l.get(&path), r.get(&path)) { + (Some(_), None) => DirectoryCompareStatus::LeftOnly, + (None, Some(_)) => DirectoryCompareStatus::RightOnly, + (Some(a), Some(b)) => { + let equal = a.kind == b.kind + && match a.kind { + EntryKind::Dir => true, + EntryKind::Symlink => a.link == b.link, + EntryKind::File => { + a.size == b.size + && hash(&left.join(&path), cancel, &mut progress, &emit)? + == hash(&right.join(&path), cancel, &mut progress, &emit)? + } + }; + if equal { DirectoryCompareStatus::Equal } else { DirectoryCompareStatus::Different } + } + _ => unreachable!(), + }; + rows.insert( + path.clone(), + DirectoryCompareEntry { + path: path.clone(), + status, + left_kind: l.get(&path).map(|e| e.kind), + right_kind: r.get(&path).map(|e| e.kind), + left_link: l.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()), + right_link: r.get(&path).and_then(|e| e.link.as_ref()).map(|p| p.to_string_lossy().into_owned()), + }, + ); + progress.compared += 1; + emit(progress.clone()); + } + let changed: Vec<_> = + rows.values().filter(|r| r.status != DirectoryCompareStatus::Equal).map(|r| r.path.clone()).collect(); + for path in changed { + for parent in Path::new(&path).ancestors().skip(1) { + if let Some(row) = rows.get_mut(&ide_core::ipc_path::to_ipc(parent)) + && row.status == DirectoryCompareStatus::Equal + { + row.status = DirectoryCompareStatus::Different; + } + } + } + check(cancel)?; + Ok(rows.into_values().collect()) +} + +/// Merges a subtree without following links inside the comparison roots. Existing files +/// require explicit overwrite; directory merges preserve destination-only entries. +pub fn copy_merge( + from: &Path, + to: &Path, + overwrite: bool, + project: Option<&Path>, + excludes: &[String], + roots: (&Path, &Path), +) -> IdeResult<()> { + fn resolve(path: &Path) -> IdeResult { + if path.exists() { + return Ok(dunce::canonicalize(path)?); + } + let parent = path.parent().ok_or_else(|| IdeError::invalid("copy requires an absolute path"))?; + Ok(resolve(parent)?.join(path.file_name().ok_or_else(|| IdeError::invalid("missing file name"))?)) + } + if !from.is_absolute() || !to.is_absolute() { + return Err(IdeError::invalid("copy requires absolute paths")); + } + if !from.starts_with(roots.0) || !to.starts_with(roots.1) { + return Err(IdeError::invalid("copy paths must belong to the comparison roots")); + } + let source = resolve(from)?; + let target = resolve(to)?; + if source.starts_with(&target) || target.starts_with(&source) { + return Err(IdeError::invalid("cannot copy overlapping paths")); + } + for parent in to.ancestors().skip(1) { + // The user explicitly selected this root; aliases above it (e.g. /tmp + // on macOS) are valid. Links inside the copied subtree are not followed. + if to == roots.1 || parent == roots.1 { + break; + } + if fs::symlink_metadata(parent).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(IdeError::invalid("cannot copy through a destination symlink")); + } + } + let source_base = project.filter(|p| from.starts_with(p)).or(Some(roots.0)); + let target_base = project.filter(|p| to.starts_with(p)).or(Some(roots.1)); + let rules = Rules::new(from, source_base, excludes); + let target_rules = Rules::new(to, target_base, excludes); + let mut pending = vec![(from.to_path_buf(), to.to_path_buf())]; + while let Some((src, dst)) = pending.pop() { + let meta = fs::symlink_metadata(&src)?; + if rules.excluded(&src, meta.is_dir()) || target_rules.excluded(&dst, meta.is_dir()) { + continue; + } + let existing = fs::symlink_metadata(&dst).ok(); + let merge = meta.is_dir() && existing.as_ref().is_some_and(|m| m.is_dir()); + if existing.is_some() && !merge && !overwrite { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into()); + } + if meta.is_dir() { + if existing.is_some() && !merge { + crate::delete(&dst)?; + } + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(&src)? { + let entry = entry?; + pending.push((entry.path(), dst.join(entry.file_name()))); + } + } else { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + // Stage the complete file before touching its destination. + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let temp = dst.with_file_name(format!(".openidea-copy-{}-{id}", std::process::id())); + let mut staged = false; + let result: IdeResult<()> = (|| { + #[cfg(windows)] + if meta.file_type().is_symlink() { + use std::os::windows::fs::{FileTypeExt, symlink_dir, symlink_file}; + let target = fs::read_link(&src)?; + if meta.file_type().is_symlink_dir() { + symlink_dir(target, &temp)?; + } else { + symlink_file(target, &temp)?; + } + } else { + crate::copy(&src, &temp)?; + } + #[cfg(not(windows))] + crate::copy(&src, &temp)?; + staged = true; + // Recheck after staging: another writer may have created the target. + if fs::symlink_metadata(&dst).is_ok() { + if !overwrite { + return Err( + std::io::Error::new(std::io::ErrorKind::AlreadyExists, dst.display().to_string()).into() + ); + } + crate::delete(&dst)?; + } + fs::rename(&temp, &dst)?; + Ok(()) + })(); + if let Err(error) = &result + && (staged || error.kind != ide_core::error::ErrorKind::AlreadyExists) + { + let _ = crate::delete_permanently(&temp); + } + result?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::error::ErrorKind; + + fn put(root: &Path, path: &str, content: &[u8]) { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn reports_add_delete_modify_equal_binary_unicode_empty_and_type_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "子目录/仅左.txt", b"left"); + put(&right, "only-right", b"right"); + for (path, a, b) in [ + ("same", b"same".as_slice(), b"same".as_slice()), + ("binary", &[0, 255, 10], &[0, 254, 10]), + ("size", b"a", b"bb"), + ("conflict", b"file", b""), + ] { + put(&left, path, a); + if path == "conflict" { + put(&right, "conflict/child", b"child"); + } else { + put(&right, path, b); + } + } + fs::create_dir(left.join("empty")).unwrap(); + fs::create_dir(right.join("empty")).unwrap(); + let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap(); + let status = |path| rows.iter().find(|r| r.path == path).unwrap().status; + assert_eq!(status("same"), DirectoryCompareStatus::Equal); + assert_eq!(status("empty"), DirectoryCompareStatus::Equal); + for path in ["binary", "size", "conflict"] { + assert_eq!(status(path), DirectoryCompareStatus::Different); + } + assert_eq!(status("子目录/仅左.txt"), DirectoryCompareStatus::LeftOnly); + assert_eq!(status("only-right"), DirectoryCompareStatus::RightOnly); + assert_eq!(status("conflict/child"), DirectoryCompareStatus::RightOnly); + } + + #[test] + fn applies_project_anchored_excludes_and_layered_ignores() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + for root in [&left, &right] { + put(root, "nested/node_modules/x", b"ignored"); + put(root, "target/x", b"ignored"); + put(root, ".git/config", b"ignored"); + put(root, "generated/x", b"ignored"); + put(root, ".gitignore", b"*.log\n"); + put(root, "nested/debug.log", b"ignored"); + put(root, "kept", b"kept"); + } + let excludes = ["**/node_modules", "**/target", "/left/generated", "/right/generated"].map(String::from); + let rows = + compare_directories(&left, &right, Some(tmp.path()), &excludes, &CancellationToken::new(), |_| {}).unwrap(); + assert!( + rows.iter() + .all(|r| !["node_modules", "target", ".git/", "generated", ".log"].iter().any(|p| r.path.contains(p))) + ); + assert!(rows.iter().any(|r| r.path == "kept")); + } + + #[test] + fn cancellation_stops_before_scan_during_scan_and_during_hashing() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "big", &vec![1; 1024 * 1024]); + put(&right, "big", &vec![1; 1024 * 1024]); + for phase in 0..3 { + let cancel = CancellationToken::new(); + if phase == 0 { + cancel.cancel(); + } + let result = compare_directories(&left, &right, None, &[], &cancel, |p| { + if phase == 1 && p.scanned > 0 || phase == 2 && p.bytes_read > 0.0 { + cancel.cancel(); + } + }); + assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled); + } + } + + #[test] + fn size_mismatches_are_not_hashed_and_directory_status_tracks_children() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "nested/file", b"a"); + put(&right, "nested/file", b"ab"); + let bytes = std::cell::Cell::new(0.0); + let rows = + compare_directories(&left, &right, None, &[], &CancellationToken::new(), |p| bytes.set(p.bytes_read)) + .unwrap(); + assert_eq!(bytes.get(), 0.0); + assert!(rows.iter().all(|r| r.status == DirectoryCompareStatus::Different)); + } + + #[test] + fn merges_copies_with_explicit_overwrite_and_keeps_destination_only_files() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "file", b"new"); + put(&left, "target/ignored", b"ignored"); + put(&right, "file", b"old"); + put(&right, "keep", b"keep"); + assert_eq!( + copy_merge(&left, &right, false, None, &["target".into()], (&left, &right)).unwrap_err().kind, + ErrorKind::AlreadyExists + ); + assert_eq!(fs::read(right.join("file")).unwrap(), b"old"); + copy_merge(&left, &right, true, None, &["target".into()], (&left, &right)).unwrap(); + assert_eq!(fs::read(right.join("file")).unwrap(), b"new"); + assert_eq!(fs::read(right.join("keep")).unwrap(), b"keep"); + assert!(!right.join("target").exists()); + for (from, to) in [(&left, left.clone()), (&left, left.join("child")), (&right, tmp.path().to_path_buf())] { + assert_eq!( + copy_merge(from, &to, true, None, &[], (tmp.path(), tmp.path())).unwrap_err().kind, + ErrorKind::InvalidInput + ); + } + } + + #[test] + fn subtree_copy_keeps_excludes_anchored_to_each_comparison_root() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "sub/build/keep", b"included below sub"); + put(&left, "sub/protected", b"do not overwrite"); + put(&right, "sub/protected", b"keep destination"); + put(&right, ".gitignore", b"sub/protected\n"); + copy_merge(&left.join("sub"), &right.join("sub"), true, None, &["/build".into()], (&left, &right)).unwrap(); + assert_eq!(fs::read(right.join("sub/build/keep")).unwrap(), b"included below sub"); + assert_eq!(fs::read(right.join("sub/protected")).unwrap(), b"keep destination"); + } + + #[test] + fn confirmed_copy_replaces_file_directory_type_conflicts() { + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + put(&left, "file", b"file"); + put(&right, "file/child", b"old directory"); + put(&left, "tree/child", b"new directory"); + put(&right, "tree", b"old file"); + for path in ["file", "tree"] { + copy_merge(&left.join(path), &right.join(path), true, None, &[], (&left, &right)).unwrap(); + } + assert_eq!(fs::read(right.join("file")).unwrap(), b"file"); + assert_eq!(fs::read(right.join("tree/child")).unwrap(), b"new directory"); + } + + #[cfg(unix)] + #[test] + fn symlinks_compare_link_text_and_are_not_followed_when_copying() { + use std::os::unix::fs::symlink; + let tmp = tempfile::tempdir().unwrap(); + let left = tmp.path().join("left"); + let right = tmp.path().join("right"); + fs::create_dir(&left).unwrap(); + fs::create_dir(&right).unwrap(); + symlink(".", left.join("loop")).unwrap(); + symlink("missing", right.join("loop")).unwrap(); + let rows = compare_directories(&left, &right, None, &[], &CancellationToken::new(), |_| {}).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, DirectoryCompareStatus::Different); + copy_merge(&left.join("loop"), &right.join("copied"), false, None, &[], (&left, &right)).unwrap(); + assert_eq!(fs::read_link(right.join("copied")).unwrap(), PathBuf::from(".")); + symlink(&right, tmp.path().join("alias")).unwrap(); + assert_eq!( + copy_merge(&left.join("loop"), &tmp.path().join("alias/new"), true, None, &[], (tmp.path(), tmp.path())) + .unwrap_err() + .kind, + ErrorKind::InvalidInput + ); + } +} diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index 55c28b71..6ac24e21 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -2,6 +2,7 @@ mod archive; mod bytes; +mod compare; mod encoding; mod ops; mod rules; @@ -20,6 +21,9 @@ pub use archive::{ read_archive_entry, split_archive_path, }; pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range}; +pub use compare::{ + DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge, +}; use encoding::SNIFF_LEN; pub use encoding::{ LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode, diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 5ff8d0f1..efa29a9b 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -5,6 +5,38 @@ use ide_vfs::FileEntry; use std::path::PathBuf; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn fs_entry_kind(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + fs::entry_kind(&state.host, path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn fs_compare_directories( + window: Window, + state: State<'_, AppState>, + left: PathBuf, + right: PathBuf, + request: ide_core::RequestId, + on_progress: tauri::ipc::Channel, +) -> IdeResult> { + fs::compare_directories(&state.host, window.label(), left, right, request, super::sender(on_progress)).await +} + +#[tauri::command] +#[specta::specta] +pub async fn fs_copy_merge( + window: Window, + state: State<'_, AppState>, + source_root: PathBuf, + destination_root: PathBuf, + path: PathBuf, + overwrite: bool, +) -> IdeResult<()> { + fs::copy_merge(&state.host, window.label(), source_root, destination_root, path, overwrite).await +} + #[tauri::command] #[specta::specta] pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ab121db6..c71ce840 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -126,6 +126,7 @@ pub fn builder() -> tauri_specta::Builder { notebook::notebook_interrupt, notebook::notebook_restart, fs::fs_read_dir, + fs::fs_entry_kind, bytes::fs_probe, bytes::fs_read_range, bytes::fs_read_image, @@ -136,6 +137,8 @@ pub fn builder() -> tauri_specta::Builder { fs::fs_set_writable, fs::fs_delete, fs::fs_copy, + fs::fs_copy_merge, + fs::fs_compare_directories, fs::fs_read_text, clipboard::clipboard_file_list, clipboard::clipboard_has_files, diff --git a/src/bindings.ts b/src/bindings.ts index 79eba7f8..97ce0528 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -145,6 +145,7 @@ export const commands = { notebookInterrupt: (id: string) => typedError(__TAURI_INVOKE("notebook_interrupt", { id })), notebookRestart: (id: string) => typedError(__TAURI_INVOKE("notebook_restart", { id })), fsReadDir: (path: string) => typedError(__TAURI_INVOKE("fs_read_dir", { path })), + fsEntryKind: (path: string) => typedError(__TAURI_INVOKE("fs_entry_kind", { path })), /** Size and text/binary classification, from the file's head only. */ fsProbe: (path: string) => typedError(__TAURI_INVOKE("fs_probe", { path })), /** Up to `len` bytes from `offset` (capped per call), base64-encoded. */ @@ -167,6 +168,8 @@ export const commands = { /** Moving to the trash can take a while (large trees): kept off the main thread. */ fsDelete: (path: string) => typedError(__TAURI_INVOKE("fs_delete", { path })), fsCopy: (from: string, to: string) => typedError(__TAURI_INVOKE("fs_copy", { from, to })), + fsCopyMerge: (sourceRoot: string, destinationRoot: string, path: string, overwrite: boolean) => typedError(__TAURI_INVOKE("fs_copy_merge", { sourceRoot, destinationRoot, path, overwrite })), + fsCompareDirectories: (left: string, right: string, request: RequestId, onProgress: Channel) => typedError(__TAURI_INVOKE("fs_compare_directories", { left, right, request, onProgress })), /** * Reads a text file without opening it as a document (previews, quick * definition). Prefers the unsaved buffer when the file is open. @@ -2513,6 +2516,25 @@ export type Diagnostic = { server?: string | null, }; +export type DirectoryCompareEntry = { + path: string, + status: DirectoryCompareStatus, + leftKind: EntryKind | null, + rightKind: EntryKind | null, + leftLink?: string | null, + rightLink?: string | null, +}; + +export type DirectoryCompareProgress = { + scanned: number, + compared: number, + total: number | null, + bytesRead: number | null, + path: string, +}; + +export type DirectoryCompareStatus = "leftOnly" | "rightOnly" | "different" | "equal"; + /** Changes under a folder (Show History on a directory). */ export type DirectoryHistory = { /** Snapshots of files under the folder and labels, newest first. */ diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index 622ddf3b..d3f18a6d 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -40,6 +40,7 @@ const OTHER_ACTIONS = [ "localHistory.putLabel", "diff.compareWith", "diff.compareFiles", + "diff.compareDirectories", "vcs.commit", "vcs.addToVcs", "vcs.annotate", @@ -75,6 +76,14 @@ afterEach(() => { }); describe("project view context menu", () => { + it("offers directory comparison only for two folders and Compare With for a single folder", () => { + expect(labels(contextMenuItems([node("/p/left", true), node("/p/right", true)]))).toContain("Compare Directories"); + expect(labels(contextMenuItems([node("/p/left", true)]))).toContain("Compare With…"); + const mixed = labels(contextMenuItems([node("/p/left", true), node("/p/a.txt")])); + for (const label of ["Compare Directories", "Compare Files", "Compare With…"]) expect(mixed).not.toContain(label); + expect(labels(contextMenuItems([node("/p/a.txt"), node("/p/b.txt")]))).toContain("Compare Files"); + }); + it("follows IDEA's order for a file", () => { const items = contextMenuItems([node(`${ROOT}/src/a.py`)]); expect(labels(items)).toEqual([ @@ -161,7 +170,7 @@ describe("project view context menu", () => { const l = labels(contextMenuItems([node(`${ROOT}/src`, true)])); expect(l).toContain("Mark Directory as"); expect(sub(contextMenuItems([node(`${ROOT}/src`, true)]), "Refactor")).toContain("Move Directory…"); - for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Compare With…", "Run 'a'"]) + for (const fileOnly of ["Find Usages", "Reformat Code", "Optimize Imports", "Run 'a'"]) expect(l).not.toContain(fileOnly); }); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 1b35cf48..9775c9b4 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -361,7 +361,17 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { ); } - // IDEA: Compare With… (one file) / Compare Files (two files). + // Compare selections of the same kind; mixed file/folder selections cannot apply. + const compareArg = single?.dir ? { path: single.path, directory: true } : single?.path; + if (nodes.length === 2 && nodes.every((n) => n.dir && !n.placeholder)) + push( + delegated( + "diff.compareDirectories", + nodes.map((n) => n.path), + "Compare Directories", + { icon: "diff" }, + ), + ); if (files.length === 2 && nodes.length === 2) push( delegated( @@ -371,9 +381,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { { icon: "diff" }, ), ); - else if (singleFile) + else if (single && !single.placeholder) push( - delegated("diff.compareWith", singleFile.path, "Compare With…", { + delegated("diff.compareWith", compareArg, "Compare With…", { icon: "diff", shortcut: keyLabel("project.compareWith"), }), diff --git a/src/features/vcs/ComparisonTree.tsx b/src/features/vcs/ComparisonTree.tsx new file mode 100644 index 00000000..e2fd952c --- /dev/null +++ b/src/features/vcs/ComparisonTree.tsx @@ -0,0 +1,78 @@ +import { useState, type ReactNode } from "react"; +import { Icon, Tree, fileIconName } from "@/ui"; +import { DiffEditor } from "./DiffEditor"; +import { diffInput, openDiff, type DiffData } from "./diffInput"; +import { dirKeys, type FileTreeNode } from "./fileTree"; +import styles from "./common.module.css"; + +/** Shared changes tree and on-demand diff surface for revisions and disk folders. */ +export function ComparisonTree({ + tree, + current, + onSelect, + diffFor, + renderDetails, + ariaLabel, + openOnActivate = false, + getColor, + onOpen, +}: { + tree: FileTreeNode[]; + current: F | null; + onSelect: (entry: F | null) => void; + diffFor: (entry: F) => DiffData | null; + renderDetails: (node: FileTreeNode) => ReactNode; + ariaLabel: string; + openOnActivate?: boolean; + getColor?: (entry: F) => string | undefined; + onOpen?: () => void; +}) { + const [selected, setSelected] = useState>(new Set()); + const [expanded, setExpanded] = useState | null>(null); + const data = current && diffFor(current); + const input = data ? diffInput(data) : null; + return ( +
+ > + 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); + onSelect(lead?.file ?? lead?.directory ?? null); + }} + onActivate={(n) => { + const entry = n.file ?? n.directory; + if (!entry) return; + onSelect(entry); + const diff = diffFor(entry); + if (openOnActivate && diff) { + void openDiff(diff); + onOpen?.(); + } + }} + aria-label={ariaLabel} + className={styles.comparisonTree} + renderNode={(n) => ( + + + + {n.name} + + {renderDetails(n)} + + )} + /> + {input && ( +
+ +
+ )} +
+ ); +} diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 49a2dc67..f44a47d0 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -200,6 +200,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { const data = input.data as DiffData; const { path, left, right } = data; const [attached, setAttached] = useState(null); + const [binary, setBinary] = useState(null); /** Text of the sides that are not live documents. */ const [statics, setStatics] = useState<{ a: string | null; b: string | null } | null>(null); const [error, setError] = useState(null); @@ -263,15 +264,18 @@ export function DiffEditor({ input }: { input: EditorInput }) { const reload = useCallback(async () => { if (!attached) return; try { - const [a, b] = await Promise.all([ + const paths = [sideFile(left, path), sideFile(right, path)].filter((p): p is string => !!p); + const [a, b, probes] = await Promise.all([ attached.a ? null : loadSide(path, left), attached.b ? null : loadSide(path, right), + Promise.all(paths.map((p) => call(commands.fsProbe(p)).catch(() => null))), ]); const prev = staticsRef.current; if (!prev || prev.a !== a || prev.b !== b) { staticsRef.current = { a, b }; setStatics({ a, b }); } + setBinary(probes.some((p) => p?.binary)); setError(null); } catch (e) { setError(errorMessage(e)); @@ -311,9 +315,9 @@ export function DiffEditor({ input }: { input: EditorInput }) { left.rev === "HEAD" && !left.path && isWorktree(right); - const editableA = !!attached?.a && !attached.a.readOnly; - const editableB = !!attached?.b && !attached.b.readOnly; - const ready = !!attached && !!statics && lang !== null; + const editableA = !binary && !!attached?.a && !attached.a.readOnly; + const editableB = !binary && !!attached?.b && !attached.b.readOnly; + const ready = !!attached && !!statics && lang !== null && binary !== null; // Read when the views are built; toggling it later goes through `setSync`. const syncScroll = useRef(opts.syncScroll); @@ -983,7 +987,8 @@ export function DiffEditor({ input }: { input: EditorInput }) { void reload()} /> - {!ready + {binary && tx("Binary content")} + {!ready || binary ? "" : chunkInfo.count === 0 ? identical @@ -1047,7 +1052,11 @@ export function DiffEditor({ input }: { input: EditorInput }) { /> )} - {error ? ( + {binary ? ( +
+ Binary files cannot be shown as text +
+ ) : error ? (
{error}
) : !ready ? (
@@ -1061,7 +1070,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { opts.highlight === "none" && styles.noHighlight, opts.mode === "split" && (opts.alignChanges ? !opts.syncScroll && styles.unsynced : styles.unaligned), )} - hidden={!!error || !ready} + hidden={!!binary || !!error || !ready} aria-label={diffTitle(data)} role="region" onContextMenu={onContextMenu} diff --git a/src/features/vcs/DirectoryCompare.tsx b/src/features/vcs/DirectoryCompare.tsx new file mode 100644 index 00000000..b70e2aca --- /dev/null +++ b/src/features/vcs/DirectoryCompare.tsx @@ -0,0 +1,291 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Channel } from "@tauri-apps/api/core"; +import type { DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus } from "@/bindings"; +import { call, commands, emitLocalIdeEvent, IpcError, isCancelled, nextRequestId } from "@/platform/ipc"; +import { notifyFileOperation } from "@/platform/fileOps"; +import { tx } from "@/platform/i18n"; +import { Button, Checkbox, Dialog, Input, Spinner, T } from "@/ui"; +import { upsertTask } from "@/workbench/tasks"; +import { ComparisonTree } from "./ComparisonTree"; +import { buildFileTree } from "./fileTree"; +import { confirmDialog, openDialog } from "./dialogs"; +import { openDiff, type DiffData, type DiffSide } from "./diffInput"; +import { compareFilesData } from "./compareData"; +import styles from "./common.module.css"; + +const STATUS: Record = { + leftOnly: "Only on left", + rightOnly: "Only on right", + different: "Contents differ", + equal: "Identical", +}; +const join = (root: string, path: string) => `${root.replace(/\/$/, "")}/${path}`; +const directory = (e: DirectoryCompareEntry) => e.leftKind === "dir" || e.rightKind === "dir"; + +export function directoryDiff(left: string, right: string, entry: DirectoryCompareEntry): DiffData | null { + if (directory(entry)) return null; + const data = compareFilesData(join(left, entry.path), join(right, entry.path)); + const missing = (): DiffSide => ({ type: "text", text: "", label: tx("(does not exist)") }); + return { + ...data, + title: entry.path, + left: + entry.leftKind === "symlink" + ? { type: "text", text: entry.leftLink ?? "", label: data.left.label ?? join(left, entry.path) } + : entry.leftKind + ? data.left + : missing(), + right: + entry.rightKind === "symlink" + ? { type: "text", text: entry.rightLink ?? "", label: data.right.label ?? join(right, entry.path) } + : entry.rightKind + ? data.right + : missing(), + }; +} + +export function showDirectoryComparison(left: string, right: string) { + if (left === right) return; + openDialog((close) => ); +} + +export function DirectoryCompareDialog({ left, right, onClose }: { left: string; right: string; onClose: () => void }) { + const [entries, setEntries] = useState([]); + const [current, setCurrent] = useState(null); + const [showEqual, setShowEqual] = useState(false); + const [filter, setFilter] = useState(""); + const [generation, setGeneration] = useState(0); + const [busy, setBusy] = useState(true); + const [operating, setOperating] = useState(false); + const [error, setError] = useState(null); + const [operationError, setOperationError] = useState(null); + const [progress, setProgress] = useState(null); + const [cancelled, setCancelled] = useState(false); + const cancel = useRef<() => void>(() => {}); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + useEffect(() => { + let alive = true; + let pending = true; + const request = nextRequestId(); + const task = { + id: `directory-compare-${request}`, + title: tx("Comparing directories"), + message: null as string | null, + fraction: null as number | null, + cancel: () => { + void commands.cancelRequest(request); + }, + }; + cancel.current = task.cancel; + upsertTask({ ...task, done: false }); + const channel = new Channel(); + channel.onmessage = (p) => { + if (!alive) return; + setProgress(p); + task.message = p.path; + task.fraction = p.total ? p.compared / p.total : null; + upsertTask({ ...task, done: false }); + }; + void call(commands.fsCompareDirectories(left, right, request, channel)) + .then((rows) => { + if (!alive) return; + setEntries(rows); + setCurrent((prev) => rows.find((e) => e.path === prev?.path) ?? null); + }) + .catch((e) => { + if (!alive) return; + if (isCancelled(e)) setCancelled(true); + else setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + pending = false; + upsertTask({ ...task, done: true }); + if (alive) setBusy(false); + }); + return () => { + alive = false; + if (pending) task.cancel(); + upsertTask({ ...task, done: true }); + }; + }, [left, right, generation]); + + const visible = useMemo(() => { + const paths = new Set(); + const query = filter.trim().toLocaleLowerCase(); + for (const e of entries) { + if ((!showEqual && e.status === "equal") || !e.path.toLocaleLowerCase().includes(query)) continue; + let path = e.path; + while (path) { + paths.add(path); + const slash = path.lastIndexOf("/"); + path = slash < 0 ? "" : path.slice(0, slash); + } + } + return entries.filter((e) => paths.has(e.path)); + }, [entries, showEqual, filter]); + const tree = useMemo(() => buildFileTree(visible, (e) => e.path, directory), [visible]); + const selected = cancelled || error ? null : (visible.find((e) => e.path === current?.path) ?? null); + const diff = selected && directoryDiff(left, right, selected); + const refresh = () => { + setBusy(true); + setCancelled(false); + setError(null); + setProgress(null); + setGeneration((g) => g + 1); + }; + + const operate = async (side: "left" | "right" | "delete") => { + if (!selected || busy || operating) return; + setOperating(true); + setOperationError(null); + let attempted = false; + const targetRoot = side === "left" ? left : right; + try { + if (side === "delete") { + if (!!selected.leftKind === !!selected.rightKind) return; + const path = join(selected.leftKind ? left : right, selected.path); + if (!(await confirmDialog({ title: "Delete", message: tx`Delete ${path}?`, okLabel: "Delete", danger: true }))) + return; + attempted = true; + await call(commands.fsDelete(path)); + notifyFileOperation({ kind: "deleted", paths: [path] }); + emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [path] }); + } else { + const sourceRoot = side === "left" ? right : left; + const to = join(targetRoot, selected.path); + try { + attempted = true; + await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, false)); + } catch (e) { + if (!(e instanceof IpcError) || e.kind !== "alreadyExists") throw e; + if ( + !(await confirmDialog({ + title: "Overwrite files?", + message: tx`Copy to ${to} and overwrite existing files?`, + okLabel: "Overwrite", + danger: true, + })) + ) + return; + await call(commands.fsCopyMerge(sourceRoot, targetRoot, selected.path, true)); + } + notifyFileOperation({ kind: "created", paths: [to] }); + emitLocalIdeEvent({ type: "fsChanged", created: [to], modified: [to], removed: [] }); + } + } catch (e) { + if (mounted.current) setOperationError(e instanceof Error ? e.message : String(e)); + } finally { + if (mounted.current) { + setOperating(false); + if (attempted) refresh(); + } + } + }; + + return ( + { + if (diff) { + void openDiff(diff); + onClose(); + } + }, + }, + { label: "Close", role: "cancel" }, + ]} + > +
{tx`Left: ${left}`}
+
{tx`Right: ${right}`}
+
+ setFilter(e.target.value)} + /> + + +
+
+ + + + {operating && } +
+ {error && ( +
+ {tx(error)} +
+ )} + {operationError && ( +
+ {tx(operationError)} +
+ )} + {busy && ( +
+ + + {progress?.total != null + ? tx`Compared ${progress.compared} of ${progress.total} entries` + : tx`Scanned ${progress?.scanned ?? 0} entries`} + + +
+ )} + {cancelled && ( +
+ Comparison cancelled +
+ )} + {!busy && !cancelled && !error && !visible.length && ( +
+ No matching differences +
+ )} + {!!tree.length && ( + directoryDiff(left, right, e)} + openOnActivate={!busy && !operating} + onOpen={onClose} + ariaLabel="Compared files" + renderDetails={(n) => { + const entry = n.file ?? n.directory; + return {entry ? tx(STATUS[entry.status]) : n.count}; + }} + /> + )} +
+ ); +} diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index 9bd19206..dbf74852 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -66,6 +66,21 @@ beforeEach(() => { }); describe("Diff toolbar", () => { + it("shows a binary notice for disk comparisons instead of identical empty text", async () => { + probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: true, tooLarge: false } }); + const binaryInput = { + ...input, + data: { path: "/p/right.bin", left: { type: "file", path: "/p/left.bin" }, right: { type: "worktree" } }, + } as EditorInput; + render(); + expect(await screen.findByText("Binary files cannot be shown as text")).toBeInTheDocument(); + expect(screen.queryByText("Contents are identical")).toBeNull(); + expect(screen.queryByRole("region", { name: /left.bin/ })).toBeNull(); + probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } }); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.queryByText("Binary files cannot be shown as text")).toBeNull()); + }); + it("puts Jump to Source right after Next Difference, in the navigation group", async () => { render(); await screen.findByRole("button", { name: "Jump to Source" }); diff --git a/src/features/vcs/__tests__/directoryCompare.test.tsx b/src/features/vcs/__tests__/directoryCompare.test.tsx new file mode 100644 index 00000000..896fc57e --- /dev/null +++ b/src/features/vcs/__tests__/directoryCompare.test.tsx @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { DirectoryCompareEntry } from "@/bindings"; + +const mocks = vi.hoisted(() => ({ + compare: vi.fn(), + cancel: vi.fn(), + copy: vi.fn(), + remove: vi.fn(), + confirm: vi.fn(), + open: vi.fn(), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + commands: { + ...real.commands, + fsCompareDirectories: mocks.compare, + fsCopyMerge: mocks.copy, + fsDelete: mocks.remove, + cancelRequest: mocks.cancel, + }, + }; +}); +vi.mock("@tauri-apps/api/core", async (original) => ({ + ...(await original()), + Channel: class { + onmessage = () => {}; + }, +})); +vi.mock("../DiffEditor", () => ({ + DiffEditor: ({ input }: { input: unknown }) =>
{JSON.stringify(input)}
, +})); +vi.mock("../dialogs", () => ({ confirmDialog: mocks.confirm, openDialog: vi.fn(), promptText: vi.fn() })); +vi.mock("../diffInput", async (original) => ({ + ...(await original()), + openDiff: mocks.open, +})); +import { DirectoryCompareDialog, directoryDiff } from "../DirectoryCompare"; + +const row = ( + path: string, + status: DirectoryCompareEntry["status"], + leftKind: DirectoryCompareEntry["leftKind"] = "file", + rightKind: DirectoryCompareEntry["rightKind"] = "file", +): DirectoryCompareEntry => ({ path, status, leftKind, rightKind }); +const rows = [ + row("nested", "different", "dir", "dir"), + row("nested/中文.txt", "different"), + row("only-left.txt", "leftOnly", "file", null), + row("only-right.txt", "rightOnly", null, "file"), + row("same.txt", "equal"), + row("empty", "leftOnly", "dir", null), +]; +const ok = (data: unknown = null) => ({ status: "ok", data }); +const setup = () => render( {}} />); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.compare.mockResolvedValue(ok(rows)); + mocks.copy.mockResolvedValue(ok()); + mocks.remove.mockResolvedValue(ok()); + mocks.confirm.mockResolvedValue(true); +}); +afterEach(cleanup); + +describe("disk directory comparison", () => { + it("reuses the changes tree, filters names and toggles identical files", async () => { + setup(); + expect(await screen.findByText("中文.txt")).toBeInTheDocument(); + expect(screen.getAllByText("Only on left")).toHaveLength(2); + expect(screen.getByText("Only on right")).toBeInTheDocument(); + expect(screen.queryByText("same.txt")).toBeNull(); + fireEvent.click(screen.getByRole("checkbox", { name: "Show identical files" })); + expect(screen.getByText("same.txt")).toBeInTheDocument(); + fireEvent.change(screen.getByRole("textbox", { name: "Filter by name" }), { target: { value: "中文" } }); + expect(screen.queryByText("same.txt")).toBeNull(); + expect(screen.getByText("nested")).toBeInTheDocument(); + fireEvent.mouseDown(screen.getByText("nested"), { button: 0 }); + expect(screen.getByRole("button", { name: "Copy to Right" })).toBeEnabled(); + }); + + it("previews on selection and opens the existing diff on double click, including missing sides", async () => { + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); + fireEvent.doubleClick(screen.getByText("中文.txt")); + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" })); + expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text"); + expect(directoryDiff("/l", "/r", rows[3]!)?.left.type).toBe("text"); + expect(directoryDiff("/l", "/r", row("type", "different", "file", "dir"))).toBeNull(); + expect( + directoryDiff("/l", "/r", { + ...row("link", "different", "symlink", "symlink"), + leftLink: "one", + rightLink: "two", + }), + ).toMatchObject({ + left: { type: "text", text: "one" }, + right: { type: "text", text: "two" }, + }); + }); + + it.each(["Copy to Left", "Copy to Right"])( + "confirms before overwriting, refreshes and restores selection after %s", + async (label) => { + mocks.copy + .mockResolvedValueOnce({ status: "error", error: { kind: "alreadyExists", message: "exists" } }) + .mockResolvedValueOnce(ok()); + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: label })); + await waitFor(() => + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Overwrite files?" })), + ); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(mocks.copy.mock.calls[0]![3]).toBe(false); + expect(mocks.copy.mock.calls[1]![3]).toBe(true); + expect(mocks.copy.mock.calls[0]!.slice(0, 3)).toEqual( + label === "Copy to Left" + ? ["/p/right", "/p/left", "nested/中文.txt"] + : ["/p/left", "/p/right", "nested/中文.txt"], + ); + expect(screen.getByTestId("diff")).toBeInTheDocument(); + }, + ); + + it("does not overwrite when confirmation is declined and reports operation errors", async () => { + mocks.copy.mockResolvedValue({ status: "error", error: { kind: "alreadyExists", message: "exists" } }); + mocks.confirm.mockResolvedValue(false); + setup(); + fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(mocks.copy).toHaveBeenCalledTimes(1); + mocks.copy.mockResolvedValue({ status: "error", error: { kind: "permissionDenied", message: "Cannot write" } }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Cannot write"); + }); + + it("copies empty and nonempty subdirectories and confirms deletion only for one-sided entries", async () => { + setup(); + fireEvent.mouseDown(await screen.findByText("nested"), { button: 0 }); + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "nested", false)); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + fireEvent.mouseDown(screen.getByText("empty"), { button: 0 }); + fireEvent.click(screen.getByRole("button", { name: "Copy to Right" })); + await waitFor(() => expect(mocks.copy).toHaveBeenCalledWith("/p/left", "/p/right", "empty", false)); + await waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(3)); + fireEvent.mouseDown(screen.getByText("only-left.txt"), { button: 0 }); + mocks.confirm.mockResolvedValueOnce(false); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalled()); + expect(mocks.remove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + await waitFor(() => expect(mocks.remove).toHaveBeenCalledWith("/p/left/only-left.txt")); + }); + + it("streams progress, cancels on demand and on close, and ignores stale results after refresh", async () => { + let resolve!: (value: unknown) => void; + mocks.compare.mockImplementationOnce((_l, _r, _request, channel) => { + channel.onmessage({ scanned: 20, compared: 5, total: 10, bytesRead: 100, path: "big" }); + return new Promise((r) => { + resolve = r; + }); + }); + const view = setup(); + expect(await screen.findByText("Compared 5 of 10 entries")).toBeInTheDocument(); + const request = mocks.compare.mock.calls[0]![2]; + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(mocks.cancel).toHaveBeenCalledWith(request); + view.unmount(); + expect(mocks.cancel).toHaveBeenCalledTimes(2); + resolve(ok(rows)); + }); +}); diff --git a/src/features/vcs/__tests__/directoryCompareActions.test.ts b/src/features/vcs/__tests__/directoryCompareActions.test.ts new file mode 100644 index 00000000..4a0d5cba --- /dev/null +++ b/src/features/vcs/__tests__/directoryCompareActions.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + show: vi.fn(), + read: vi.fn(), + notify: vi.fn(), + pick: vi.fn(), + remote: vi.fn(), + prompt: vi.fn(), + openDiff: vi.fn(), +})); +vi.mock("../DirectoryCompare", () => ({ showDirectoryComparison: mocks.show })); +vi.mock("../dialogs", () => ({ promptText: mocks.prompt })); +vi.mock("../diffInput", () => ({ openDiff: mocks.openDiff })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.pick })); +vi.mock("@/platform/remote", () => ({ isRemoteWindow: mocks.remote })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { fsEntryKind: mocks.read }, +})); +vi.mock("@/workbench/contributions", async (original) => ({ + ...(await original()), + notifications: () => ({ notify: mocks.notify }), +})); +import { getAction, isActionEnabled } from "@/platform/actions"; +import { provideProjectViewSelection } from "@/workbench/contributions"; +import { registerCompareActions } from "../compare"; + +let dispose: () => void; +beforeEach(() => { + vi.clearAllMocks(); + dispose = registerCompareActions(); + mocks.remote.mockReturnValue(false); + mocks.read.mockImplementation((path: string) => + Promise.resolve({ status: "ok", data: path.endsWith(".txt") ? "file" : "dir" }), + ); +}); +afterEach(() => dispose()); + +describe("directory compare actions", () => { + it("has a stable searchable action and resolves the selection for Find Action", async () => { + const unselect = provideProjectViewSelection(() => ["/p/left", "/p/right"]); + try { + const action = getAction("diff.compareDirectories")!; + expect(action.title).toBe("Compare Directories"); + expect(action.hidden).not.toBe(true); + expect(isActionEnabled(action, { projectOpen: true })).toBe(true); + await action.run({ ctx: { projectOpen: true } }); + expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right"); + await action.run({ ctx: { projectOpen: true }, arg: { paths: ["/x/left", "/x/right"] } }); + expect(mocks.show).toHaveBeenLastCalledWith("/x/left", "/x/right"); + } finally { + unselect(); + } + }); + + it("rejects mixed selections and does not start a directory comparison", async () => { + await getAction("diff.compareDirectories")!.run({ ctx: {}, arg: ["/p/left", "/p/file.txt"] }); + expect(mocks.show).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Select two directories to compare" })); + }); + + it("uses the native folder picker for Compare With on a local directory", async () => { + mocks.pick.mockResolvedValue("/p/right"); + await getAction("diff.compareWith")!.run({ ctx: {}, arg: { path: "/p/left", directory: true } }); + expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: true })); + expect(mocks.show).toHaveBeenCalledWith("/p/left", "/p/right"); + expect(mocks.openDiff).not.toHaveBeenCalled(); + }); + + it("uses remote paths and validates the selected directory via the routed filesystem", async () => { + mocks.remote.mockReturnValue(true); + mocks.prompt.mockResolvedValue({ value: "/remote/right" }); + const unselect = provideProjectViewSelection(() => ["/remote/left"]); + try { + await getAction("diff.compareWith")!.run({ ctx: {} }); + expect(mocks.prompt).toHaveBeenCalledWith( + expect.objectContaining({ label: "Directory path", initial: "/remote/left" }), + ); + expect(mocks.pick).not.toHaveBeenCalled(); + expect(mocks.read).toHaveBeenCalledWith("/remote/right"); + expect(mocks.show).toHaveBeenCalledWith("/remote/left", "/remote/right"); + } finally { + unselect(); + } + }); + + it("leaves file Compare With on the existing diff flow", async () => { + mocks.pick.mockResolvedValue("/p/b.txt"); + await getAction("diff.compareWith")!.run({ ctx: {}, arg: "/p/a.txt" }); + expect(mocks.pick).toHaveBeenCalledWith(expect.objectContaining({ directory: false })); + expect(mocks.openDiff).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/b.txt" })); + expect(mocks.show).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/__tests__/fileTree.test.ts b/src/features/vcs/__tests__/fileTree.test.ts index c0327edc..158f3282 100644 --- a/src/features/vcs/__tests__/fileTree.test.ts +++ b/src/features/vcs/__tests__/fileTree.test.ts @@ -9,4 +9,21 @@ describe("buildFileTree", () => { expect(tree[1]!.children.map((c) => c.name)).toEqual(["a.ts", "b.ts"]); expect(dirKeys(tree)).toEqual(["d:docs/x/y", "d:src/app"]); }); + + it("retains explicit empty directories and their entries for subtree operations", () => { + const entries = [ + { path: "empty", dir: true }, + { path: "src", dir: true }, + { path: "src/nested", dir: true }, + { path: "src/nested/a", dir: false }, + ]; + const tree = buildFileTree( + entries, + (e) => e.path, + (e) => e.dir, + ); + expect(tree[0]).toMatchObject({ name: "empty", directory: entries[0], children: [], count: 0 }); + expect(tree[1]).toMatchObject({ name: "src", directory: entries[1] }); + expect(tree[1]!.children[0]).toMatchObject({ name: "nested", directory: entries[2] }); + }); }); diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index c18a071e..9e9acc8d 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -310,6 +310,17 @@ color: var(--fg-subtle); overflow-wrap: anywhere; } +.compareToolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 6px 14px; +} +.compareToolbar > input { + flex: 1; + min-width: 140px; +} .compareAdded { color: var(--vcs-added); font-variant-numeric: tabular-nums; diff --git a/src/features/vcs/compare.tsx b/src/features/vcs/compare.tsx index a2799e61..1437abdb 100644 --- a/src/features/vcs/compare.tsx +++ b/src/features/vcs/compare.tsx @@ -4,10 +4,13 @@ */ import { activeEditor } from "@/editor/api"; import { registerActions } from "@/platform/actions"; -import { isTauri } from "@/platform/ipc"; -import { useProject } from "@/platform/project"; -import { maybeEditorService, notifications } from "@/workbench/contributions"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { isRemoteWindow } from "@/platform/remote"; +import { maybeEditorService, notifications, projectViewSelection } from "@/workbench/contributions"; +import { promptText } from "./dialogs"; import { focusedDiffNavigator } from "./diffNavigator"; +import { compareFilesData, displayPath } from "./compareData"; +export { compareFilesData, displayPath } from "./compareData"; import { openDiff, type DiffData } from "./diffInput"; import { baseName } from "./model"; import { tx } from "@/platform/i18n"; @@ -27,20 +30,6 @@ function argPaths(arg: unknown): string[] { return p ? [p] : []; } -/** Project-relative path for labels. */ -export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string { - return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; -} - -/** Diff of two files on disk; both sides are live, editable documents. */ -export function compareFilesData(leftPath: string, rightPath: string): DiffData { - return { - path: rightPath, - left: { type: "file", path: leftPath, label: displayPath(leftPath) }, - right: { type: "worktree", label: displayPath(rightPath) }, - }; -} - /** Clipboard ↔ the file (editable) or ↔ the editor selection. */ export function clipboardDiffData(path: string, clipboard: string, selection: string | null): DiffData { return { @@ -122,6 +111,29 @@ async function pickComparison( const contextPath = (arg: unknown) => argPath(arg) ?? maybeEditorService()?.activePath() ?? undefined; +async function showDirectories(left: string, right: string) { + const { showDirectoryComparison } = await import("./DirectoryCompare"); + showDirectoryComparison(left, right); +} + +async function isDirectory(path: string): Promise { + try { + return (await call(commands.fsEntryKind(path))) === "dir"; + } catch { + return false; + } +} + +async function pickDirectory(near: string): Promise { + if (isRemoteWindow() || !isTauri()) { + const picked = await promptText({ title: "Compare With", label: "Directory path", initial: near, mono: true }); + return picked?.value ?? null; + } + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ multiple: false, directory: true, title: tx("Compare With"), defaultPath: near }); + return (Array.isArray(picked) ? picked[0] : picked) ?? null; +} + export function registerCompareActions(): () => void { return registerActions([ { @@ -210,10 +222,18 @@ export function registerCompareActions(): () => void { category: "Diff", icon: "diff", when: "projectOpen", - keywords: ["diff", "compare files"], + keywords: ["diff", "compare files", "compare folders"], run: async ({ arg }) => { - const path = contextPath(arg); + const path = argPath(arg) ?? projectViewSelection()[0] ?? contextPath(arg); if (!path) return info("Open or select a file to compare"); + const directoryArg = !!arg && typeof arg === "object" && "directory" in arg && arg.directory === true; + if (directoryArg || (isTauri() && (await isDirectory(path)))) { + const other = await pickDirectory(path); + if (!other || other === path) return; + if (!(await isDirectory(other))) return info("Select a directory to compare"); + await showDirectories(path, other); + return; + } const other = await pickComparison(path); if (!other) return; if (other.type === "file") { @@ -228,6 +248,20 @@ export function registerCompareActions(): () => void { } }, }, + { + id: "diff.compareDirectories", + title: "Compare Directories", + category: "Diff", + icon: "diff", + when: "projectOpen", + keywords: ["diff", "folders", "compare folders"], + run: async ({ arg }) => { + const paths = arg === undefined ? projectViewSelection() : argPaths(arg); + if (paths.length !== 2 || !(await isDirectory(paths[0]!)) || !(await isDirectory(paths[1]!))) + return info("Select two directories to compare"); + await showDirectories(paths[0]!, paths[1]!); + }, + }, { id: "diff.compareFiles", title: "Compare Files", diff --git a/src/features/vcs/compareData.ts b/src/features/vcs/compareData.ts new file mode 100644 index 00000000..fc693fce --- /dev/null +++ b/src/features/vcs/compareData.ts @@ -0,0 +1,16 @@ +import { useProject } from "@/platform/project"; +import type { DiffData } from "./diffInput"; + +/** Project-relative path for labels. */ +export function displayPath(path: string, root = useProject.getState().project?.root ?? null): string { + return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path; +} + +/** Diff of two files on disk; both sides are live, editable documents. */ +export function compareFilesData(leftPath: string, rightPath: string): DiffData { + return { + path: rightPath, + left: { type: "file", path: leftPath, label: displayPath(leftPath) }, + right: { type: "worktree", label: displayPath(rightPath) }, + }; +} diff --git a/src/features/vcs/fileTree.ts b/src/features/vcs/fileTree.ts index a3126563..72bd576f 100644 --- a/src/features/vcs/fileTree.ts +++ b/src/features/vcs/fileTree.ts @@ -5,15 +5,22 @@ export interface FileTreeNode { /** Directory label (`src/app`) or file name. */ name: string; file: F | null; + /** An explicit directory entry, including empty directories. */ + directory?: F; children: FileTreeNode[]; /** Number of files below (1 for a file). */ count: number; } -export function buildFileTree(files: readonly F[], relPath: (f: F) => string): FileTreeNode[] { +export function buildFileTree( + files: readonly F[], + relPath: (f: F) => string, + isDirectory?: (f: F) => boolean, +): FileTreeNode[] { interface Dir { dirs: Map; files: { name: string; file: F; rel: string }[]; + entry?: F; } const root: Dir = { dirs: new Map(), files: [] }; for (const f of files) { @@ -26,7 +33,11 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) if (!next) d.dirs.set(p, (next = { dirs: new Map(), files: [] })); d = next; } - d.files.push({ name, file: f, rel }); + if (isDirectory?.(f)) { + let sub = d.dirs.get(name); + if (!sub) d.dirs.set(name, (sub = { dirs: new Map(), files: [] })); + sub.entry = f; + } else d.files.push({ name, file: f, rel }); } const convert = (d: Dir, prefix: string): FileTreeNode[] => { @@ -35,7 +46,7 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) let label = name; let path = prefix ? `${prefix}/${name}` : name; let cur = sub; - while (cur.files.length === 0 && cur.dirs.size === 1) { + while (!cur.entry && cur.files.length === 0 && cur.dirs.size === 1) { const [n, only] = [...cur.dirs][0]!; label += `/${n}`; path += `/${n}`; @@ -46,6 +57,7 @@ export function buildFileTree(files: readonly F[], relPath: (f: F) => string) key: `d:${path}`, name: label, file: null, + directory: cur.entry, children, count: children.reduce((n, c) => n + c.count, 0), }); diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index e0099acd..b18a7ae1 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, nextRequestId } from "@/platform/ipc"; -import { Checkbox, Dialog, Icon, Input, Tree, Spinner, fileIconName, T } from "@/ui"; +import { Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui"; import { notifications } from "@/workbench/contributions"; import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput"; import { openDialog, promptText } from "./dialogs"; @@ -17,10 +17,10 @@ import { runGitOperation, runOp } from "./ops"; import { reloadLog, useGitLog } from "./logStore"; 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 { ComparisonTree } from "./ComparisonTree"; +import { buildFileTree, type FileTreeNode } from "./fileTree"; import { tx } from "@/platform/i18n"; -import { displayPath } from "./compare"; +import { displayPath } from "./compareData"; // --------------------------------------------------------------------------- // Operation in progress @@ -528,8 +528,6 @@ export function ChangesBetweenDialog({ const [files, setFiles] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - 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; @@ -665,52 +663,25 @@ export function ChangesBetweenDialog({
) : 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); - }} - onActivate={(n) => n.file && setCurrent(n.file)} - aria-label="Changed files" - className={styles.comparisonTree} - renderNode={(n) => ( - - - - {n.name} - - {n.file ? ( - <> - {tx(n.file.kind)} - {n.file.added !== undefined && +{n.file.added}} - {n.file.removed !== undefined && −{n.file.removed}} - - ) : ( - {n.count} - )} - - )} - /> - {input && ( -
- -
- )} -
+ KIND_COLOR[f.kind === "untracked" ? "added" : f.kind]} + ariaLabel="Changed files" + renderDetails={(n) => + n.file ? ( + <> + {tx(n.file.kind)} + {n.file.added !== undefined && +{n.file.added}} + {n.file.removed !== undefined && −{n.file.removed}} + + ) : ( + {n.count} + ) + } + /> ) : null} ); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0f69670d..a7637c16 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5382,5 +5382,36 @@ "in {length} repositories": "在 {length} 个仓库中", "No repository in the selection has revision {label}": "所选内容中的仓库均无版本 {label}", "{repoName} has no revision {label}": "{repoName} 没有版本 {label}", - "Waiting for language server…": "正在等待语言服务器…" + "Waiting for language server…": "正在等待语言服务器…", + "Compare Directories": "比较目录", + "Select two directories to compare": "选择两个要比较的目录", + "Select a directory to compare": "选择要比较的目录", + "Directory path": "目录路径", + "Only on left": "仅左侧", + "Only on right": "仅右侧", + "Contents differ": "内容不同", + "Comparing directories": "正在比较目录", + "Filter by name": "按名称过滤", + "Show identical files": "显示相同文件", + "Copy to Left": "复制到左侧", + "Copy to Right": "复制到右侧", + "Compared files": "比较的文件", + "Comparison cancelled": "已取消比较", + "No matching differences": "没有匹配的差异", + "Overwrite files?": "覆盖文件?", + "Copy to {to} and overwrite existing files?": "复制到 {to} 并覆盖已有文件?", + "Left: {left}": "左侧:{left}", + "Right: {right}": "右侧:{right}", + "Compared {compared} of {total} entries": "已比较 {compared} / {total} 个条目", + "Scanned {scanned} entries": "已扫描 {scanned} 个条目", + "Binary content": "二进制内容", + "Binary files cannot be shown as text": "二进制文件无法以文本形式显示", + "Delete {path}?": "删除 {path}?", + "copy requires an absolute path": "复制需要绝对路径", + "missing file name": "缺少文件名", + "copy requires absolute paths": "复制需要绝对路径", + "copy paths must belong to the comparison roots": "复制路径必须位于比较的根目录内", + "cannot copy overlapping paths": "无法在相互包含的路径之间复制", + "cannot copy through a destination symlink": "无法通过目标目录内的符号链接复制", + "expected a relative comparison entry path": "需要比较条目的相对路径" }