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": "需要比较条目的相对路径" }