diff --git a/Cargo.lock b/Cargo.lock index eb4b7f542..bd6ee1ba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4735,6 +4735,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.17+spec-1.1.0", "tracing", + "uuid", "zip", ] diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs index 0277dfc44..e98075006 100644 --- a/crates/ide-host/src/ops/fs.rs +++ b/crates/ide-host/src/ops/fs.rs @@ -137,16 +137,23 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> { let history = host.project(window).ok().map(|p| p.history.clone()); host.blocking(move || { + let paths = ide_workspace::file_ops::deletion_paths(&path)?; if let Some(h) = history { - h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT); + for p in paths { + h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT); + } } - ide_vfs::delete(&path) + ide_workspace::file_ops::delete(&path) }) .await } pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> { - host.blocking(move || ide_vfs::copy(&from, &to)).await + host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await +} + +pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> { + host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await } /// Reads a text file without opening it as a document; prefers the unsaved buffer. diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index e6632c1d1..9895b0aa5 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -17,6 +17,8 @@ pub struct ProjectInfo { pub settings: Settings, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unity: Option, } pub fn info(project: &Project) -> ProjectInfo { @@ -33,6 +35,7 @@ pub fn info(project: &Project) -> ProjectInfo { settings, root: project.root.clone().into(), workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(), + unity: ide_workspace::unity::detect(&project.root), } } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 59860e912..e33e4c8e2 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -692,7 +692,14 @@ fn scan_repositories( build_cache: &Path, ) { let ignored = settings.effective().ignored_names(); - let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy())); + let skip = |p: &Path| { + p.file_name().is_some_and(|n| { + let name = n.to_string_lossy(); + ignored.is_ignored_name(&name) + || (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref()) + && p.parent().is_some_and(ide_workspace::unity::is_project)) + }) + }; let mut roots = vec![root.to_path_buf()]; roots.extend(content_roots(&settings.effective(), root, build_cache)); match Repos::discover_roots(&roots, &skip) { diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 034c40fbe..fafe557ab 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -188,7 +188,7 @@ impl Dispatcher { ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await }) .add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::("path")?) }) - .add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::("from")?, &c.arg::("to")?) }) + .add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await }) .add("fs_set_writable", |c| async move { ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?) }) diff --git a/crates/ide-host/tests/unity.rs b/crates/ide-host/tests/unity.rs new file mode 100644 index 000000000..f38fdbdd7 --- /dev/null +++ b/crates/ide-host/tests/unity.rs @@ -0,0 +1,100 @@ +use ide_core::{NullSink, RequestId}; +use ide_host::{Host, HostConfig, ops}; +use std::{sync::Arc, time::Duration}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("Game"); + for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] { + std::fs::create_dir_all(root.join(name)).unwrap(); + } + std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap(); + std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap(); + std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap(); + std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap(); + std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap(); + std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + let info = ops::project::info(&project); + assert_eq!(info.name, "Game"); + let unity = info.unity.unwrap(); + assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1")); + assert!(unity.projects.is_empty() && unity.solutions.is_empty()); + assert!(project.index.wait_idle(Duration::from_secs(20))); + assert_eq!(project.index.find_files("Player.cs", 100).len(), 1); + for name in ["Cached.cs", "Temporary.cs", "Editor.log"] { + assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}"); + } + let hits = Arc::new(std::sync::Mutex::new(Vec::new())); + let out = hits.clone(); + let query = serde_json::from_value(serde_json::json!({ + "pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false, + "include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100, + })) + .unwrap(); + ops::search::start( + &host, + "main", + RequestId::next(), + query, + Arc::new(move |event| { + if let ops::search::SearchEvent::File { file } = event { + out.lock().unwrap().push(file.rel_path); + } + }), + ) + .await + .unwrap(); + assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]); + let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap(); + assert!(listing.iter().any(|e| e.name == "Library" && e.excluded)); + assert!(project.exclude_globs().contains(&"/Obj".to_owned())); + + // Project metadata refreshes without reopening when Unity generates its solution. + std::fs::write(root.join("Game.sln"), "").unwrap(); + std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap(); + let unity = ops::project::info(&project).unity.unwrap(); + assert_eq!(unity.solutions, ["Game.sln"]); + assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]); + + // Desktop commands and remote RPC share these host operations. + let original = root.join("Assets/Scripts/Player.cs"); + let renamed = root.join("Assets/Scripts/Hero.cs"); + let copied = root.join("Assets/Scripts/HeroCopy.cs"); + let meta = |path: &std::path::Path| -> std::path::PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".meta"); + name.into() + }; + let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n"; + std::fs::write(meta(&original), original_metadata).unwrap(); + ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap(); + assert!(!original.exists() && !meta(&original).exists()); + assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata); + ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap(); + assert!(copied.exists()); + assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata); + assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata); + host.close_project("main"); + + let ordinary = base.join("ConsoleApp"); + std::fs::create_dir(&ordinary).unwrap(); + std::fs::write(ordinary.join("App.csproj"), "").unwrap(); + let project = host.open_project("main", &ordinary).unwrap(); + assert!(ops::project::info(&project).unity.is_none()); + assert!(!project.exclude_globs().contains(&"/Library".to_owned())); + host.close_project("main"); +} diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index 2dd5a0e44..3941e8ba9 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -256,6 +256,11 @@ pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> { pub fn rename(from: &Path, to: &Path) -> IdeResult<()> { ops::rename(from, to) } +/// Checks source existence and destination conflicts without mutation, using +/// the same filesystem identity rules as [`rename`]. +pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> { + ops::validate_rename(from, to) +} /// Moves to the OS trash (recoverable), like IDEA's safe delete for files. /// Deletes permanently when the trash is unavailable. pub fn delete(path: &Path) -> IdeResult<()> { diff --git a/crates/ide-vfs/src/ops.rs b/crates/ide-vfs/src/ops.rs index d256aa34c..7ebe171f1 100644 --- a/crates/ide-vfs/src/ops.rs +++ b/crates/ide-vfs/src/ops.rs @@ -88,7 +88,7 @@ fn same_file(a: &Path, b: &Path) -> bool { } } -pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> { +pub(crate) fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> { if !exists(from) { return Err(not_found(from)); } @@ -99,6 +99,14 @@ pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> { if exists(to) && !same_file(from, to) { return Err(already_exists(to)); } + Ok(()) +} + +pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> { + validate_rename(from, to)?; + if from == to { + return Ok(()); + } ensure_parent(to)?; match fs::rename(from, to) { Ok(()) => Ok(()), diff --git a/crates/ide-workspace/Cargo.toml b/crates/ide-workspace/Cargo.toml index bf51d7934..5ebf071f4 100644 --- a/crates/ide-workspace/Cargo.toml +++ b/crates/ide-workspace/Cargo.toml @@ -21,6 +21,7 @@ blake3.workspace = true tracing.workspace = true tempfile.workspace = true globset.workspace = true +uuid.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-workspace/src/documents/workspace_edit.rs b/crates/ide-workspace/src/documents/workspace_edit.rs index e74dc9f49..6e5e67f96 100644 --- a/crates/ide-workspace/src/documents/workspace_edit.rs +++ b/crates/ide-workspace/src/documents/workspace_edit.rs @@ -401,8 +401,11 @@ impl Documents { if edit.files.is_empty() { return Ok(()); } + // Validate project rules before path resolution can erase linked components. + crate::file_ops::validate_workspace_edit_paths(edit)?; let mut edit = normalize_edit(edit)?; let captured = !edit.preconditions.is_empty(); + crate::file_ops::expand_workspace_edit(&mut edit)?; self.prepare_workspace_edit(&mut edit)?; self.validate_preconditions(&edit.preconditions)?; for file in &edit.files { diff --git a/crates/ide-workspace/src/file_moves.rs b/crates/ide-workspace/src/file_moves.rs index ad6444b89..7a1cae91d 100644 --- a/crates/ide-workspace/src/file_moves.rs +++ b/crates/ide-workspace/src/file_moves.rs @@ -120,6 +120,8 @@ impl FileMoves { if moves.is_empty() { return Err(IdeError::invalid("nothing to move")); } + let expanded = crate::file_ops::expand_moves(moves)?; + let moves = expanded.as_slice(); for m in moves { if !m.from.exists() { return Err(IdeError::not_found(format!("{} does not exist", m.from.display()))); @@ -145,16 +147,9 @@ impl FileMoves { restore(documents, &paths, &before); return Err(e); } - let mut done: Vec<&FileMove> = Vec::new(); - for m in moves { - if let Err(e) = ide_vfs::rename(&m.from, &m.to) { - for d in done.iter().rev() { - let _ = ide_vfs::rename(&d.to, &d.from); - } - restore(documents, &paths, &before); - return Err(e); - } - done.push(m); + if let Err(e) = crate::file_ops::move_files(moves) { + restore(documents, &paths, &before); + return Err(e); } let mut edited = Vec::with_capacity(paths.len()); for (p, before) in paths.iter().zip(before) { @@ -196,27 +191,21 @@ impl FileMoves { .ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?; records.remove(i) }; - for m in &record.moves { - if !m.to.exists() { - return Err(IdeError::not_found(format!("{} no longer exists", m.to.display()))); - } - if m.from.exists() { - return Err(IdeError::new( - ide_core::error::ErrorKind::AlreadyExists, - format!("{} exists again", m.from.display()), - )); - } + let back: Vec<_> = + record.moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect(); + if let Err(e) = crate::file_ops::move_files(&back) { + self.records.lock().push(record); + return Err(e); } - let mut outcome = UndoOutcome::default(); - for m in record.moves.iter().rev() { - ide_vfs::rename(&m.to, &m.from)?; - outcome.moves.push(FileMove { from: m.to.clone(), to: m.from.clone() }); - } - let back: Vec = outcome.moves.clone(); + let mut outcome = UndoOutcome { moves: back.clone(), conflicts: Vec::new() }; for f in &record.edited { let path = moved_path(&f.path, &back); match read_text(documents, &path) { - Ok(now) if now == f.after => write_text(documents, &path, &f.before)?, + Ok(now) if now == f.after => { + if write_text(documents, &path, &f.before).is_err() { + outcome.conflicts.push(path.into()); + } + } Ok(now) if now == f.before => {} _ => outcome.conflicts.push(path.into()), } diff --git a/crates/ide-workspace/src/file_ops.rs b/crates/ide-workspace/src/file_ops.rs new file mode 100644 index 000000000..6f3324d13 --- /dev/null +++ b/crates/ide-workspace/src/file_ops.rs @@ -0,0 +1,94 @@ +//! Project-aware file operations. Plain files retain the VFS behavior; project +//! rules may expand a resource operation before the common transaction runs. + +use crate::FileMove; +use ide_core::{IdeError, IdeResult, WorkspaceEdit}; +use std::path::{Path, PathBuf}; + +mod project; + +pub fn copy(from: &Path, to: &Path) -> IdeResult<()> { + project::copy(from, to).unwrap_or_else(|| ide_vfs::copy(from, to)) +} + +pub fn delete(path: &Path) -> IdeResult<()> { + project::delete(path).unwrap_or_else(|| ide_vfs::delete(path)) +} + +/// Files Local History must capture before deletion. +pub fn deletion_paths(path: &Path) -> IdeResult> { + project::deletion_paths(path).unwrap_or_else(|| Ok(vec![path.to_path_buf()])) +} + +pub fn rename(from: &Path, to: &Path) -> IdeResult<()> { + if !project::has_move_rules(from, to) { + return ide_vfs::rename(from, to); + } + let moves = expand_moves(&[FileMove { from: from.into(), to: to.into() }])?; + move_files(&moves) +} + +pub(crate) fn expand_moves(moves: &[FileMove]) -> IdeResult> { + let expanded = project::expand_moves(moves)?; + validate_moves(&expanded)?; + Ok(expanded) +} + +fn path_key(path: &Path) -> PathBuf { + #[cfg(windows)] + return path.to_string_lossy().replace('\\', "/").to_lowercase().into(); + #[cfg(not(windows))] + path.to_path_buf() +} + +fn validate_moves(moves: &[FileMove]) -> IdeResult<()> { + let mut sources = std::collections::HashSet::new(); + let mut targets = std::collections::HashSet::new(); + for m in moves { + ide_vfs::validate_rename(&m.from, &m.to)?; + let from = path_key(&m.from); + let to = path_key(&m.to); + if to != from && to.starts_with(&from) { + return Err(IdeError::invalid("Cannot move a directory into itself")); + } + if !sources.insert(from) || !targets.insert(to) { + return Err(IdeError::invalid("Duplicate source or destination in file operation")); + } + } + Ok(()) +} + +pub(crate) fn move_files(moves: &[FileMove]) -> IdeResult<()> { + execute_moves(moves, &|from, to| project::rename_entry(from, to).unwrap_or_else(|| ide_vfs::rename(from, to))) +} + +/// Executes an expanded batch. Rollback never overwrites a newly occupied path; +/// failures report where the caller can recover the remaining files. +pub(crate) fn execute_moves(moves: &[FileMove], rename: &dyn Fn(&Path, &Path) -> IdeResult<()>) -> IdeResult<()> { + validate_moves(moves)?; + for (i, m) in moves.iter().enumerate() { + if let Err(mut error) = rename(&m.from, &m.to) { + for done in moves[..i].iter().rev() { + if let Err(rollback) = + ide_vfs::validate_rename(&done.to, &done.from).and_then(|()| rename(&done.to, &done.from)) + { + error.message.push_str(&format!( + "; rollback {} -> {} failed: {rollback}", + done.to.display(), + done.from.display() + )); + } + } + return Err(error); + } + } + Ok(()) +} + +pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> { + project::validate_workspace_edit_paths(edit) +} + +pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> { + project::expand_workspace_edit(edit) +} diff --git a/crates/ide-workspace/src/file_ops/project.rs b/crates/ide-workspace/src/file_ops/project.rs new file mode 100644 index 000000000..d38b6ccdf --- /dev/null +++ b/crates/ide-workspace/src/file_ops/project.rs @@ -0,0 +1,52 @@ +//! The integration boundary for project-specific resource rules. These rules +//! never replace the plain VFS path unless an operation touches project assets. + +use crate::{FileMove, unity::assets}; +use ide_core::{FileEdit, IdeResult, WorkspaceEdit}; +use std::path::{Path, PathBuf}; + +pub(super) fn has_move_rules(from: &Path, to: &Path) -> bool { + assets::project_root(from).is_some() || assets::project_root(to).is_some() +} + +pub(super) fn copy(from: &Path, to: &Path) -> Option> { + has_move_rules(from, to).then(|| assets::copy(from, to)) +} + +pub(super) fn delete(path: &Path) -> Option> { + assets::project_root(path).map(|_| assets::delete(path)) +} + +pub(super) fn deletion_paths(path: &Path) -> Option>> { + assets::project_root(path).map(|_| assets::deletion_paths(path)) +} + +pub(super) fn rename_entry(from: &Path, to: &Path) -> Option> { + has_move_rules(from, to).then(|| assets::move_one(from, to)) +} + +pub(super) fn expand_moves(moves: &[FileMove]) -> IdeResult> { + if moves.iter().any(|m| has_move_rules(&m.from, &m.to)) { assets::expand_moves(moves) } else { Ok(moves.to_vec()) } +} + +fn has_edit_rules(edit: &WorkspaceEdit) -> bool { + edit.files.iter().any(|file| match file { + FileEdit::Rename { from, to } => has_move_rules(from, to), + FileEdit::Delete { path, .. } => assets::project_root(path).is_some(), + _ => false, + }) +} + +pub(super) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> { + if has_edit_rules(edit) { + assets::validate_workspace_edit_paths(edit)?; + } + Ok(()) +} + +pub(super) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> { + if has_edit_rules(edit) { + assets::expand_workspace_edit(edit)?; + } + Ok(()) +} diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 38ba30ca7..18c29fc18 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -7,6 +7,7 @@ pub mod documents; pub mod editorconfig; pub mod file_defaults; pub mod file_moves; +pub mod file_ops; pub mod file_refs; pub mod history; pub mod memory; @@ -15,6 +16,7 @@ pub mod recent; pub mod roots; pub mod settings; pub mod text_refs; +pub mod unity; pub mod window_groups; pub mod workspace; diff --git a/crates/ide-workspace/src/roots/detect.rs b/crates/ide-workspace/src/roots/detect.rs index 3212bcffb..1e243bc26 100644 --- a/crates/ide-workspace/src/roots/detect.rs +++ b/crates/ide-workspace/src/roots/detect.rs @@ -62,7 +62,10 @@ const ROOT_NAMES: &[&str] = &[ pub fn affects_roots(rel: &Path) -> bool { let mut names = rel.iter().rev().filter_map(|c| c.to_str()); let Some(name) = names.next() else { return false }; - BUILD_FILES.contains(&name) || ROOT_NAMES.contains(&name) || names.next() == Some("src") + BUILD_FILES.contains(&name) + || ROOT_NAMES.contains(&name) + || matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt") + || names.next() == Some("src") } /// Detects roots below `root`; paths in the result are unique and sorted. @@ -77,6 +80,29 @@ pub fn detect(root: &Path) -> Vec { break; } let dir = if rel.is_empty() { root.to_path_buf() } else { root.join(&rel) }; + if crate::unity::is_project(&dir) { + for (name, kind) in + [("Assets", RootKind::Source), ("Packages", RootKind::Source), ("ProjectSettings", RootKind::Resources)] + { + if dir.join(name).is_dir() { + out.push(SourceFolder { + path: join(&rel, name), + kind, + detected_from: Some(join(&rel, crate::unity::VERSION_FILE)), + }); + } + } + // Exclude before the index/watchers start, even if Unity creates its caches later. + for name in crate::unity::GENERATED_DIRS { + out.push(SourceFolder { + path: join(&rel, name), + kind: RootKind::Excluded, + detected_from: Some(join(&rel, crate::unity::VERSION_FILE)), + }); + } + // Assets and PackageCache may contain arbitrary build files; they aren't nested modules. + continue; + } let Ok(entries) = std::fs::read_dir(&dir) else { continue }; let mut files: Vec = Vec::new(); let mut dirs: Vec = Vec::new(); @@ -244,6 +270,43 @@ mod tests { roots.iter().map(|r| format!("{}={:?}@{}", r.path, r.kind, r.detected_from.as_deref().unwrap_or(""))).collect() } + #[test] + fn unity_roots_exclude_future_caches_and_do_not_scan_assets_as_modules() { + let dir = tree(&[ + "Assets/Scripts/Player.cs", + "Assets/Plugins/Tool/package.json", + "ProjectSettings/ProjectVersion.txt", + "Packages/manifest.json", + "Library/PackageCache/tool/Cargo.toml", + ]); + let roots = detect(dir.path()); + assert!(roots.iter().any(|r| r.path == "Assets" && r.kind == RootKind::Source)); + assert!(roots.iter().any(|r| r.path == "Packages" && r.kind == RootKind::Source)); + assert!(!roots.iter().any(|r| r.path.starts_with("Assets/") || r.path.starts_with("Library/"))); + let effective = super::super::ProjectRoots::compute(roots, &Default::default()); + for name in crate::unity::GENERATED_DIRS { + assert!(effective.excluded_globs().contains(&format!("/{name}"))); + } + for path in ["Assets", "Packages", "ProjectSettings", crate::unity::VERSION_FILE] { + assert!(affects_roots(Path::new(path))); + } + } + + #[test] + fn nested_unity_roots_are_anchored_without_excluding_other_projects() { + let dir = tree(&[ + "game/Assets/Scripts/Player.cs", + "game/ProjectSettings/ProjectVersion.txt", + "server/Cargo.toml", + "server/src/main.rs", + "Library/Source.cs", + ]); + let roots = super::super::ProjectRoots::compute(detect(dir.path()), &Default::default()); + assert!(roots.excluded_globs().contains(&"/game/Library".to_owned())); + assert!(!roots.excluded_globs().contains(&"/Library".to_owned())); + assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source)); + } + #[test] fn maven_conventional_roots_and_target() { let dir = tree(&[ diff --git a/crates/ide-workspace/src/unity.rs b/crates/ide-workspace/src/unity.rs new file mode 100644 index 000000000..1d348b9d7 --- /dev/null +++ b/crates/ide-workspace/src/unity.rs @@ -0,0 +1,94 @@ +//! Unity project identity. Generated C# solutions are optional, not root markers. + +pub(crate) mod assets; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::io::Read; +use std::path::Path; + +pub const VERSION_FILE: &str = "ProjectSettings/ProjectVersion.txt"; +/// Root-relative caches and build outputs, including folders not created yet. +pub const GENERATED_DIRS: &[&str] = &["Library", "Temp", "Obj", "obj", "Logs", "Build", "Builds", "UserSettings"]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct UnityProjectInfo { + pub editor_version: Option, + /// File names relative to the Unity root, in stable order. + pub solutions: Vec, + pub projects: Vec, +} + +pub fn is_project(root: &Path) -> bool { + root.join("Assets").is_dir() && root.join(VERSION_FILE).is_file() +} + +pub fn detect(root: &Path) -> Option { + if !is_project(root) { + return None; + } + let mut text = String::new(); + let _ = std::fs::File::open(root.join(VERSION_FILE)).and_then(|f| f.take(64 * 1024).read_to_string(&mut text)); + let editor_version = text.lines().find_map(|line| { + let (key, value) = line.trim_start_matches('\u{feff}').split_once(':')?; + let value = value.trim(); + (key.trim() == "m_EditorVersion" && !value.is_empty() && value.len() <= 128).then(|| value.to_owned()) + }); + let mut info = UnityProjectInfo { editor_version, solutions: Vec::new(), projects: Vec::new() }; + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + match entry.path().extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { + Some("sln" | "slnx") => info.solutions.push(name), + Some("csproj") => info.projects.push(name), + _ => {} + } + } + } + info.solutions.sort(); + info.projects.sort(); + Some(info) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_fresh_and_legacy_projects_without_packages_or_solutions() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir(root.join("Assets")).unwrap(); + assert!(detect(root).is_none()); + std::fs::create_dir(root.join("ProjectSettings")).unwrap(); + std::fs::write( + root.join(VERSION_FILE), + "\u{feff}m_EditorVersion: 2022.3.62f1\r\nm_EditorVersionWithRevision: ignored\r\n", + ) + .unwrap(); + let info = detect(root).unwrap(); + assert_eq!(info.editor_version.as_deref(), Some("2022.3.62f1")); + assert!(info.solutions.is_empty() && info.projects.is_empty()); + std::fs::write(root.join("Game.sln"), "").unwrap(); + std::fs::write(root.join("Game.slnx"), "").unwrap(); + std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap(); + std::fs::create_dir(root.join("NotAProject.csproj")).unwrap(); + let info = detect(root).unwrap(); + assert_eq!(info.solutions, ["Game.sln", "Game.slnx"]); + assert_eq!(info.projects, ["Assembly-CSharp.csproj"]); + std::fs::write(root.join(VERSION_FILE), "malformed").unwrap(); + assert_eq!(detect(root).unwrap().editor_version, None); + } + + #[test] + fn ordinary_csharp_projects_are_not_unity_projects() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("App.csproj"), "").unwrap(); + std::fs::create_dir(temp.path().join("Assets")).unwrap(); + assert!(detect(temp.path()).is_none()); + } +} diff --git a/crates/ide-workspace/src/unity/assets.rs b/crates/ide-workspace/src/unity/assets.rs new file mode 100644 index 000000000..2aa69bdb1 --- /dev/null +++ b/crates/ide-workspace/src/unity/assets.rs @@ -0,0 +1,254 @@ +//! Filesystem operations for assets under a Unity project's Assets directory. +//! Metadata moves with the asset; duplication gets new GUIDs before publication. + +use crate::{file_moves::FileMove, file_ops::execute_moves}; +use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use std::{ + collections::HashMap, + fs, + path::{Component, Path, PathBuf}, +}; + +mod duplicate; +mod workspace_edit; +pub(crate) use workspace_edit::{expand_workspace_edit, validate_workspace_edit_paths}; +#[cfg(test)] +mod tests; + +pub fn meta_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".meta"); + name.into() +} + +/// Includes Assets itself so callers can protect the asset root. +pub fn project_root(path: &Path) -> Option { + path.ancestors().find_map(|p| { + (p.file_name().is_some_and(|n| if cfg!(windows) { n.eq_ignore_ascii_case("Assets") } else { n == "Assets" }) + && p.parent().is_some_and(super::is_project)) + .then(|| p.parent().unwrap().to_path_buf()) + }) +} + +fn exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn is_meta(path: &Path) -> bool { + path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("meta")) +} + +fn validate_asset(path: &Path) -> IdeResult<()> { + if let Some(root) = project_root(path) { + if key(path) == key(&root.join("Assets")) { + return Err(IdeError::invalid("The Unity Assets root cannot be moved, copied or deleted")); + } + if is_meta(path) && exists(&path.with_extension("")) { + return Err(IdeError::invalid( + "Select the Unity asset instead of its .meta file; metadata is handled together", + )); + } + } + no_links(path) +} + +fn no_links(path: &Path) -> IdeResult<()> { + if path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(IdeError::invalid("Unity asset operations require paths without '..'")); + } + // Trust the opened project's location, including a linked root or parent + // (macOS /var and /tmp). Check inside it, including Library staging paths. + // The outermost root prevents a nested Unity project from hiding a link. + let root = path.ancestors().filter(|p| super::is_project(p)).last(); + for p in path.ancestors().take_while(|p| Some(*p) != root) { + match fs::symlink_metadata(p) { + Ok(m) if is_link(&m) => { + return Err(IdeError::invalid(format!("Unity asset operations do not follow links: {}", p.display()))); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) +} + +fn is_link(metadata: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + // Also reject directory junctions and other reparse points. + metadata.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + metadata.file_type().is_symlink() + } +} + +fn key(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { s.to_lowercase() } else { s } +} + +fn vacant(from: &Path, to: &Path) -> IdeResult<()> { + if exists(to) { + if !exists(from) { + return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", to.display()))); + } + ide_vfs::validate_rename(from, to)?; + } + Ok(()) +} + +/// Expand before applying reference edits, so orphan metadata conflicts cannot +/// leave a partially refactored asset. Metadata goes first on publication. +pub fn expand_moves(moves: &[FileMove]) -> IdeResult> { + let mut out = Vec::new(); + for m in moves { + let unity = project_root(&m.from).is_some() || project_root(&m.to).is_some(); + if unity { + // A caller may explicitly select both an asset and its metadata. + if is_meta(&m.from) + && moves + .iter() + .any(|a| meta_path(&a.from) == m.from.to_path_buf() && meta_path(&a.to) == m.to.to_path_buf()) + { + continue; + } + validate_asset(&m.from)?; + validate_asset(&m.to)?; + let from = meta_path(&m.from); + let to = meta_path(&m.to); + no_links(&from)?; + no_links(&to)?; + vacant(&from, &to)?; + if exists(&from) { + out.push(FileMove { from: from.into(), to: to.into() }); + } + } + out.push(m.clone()); + } + Ok(out) +} + +pub(crate) fn move_one(from: &Path, to: &Path) -> IdeResult<()> { + if project_root(from).is_some() || project_root(to).is_some() { + no_links(from)?; + no_links(to)?; + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + // A failed cross-volume copy/delete cannot be rolled back reliably. + // Keep paired Unity moves atomic per entry and let rename report it. + fs::rename(from, to)?; + Ok(()) + } else { + ide_vfs::rename(from, to) + } +} + +/// The paths Local History must snapshot before an asset deletion. +pub fn deletion_paths(path: &Path) -> IdeResult> { + validate_asset(path)?; + let meta = meta_path(path); + no_links(&meta)?; + let mut paths = vec![path.to_path_buf()]; + if exists(&meta) { + paths.push(meta); + } + Ok(paths) +} + +fn staging(root: &Path) -> IdeResult { + let parent = root.join("Library/OpenIDEA/AssetOperations"); + no_links(&parent)?; + fs::create_dir_all(&parent)?; + let dir = parent.join(uuid::Uuid::new_v4().simple().to_string()); + fs::create_dir(&dir)?; + Ok(dir) +} + +pub fn delete(path: &Path) -> IdeResult<()> { + delete_with(path, &ide_vfs::delete) +} + +fn delete_with(path: &Path, remove: &dyn Fn(&Path) -> IdeResult<()>) -> IdeResult<()> { + let paths = deletion_paths(path)?; + let root = project_root(path).ok_or_else(|| IdeError::invalid("Expected a Unity asset path"))?; + let stage = staging(&root)?; + let payload = stage.join("files"); + fs::create_dir(&payload)?; + let moves: Vec<_> = paths + .iter() + .map(|p| FileMove { from: p.clone().into(), to: payload.join(p.file_name().unwrap()).into() }) + .collect(); + // Recycle-bin restore returns the group to staging. Keep its destinations + // with it so originals can also be recovered without the IDE's history. + let manifest = stage.join("restore-paths.json"); + fs::write(&manifest, serde_json::to_vec_pretty(&paths).map_err(|e| IdeError::internal(e.to_string()))?)?; + // Never automatically delete this staging directory on failure: it may + // contain originals when rollback fails. + if let Err(e) = execute_moves(&moves, &move_one) { + if fs::remove_dir(&payload).is_ok() { + let _ = fs::remove_file(&manifest); + let _ = fs::remove_dir(&stage); + } + return Err(e); + } + if let Err(mut e) = remove(&stage) { + let back: Vec<_> = moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect(); + if let Err(rollback) = execute_moves(&back, &move_one) { + e.message.push_str(&format!("; recovery files at {}: {rollback}", stage.display())); + } + if fs::remove_dir(&payload).is_ok() { + let _ = fs::remove_file(&manifest); + let _ = fs::remove_dir(&stage); + } + return Err(e); + } + Ok(()) +} + +pub fn copy(from: &Path, to: &Path) -> IdeResult<()> { + let root = project_root(to) + .or_else(|| project_root(from)) + .ok_or_else(|| IdeError::invalid("Expected a Unity asset path"))?; + validate_asset(from)?; + validate_asset(to)?; + if key(from) == key(to) || key(to).starts_with(&(key(from) + "/")) { + return Err(IdeError::invalid("Cannot copy an asset onto or into itself")); + } + if exists(to) { + return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", to.display()))); + } + let src_meta = meta_path(from); + let dst_meta = meta_path(to); + no_links(&src_meta)?; + no_links(&dst_meta)?; + // Copies never overwrite metadata, including orphan destination metadata. + if exists(&dst_meta) { + return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", dst_meta.display()))); + } + let stage = staging(&root)?; + let result = (|| { + let asset = stage.join(from.file_name().ok_or_else(|| IdeError::invalid("Missing asset name"))?); + duplicate::copy_tree(from, &asset)?; + let meta = meta_path(&asset); + if exists(&src_meta) { + duplicate::copy_tree(&src_meta, &meta)?; + } + if project_root(to).is_some() { + duplicate::regenerate(&stage)?; + } + let mut moves = Vec::new(); + if exists(&meta) { + moves.push(FileMove { from: meta.into(), to: dst_meta.into() }); + } + moves.push(FileMove { from: asset.into(), to: to.into() }); + execute_moves(&moves, &move_one) + })(); + // Only disposable copies live here, never the source assets. + let _ = fs::remove_dir_all(&stage); + result +} diff --git a/crates/ide-workspace/src/unity/assets/duplicate.rs b/crates/ide-workspace/src/unity/assets/duplicate.rs new file mode 100644 index 000000000..f35642ae1 --- /dev/null +++ b/crates/ide-workspace/src/unity/assets/duplicate.rs @@ -0,0 +1,236 @@ +use super::*; +use std::io::Read; + +const TEXT_LIMIT: u64 = 32 * 1024 * 1024; + +pub(super) fn copy_tree(from: &Path, to: &Path) -> IdeResult<()> { + no_links(from)?; + let metadata = fs::symlink_metadata(from)?; + if metadata.is_dir() { + fs::create_dir(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + copy_tree(&entry.path(), &to.join(entry.file_name()))?; + } + } else if metadata.is_file() { + fs::copy(from, to)?; + } else { + return Err(IdeError::invalid(format!("Not a regular Unity asset: {}", from.display()))); + } + Ok(()) +} + +fn files(dir: &Path, out: &mut Vec) -> IdeResult<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + files(&entry.path(), out)?; + } else { + out.push(entry.path()); + } + } + Ok(()) +} + +fn read_text(path: &Path) -> IdeResult { + let mut bytes = Vec::new(); + fs::File::open(path)?.take(TEXT_LIMIT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > TEXT_LIMIT { + return Err(IdeError::invalid(format!( + "Unity serialized asset exceeds the 32 MiB copy limit: {}", + path.display() + ))); + } + String::from_utf8(bytes) + .map_err(|_| IdeError::invalid(format!("Cannot rewrite binary Unity metadata: {}", path.display()))) +} + +fn guid(value: &str) -> bool { + value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit()) && value.bytes().any(|b| b != b'0') +} + +pub(super) fn regenerate(stage: &Path) -> IdeResult<()> { + let mut paths = Vec::new(); + files(stage, &mut paths)?; + let mut ids = HashMap::new(); + for path in paths.iter().filter(|p| is_meta(p)) { + let text = read_text(path)?; + let values: Vec<_> = text + .trim_start_matches('\u{feff}') + .lines() + .filter_map(|l| l.strip_prefix("guid:").map(str::trim)) + .collect(); + if values.len() != 1 || !guid(values[0]) { + return Err(IdeError::invalid(format!("Missing or invalid GUID in {}", path.display()))); + } + if ids.insert(values[0].to_ascii_lowercase(), uuid::Uuid::new_v4().simple().to_string()).is_some() { + return Err(IdeError::invalid("Duplicate source GUIDs; resolve them in Unity before copying")); + } + } + for path in paths { + let ext = path.extension().unwrap_or_default().to_string_lossy().to_ascii_lowercase(); + if ext == "asmdef" || ext == "asmref" { + let text = read_text(&path)?; + let mut json: serde_json::Value = + serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?; + if rewrite_json(&mut json, &ids) { + fs::write(&path, serde_json::to_string_pretty(&json).map_err(|e| IdeError::internal(e.to_string()))?)?; + } + continue; + } + let mut header = [0; 32]; + let n = fs::File::open(&path)?.read(&mut header)?; + let header = String::from_utf8_lossy(&header[..n]); + let header = header.trim_start_matches('\u{feff}'); + let yaml = header.starts_with("%YAML") || header.starts_with("--- !u!"); + if !yaml && !is_meta(&path) { + // These formats may be binary under Mixed/Force Binary. We cannot + // safely preserve internal references without Unity deserialization. + if matches!( + ext.as_str(), + "asset" + | "prefab" + | "unity" + | "mat" + | "anim" + | "controller" + | "overridecontroller" + | "playable" + | "mask" + | "rendertexture" + | "physicmaterial" + | "physicsmaterial2d" + | "lighting" + | "terrainlayer" + | "guiskin" + | "flare" + | "cubemap" + | "spriteatlas" + | "spriteatlasv2" + | "preset" + | "shadervariants" + | "giparams" + | "lightingdata" + | "fontsettings" + | "texture2d" + | "mesh" + ) { + return Err(IdeError::invalid(format!( + "Cannot copy binary Unity serialized asset {}; use Force Text serialization in Unity", + path.display() + ))); + } + continue; + } + let before = read_text(&path)?; + let after = rewrite_yaml(&before, &ids); + if before != after { + fs::write(&path, after)?; + } + } + Ok(()) +} + +fn rewrite_json(value: &mut serde_json::Value, ids: &HashMap) -> bool { + match value { + serde_json::Value::String(s) => { + if let Some(id) = s.strip_prefix("GUID:").and_then(|id| ids.get(&id.to_ascii_lowercase())) { + *s = format!("GUID:{id}"); + true + } else { + false + } + } + serde_json::Value::Array(a) => { + let mut changed = false; + for v in a { + changed |= rewrite_json(v, ids); + } + changed + } + serde_json::Value::Object(o) => { + let mut changed = false; + for v in o.values_mut() { + changed |= rewrite_json(v, ids); + } + changed + } + _ => false, + } +} + +/// Patch GUID scalar tokens without reserializing Unity's YAML dialect. Quoted +/// strings, comments and indented block scalars are not asset references. +fn rewrite_yaml(text: &str, ids: &HashMap) -> String { + let mut bytes = text.as_bytes().to_vec(); + let mut offset = 0; + let mut block_indent = None; + let mut quote = None; + for line in text.split_inclusive('\n') { + let raw = line.as_bytes(); + let indent = raw.iter().take_while(|b| **b == b' ').count(); + if block_indent.is_some_and(|n| indent > n || line.trim().is_empty()) { + offset += raw.len(); + continue; + } + block_indent = None; + let mut i = 0; + while i < raw.len() { + let b = raw[i]; + if let Some(q) = quote { + if q == b'"' && b == b'\\' { + i += 2; + continue; + } + if b == q { + if q == b'\'' && raw.get(i + 1) == Some(&q) { + i += 2; + continue; + } + quote = None; + } + i += 1; + continue; + } + if b == b'#' && (i == 0 || raw[i - 1].is_ascii_whitespace()) { + break; + } + if b == b'"' || b == b'\'' { + let prefix = line[..i].trim(); + // Apostrophes inside plain scalars (e.g. Hero's Sword) do not + // open a quoted scalar or suppress references on later lines. + if prefix.is_empty() || prefix.ends_with([':', '-', '[', '{', ',']) { + quote = Some(b); + i += 1; + continue; + } + } + if (b == b'|' || b == b'>') && line[..i].trim_end().ends_with([':', '-']) { + block_indent = Some(indent); + break; + } + if raw[i..].starts_with(b"guid:") { + let prefix = line[..i].trim_start_matches('\u{feff}').trim_end(); + if prefix.is_empty() || prefix.ends_with(['{', ',']) { + let start = i + 5 + raw[i + 5..].iter().take_while(|b| **b == b' ' || **b == b'\t').count(); + let end = start + 32; + if end <= raw.len() + && raw[start..end].iter().all(u8::is_ascii_hexdigit) + && (end == raw.len() + || raw[end].is_ascii_whitespace() + || matches!(raw[end], b',' | b'}' | b'#')) + && let Some(new) = ids.get(&line[start..end].to_ascii_lowercase()) + { + bytes[offset + start..offset + end].copy_from_slice(new.as_bytes()); + i = end; + continue; + } + } + } + // Non-ASCII bytes cannot begin any token handled above. + i += 1; + } + offset += raw.len(); + } + String::from_utf8(bytes).expect("GUID substitutions preserve UTF-8") +} diff --git a/crates/ide-workspace/src/unity/assets/tests.rs b/crates/ide-workspace/src/unity/assets/tests.rs new file mode 100644 index 000000000..3dd670e11 --- /dev/null +++ b/crates/ide-workspace/src/unity/assets/tests.rs @@ -0,0 +1,217 @@ +use super::*; +use crate::file_ops::{move_files, rename}; +use crate::{documents::Documents, file_moves::FileMoves}; +use ide_core::{LanguageId, WorkspaceEdit}; +use std::sync::Arc; + +mod links; + +const A: &str = "11111111111111111111111111111111"; +const B: &str = "22222222222222222222222222222222"; +const C: &str = "33333333333333333333333333333333"; + +fn project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join("Assets")).unwrap(); + fs::create_dir(tmp.path().join("ProjectSettings")).unwrap(); + fs::write(tmp.path().join(super::super::VERSION_FILE), "m_EditorVersion: 6000.0.1f1").unwrap(); + tmp +} + +fn asset(root: &Path, name: &str, content: &str, id: &str) -> PathBuf { + let path = root.join("Assets").join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, content).unwrap(); + fs::write(meta_path(&path), format!("fileFormatVersion: 2\r\nguid: {id}\r\nImporter:\r\n setting: keep\r\n")) + .unwrap(); + path +} + +fn id(path: &Path) -> String { + fs::read_to_string(meta_path(path)) + .unwrap() + .lines() + .find_map(|l| l.strip_prefix("guid: ").map(str::to_owned)) + .unwrap() +} + +#[test] +fn rename_preserves_metadata_and_undo_retries_after_conflict() { + let tmp = project(); + let src = asset(tmp.path(), "Player.cs", "class Player {}", A); + let dst = src.with_file_name("Hero.cs"); + let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext"))); + let stack = FileMoves::new(); + let moves = [FileMove { from: src.clone().into(), to: dst.clone().into() }]; + let token = stack.perform(&docs, None, &moves, &WorkspaceEdit::default(), "Rename", &|_| Ok(())).unwrap(); + assert_eq!(id(&dst), A); + assert!(!meta_path(&src).exists()); + fs::write(meta_path(&src), "conflict").unwrap(); + assert!(stack.undo(&docs, token).is_err()); + assert!(stack.can_undo(token)); + assert!(dst.exists()); + fs::remove_file(meta_path(&src)).unwrap(); + let undone = stack.undo(&docs, token).unwrap(); + assert_eq!(undone.moves.len(), 2); + assert_eq!(id(&src), A); + assert!(!dst.exists()); +} + +#[test] +fn orphan_metadata_conflicts_and_standalone_metadata_are_protected() { + let tmp = project(); + let src = asset(tmp.path(), "Player.cs", "code", A); + let dst = src.with_file_name("Hero.cs"); + fs::write(meta_path(&dst), "orphan").unwrap(); + assert!(rename(&src, &dst).is_err()); + assert!(copy(&src, &dst).is_err()); + assert!(src.exists() && !dst.exists()); + assert_eq!(id(&src), A); + assert!(rename(&meta_path(&src), &src.with_file_name("Other.meta")).is_err()); + assert!(delete_with(&meta_path(&src), &|_| panic!("must reject before deletion")).is_err()); + assert!(rename(&tmp.path().join("Assets"), &tmp.path().join("Renamed")).is_err()); +} + +#[test] +fn folder_copy_regenerates_guids_and_only_internal_references() { + let tmp = project(); + let root = tmp.path(); + let text = format!( + "%YAML 1.1\n--- !u!1 &1\nObject:\n internal: {{fileID: 1, guid: {B}, type: 3}}\n external: {{fileID: 2, guid: {C}, type: 3}}\n quoted: \"guid: {B}\"\n block: |\n guid: {B}\n" + ); + let src = asset(root, "Group/Scene.prefab", &text, A); + asset(root, "Group/Script.cs", "// untouched", B); + fs::write(root.join("Assets/Group.meta"), format!("guid: {C}\nfolderAsset: yes\n")).unwrap(); + // An actual external id not copied into Group. + let external = "44444444444444444444444444444444"; + fs::write( + &src, + text.replace(&format!("external: {{fileID: 2, guid: {C}"), &format!("external: {{fileID: 2, guid: {external}")), + ) + .unwrap(); + fs::write(root.join("Assets/Group/Assembly.asmdef"), format!(r#"{{"references":["GUID:{B}","GUID:{external}"]}}"#)) + .unwrap(); + let to = root.join("Assets/Group Copy"); + copy(&root.join("Assets/Group"), &to).unwrap(); + let script = id(&to.join("Script.cs")); + assert_ne!(script, B); + assert_ne!(id(&to.join("Scene.prefab")), A); + assert_ne!(id(&to), C); + assert_eq!(id(&src), A); + let copied = fs::read_to_string(to.join("Scene.prefab")).unwrap(); + assert!(copied.contains(&format!("internal: {{fileID: 1, guid: {script}"))); + assert!(copied.contains(&format!("external: {{fileID: 2, guid: {external}"))); + assert!(copied.contains(&format!("quoted: \"guid: {B}\""))); + assert!(copied.contains(&format!("block: |\n guid: {B}"))); + assert!(fs::read_to_string(meta_path(&to.join("Script.cs"))).unwrap().contains("setting: keep\r\n")); + let assembly = fs::read_to_string(to.join("Assembly.asmdef")).unwrap(); + assert!(assembly.contains(&format!("GUID:{script}")) && assembly.contains(external)); +} + +#[test] +fn invalid_or_binary_copy_never_publishes_partial_assets() { + let tmp = project(); + let src = asset(tmp.path(), "Group/Data.asset", "binary data", A); + let from = src.parent().unwrap(); + let to = from.with_file_name("Copy"); + assert!(copy(from, &to).is_err()); + assert!(!to.exists() && !meta_path(&to).exists()); + assert_eq!(id(&src), A); + fs::write(&src, "%YAML 1.1\n--- !u!1 &1\n").unwrap(); + fs::write(meta_path(&src), "guid: invalid\n").unwrap(); + assert!(copy(from, &to).is_err()); + assert!(!to.exists()); +} + +#[test] +fn deletion_groups_sidecar_and_restores_both_on_trash_failure() { + let tmp = project(); + let src = asset(tmp.path(), "Player.cs", "code", A); + assert_eq!(deletion_paths(&src).unwrap(), vec![src.clone(), meta_path(&src)]); + let error = delete_with(&src, &|stage| { + assert!(stage.join("files/Player.cs").exists()); + assert!(stage.join("files/Player.cs.meta").exists()); + let destinations: Vec = + serde_json::from_slice(&fs::read(stage.join("restore-paths.json")).unwrap()).unwrap(); + assert_eq!(destinations, vec![src.clone(), meta_path(&src)]); + Err(IdeError::internal("trash unavailable")) + }) + .unwrap_err(); + assert!(error.message.contains("trash unavailable")); + assert!(src.exists()); + assert_eq!(id(&src), A); + delete_with(&src, &ide_vfs::delete_permanently).unwrap(); + assert!(!src.exists() && !meta_path(&src).exists()); +} + +#[test] +fn selecting_both_asset_and_sidecar_does_not_move_twice() { + let tmp = project(); + let src = asset(tmp.path(), "Player.cs", "code", A); + let dst = src.with_file_name("Hero.cs"); + let expanded = expand_moves(&[ + FileMove { from: src.clone().into(), to: dst.clone().into() }, + FileMove { from: meta_path(&src).into(), to: meta_path(&dst).into() }, + ]) + .unwrap(); + assert_eq!(expanded.len(), 2); + move_files(&expanded).unwrap(); + assert_eq!(id(&dst), A); +} + +#[test] +fn non_unity_operations_do_not_touch_adjacent_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("file.txt"); + let dst = tmp.path().join("copy.txt"); + fs::write(&src, "text").unwrap(); + fs::write(meta_path(&src), "unrelated").unwrap(); + crate::file_ops::copy(&src, &dst).unwrap(); + assert!(!meta_path(&dst).exists()); + rename(&src, &tmp.path().join("moved.txt")).unwrap(); + assert!(meta_path(&src).exists()); +} + +#[test] +fn failed_second_move_rolls_back_metadata() { + let tmp = project(); + let src = asset(tmp.path(), "Player.cs", "code", A); + let meta_dst = src.with_file_name("Moved.cs.meta"); + let blocker = tmp.path().join("Assets/blocked"); + fs::write(&blocker, "not a directory").unwrap(); + let moves = [ + FileMove { from: meta_path(&src).into(), to: meta_dst.clone().into() }, + FileMove { from: src.clone().into(), to: blocker.join("Moved.cs").into() }, + ]; + assert!(move_files(&moves).is_err()); + assert_eq!(id(&src), A); + assert!(src.exists() && !meta_dst.exists()); +} + +#[test] +fn yaml_scalar_text_does_not_change_reference_detection() { + let tmp = project(); + let text = format!( + "%YAML 1.1\n--- !u!1 &1\nObject:\n name: Hero's Sword\n self: {{fileID: 1, guid: {A}, type: 3}}\n text: \"escaped \\\" guid: {A}\"\n multiline: 'value\n guid: {A}'\n selfAgain: {{fileID: 1, guid: {A}, type: 3}}\n" + ); + let src = asset(tmp.path(), "Player.prefab", &text, A); + let dst = src.with_file_name("Copied.prefab"); + copy(&src, &dst).unwrap(); + let new_id = id(&dst); + let copied = fs::read_to_string(&dst).unwrap(); + assert!(copied.contains(&format!("self: {{fileID: 1, guid: {new_id}"))); + assert!(copied.contains(&format!("selfAgain: {{fileID: 1, guid: {new_id}"))); + assert!(copied.contains(&format!("multiline: 'value\n guid: {A}'"))); + assert!(copied.contains(&format!("text: \"escaped \\\" guid: {A}\""))); +} + +#[cfg(windows)] +#[test] +fn case_only_rename_keeps_metadata() { + let tmp = project(); + let src = asset(tmp.path(), "Player.cs", "code", A); + let dst = src.with_file_name("player.cs"); + rename(&src, &dst).unwrap(); + assert_eq!(id(&dst), A); + assert!(fs::read_dir(dst.parent().unwrap()).unwrap().any(|e| e.unwrap().file_name() == "player.cs.meta")); +} diff --git a/crates/ide-workspace/src/unity/assets/tests/links.rs b/crates/ide-workspace/src/unity/assets/tests/links.rs new file mode 100644 index 000000000..cabbb1765 --- /dev/null +++ b/crates/ide-workspace/src/unity/assets/tests/links.rs @@ -0,0 +1,233 @@ +use super::*; +use ide_core::FileEdit; + +fn dir_link(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let output = std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(link.to_string_lossy().replace('/', "\\")) + .arg(target.to_string_lossy().replace('/', "\\")) + .creation_flags(0x08000000) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } +} + +#[test] +fn linked_parent_and_project_root_support_asset_operations_and_undo() { + for parent_alias in [false, true] { + let tmp = project(); + let aliases = tempfile::tempdir().unwrap(); + let link = aliases.path().join("alias"); + let root = if parent_alias { + dir_link(tmp.path().parent().unwrap(), &link); + link.join(tmp.path().file_name().unwrap()) + } else { + dir_link(tmp.path(), &link); + link.clone() + }; + let from = asset(&root, "Player.cs", "class Player {}", A); + let to = root.join("Assets/New/Nested/Hero.cs"); + let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext"))); + let moves = FileMoves::new(); + let token = moves + .perform( + &docs, + None, + &[FileMove { from: from.clone().into(), to: to.clone().into() }], + &WorkspaceEdit::default(), + "Move", + &|_| Ok(()), + ) + .unwrap(); + assert_eq!(id(&to), A); + moves.undo(&docs, token).unwrap(); + assert_eq!(id(&from), A); + copy(&from, &to).unwrap(); + assert_ne!(id(&to), A); + assert_eq!(id(&from), A); + delete_with(&to, &ide_vfs::delete_permanently).unwrap(); + assert!(!to.exists() && !meta_path(&to).exists()); + let intermediate = root.join("Assets/Intermediate.cs"); + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![ + FileEdit::Rename { from: from.clone().into(), to: intermediate.clone().into() }, + FileEdit::Rename { from: intermediate.into(), to: to.clone().into() }, + ], + ..Default::default() + }) + .unwrap(); + assert_eq!(id(&to), A); + assert!(!from.exists() && !meta_path(&from).exists()); + } +} + +#[test] +fn internal_directory_links_reject_asset_operations_before_mutation() { + for relative in ["Assets/Linked", "Library", "Library/OpenIDEA", "Library/OpenIDEA/AssetOperations"] { + let tmp = project(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("sentinel"), "keep").unwrap(); + let from = asset(tmp.path(), "Player.cs", "code", A); + let link = tmp.path().join(relative); + fs::create_dir_all(link.parent().unwrap()).unwrap(); + dir_link(outside.path(), &link); + let to = if relative.starts_with("Assets") { link.join("Copy.cs") } else { from.with_file_name("Copy.cs") }; + assert!(copy(&from, &to).unwrap_err().message.contains("do not follow links")); + if relative.starts_with("Assets") { + assert!(rename(&from, &to).is_err()); + let docs = + Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext"))); + assert!( + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Rename { from: from.clone().into(), to: to.clone().into() },], + ..Default::default() + }) + .is_err() + ); + let sentinel = link.join("sentinel"); + assert!( + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![FileEdit::Delete { path: sentinel.into(), recursive: false },], + ..Default::default() + }) + .is_err() + ); + } else { + assert!(delete_with(&from, &|_| panic!("must reject before removal")).is_err()); + } + assert_eq!(id(&from), A); + assert!(from.exists() && !to.exists() && !meta_path(&to).exists()); + assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep"); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1); + } +} + +#[test] +fn linked_assets_root_and_recursive_copy_links_are_rejected() { + let tmp = project(); + let outside = tempfile::tempdir().unwrap(); + fs::remove_dir(tmp.path().join("Assets")).unwrap(); + dir_link(outside.path(), &tmp.path().join("Assets")); + let from = asset(tmp.path(), "Player.cs", "code", A); + assert!(rename(&from, &from.with_file_name("Hero.cs")).is_err()); + assert!(deletion_paths(&from).is_err()); + assert_eq!(id(&from), A); + + let tmp = project(); + let from = asset(tmp.path(), "Group/Player.cs", "code", A); + dir_link(outside.path(), &tmp.path().join("Assets/Group/Linked")); + let to = tmp.path().join("Assets/Copy"); + assert!(copy(from.parent().unwrap(), &to).is_err()); + assert!(!to.exists() && !meta_path(&to).exists()); + assert_eq!(id(&from), A); +} + +#[cfg(unix)] +#[test] +fn linked_asset_and_sidecar_leaves_are_rejected() { + for sidecar in [false, true] { + let tmp = project(); + let outside = tempfile::NamedTempFile::new().unwrap(); + let from = asset(tmp.path(), "Player.cs", "code", A); + let path = if sidecar { meta_path(&from) } else { from.clone() }; + fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink(outside.path(), &path).unwrap(); + assert!(rename(&from, &from.with_file_name("Hero.cs")).is_err()); + assert!(copy(&from, &from.with_file_name("Copy.cs")).is_err()); + assert!(deletion_paths(&from).is_err()); + assert!(fs::symlink_metadata(&path).unwrap().file_type().is_symlink()); + } +} + +#[test] +fn cross_project_operations_validate_each_root_and_keep_metadata() { + let source = project(); + let destination = project(); + let aliases = tempfile::tempdir().unwrap(); + let a = aliases.path().join("source"); + let b = aliases.path().join("destination"); + dir_link(source.path(), &a); + dir_link(destination.path(), &b); + let from = asset(&a, "Player.cs", "code", A); + let copied = b.join("Assets/Copied.cs"); + copy(&from, &copied).unwrap(); + assert_ne!(id(&copied), A); + assert_eq!(id(&from), A); + let moved = b.join("Assets/Moved.cs"); + rename(&from, &moved).unwrap(); + assert_eq!(id(&moved), A); + assert!(!from.exists() && !meta_path(&from).exists()); +} + +#[test] +fn external_links_do_not_become_trusted_by_importing_into_unity() { + let tmp = project(); + let external = tempfile::tempdir().unwrap(); + let aliases = tempfile::tempdir().unwrap(); + fs::write(external.path().join("File.cs"), "code").unwrap(); + let link = aliases.path().join("external"); + dir_link(external.path(), &link); + let from = link.join("File.cs"); + let to = tmp.path().join("Assets/Imported.cs"); + assert!(copy(&from, &to).is_err()); + assert!(rename(&from, &to).is_err()); + assert!(from.exists() && !to.exists()); +} + +#[test] +fn folder_rename_cannot_hide_linked_children_in_later_transaction_operations() { + let tmp = project(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("sentinel"), "keep").unwrap(); + let from = tmp.path().join("Assets/Group"); + fs::create_dir(&from).unwrap(); + dir_link(outside.path(), &from.join("Linked")); + let to = tmp.path().join("Assets/Moved"); + let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext"))); + let error = docs + .apply_workspace_edit(&WorkspaceEdit { + files: vec![ + FileEdit::Rename { from: from.clone().into(), to: to.clone().into() }, + FileEdit::Delete { + path: (if cfg!(windows) { tmp.path().join("Assets/moved") } else { to.clone() }) + .join("Linked/sentinel") + .into(), + recursive: false, + }, + ], + ..Default::default() + }) + .unwrap_err(); + assert!(error.message.contains("do not follow links")); + assert!(from.exists() && !to.exists()); + assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep"); +} + +#[test] +fn directory_transactions_reject_nested_links_before_snapshotting() { + for delete in [false, true] { + let tmp = project(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("sentinel"), "keep").unwrap(); + let from = tmp.path().join("Assets/Group"); + fs::create_dir(&from).unwrap(); + dir_link(outside.path(), &from.join("Linked")); + let to = tmp.path().join("Assets/Moved"); + let file = if delete { + FileEdit::Delete { path: from.clone().into(), recursive: true } + } else { + FileEdit::Rename { from: from.clone().into(), to: to.clone().into() } + }; + let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext"))); + let error = docs.apply_workspace_edit(&WorkspaceEdit { files: vec![file], ..Default::default() }).unwrap_err(); + assert!(error.message.contains("do not follow links")); + assert!(from.exists() && !to.exists()); + assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep"); + } +} diff --git a/crates/ide-workspace/src/unity/assets/workspace_edit.rs b/crates/ide-workspace/src/unity/assets/workspace_edit.rs new file mode 100644 index 000000000..c3a5baf1a --- /dev/null +++ b/crates/ide-workspace/src/unity/assets/workspace_edit.rs @@ -0,0 +1,99 @@ +//! Include sidecars in the document transaction's preflight, recovery and undo. +use super::{exists, fs, is_link, key, meta_path, no_links, project_root, validate_asset}; +use ide_core::{FileEdit, IdeError, IdeResult, WorkspaceEdit}; +use std::path::Path; + +pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> { + for file in &edit.files { + let validate = |path: &Path| -> IdeResult<()> { + validate_asset(path)?; + no_links(&meta_path(path))?; + no_nested_links(path) + }; + match file { + FileEdit::Rename { from, to } if project_root(from).is_some() || project_root(to).is_some() => { + validate(from)?; + validate(to)?; + } + FileEdit::Delete { path, .. } if project_root(path).is_some() => validate(path)?, + _ => {} + } + } + Ok(()) +} + +// Transactions snapshot directories recursively. Reject linked descendants +// before backup, including junctions and children renamed by a later operation. +fn no_nested_links(path: &Path) -> IdeResult<()> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if is_link(&metadata) { + return Err(IdeError::invalid(format!("Unity asset operations do not follow links: {}", path.display()))); + } + if metadata.is_dir() { + for entry in fs::read_dir(path)? { + no_nested_links(&entry?.path())?; + } + } + Ok(()) +} + +// Resolve against the state produced by earlier operations without touching disk. +// This also handles renaming a folder before renaming/deleting a child asset. +fn present(path: &Path, preceding: &[FileEdit]) -> bool { + let mut path = path.to_path_buf(); + for op in preceding.iter().rev() { + match op { + FileEdit::Rename { from, to } => { + if let Ok(relative) = path.strip_prefix(to) { + // Joining an empty suffix adds a separator to a file path; + // that makes an existing sidecar look like a missing directory. + path = if relative.as_os_str().is_empty() { from.to_path_buf() } else { from.join(relative) }; + } else if path.starts_with(from) { + return false; + } + } + FileEdit::Delete { path: deleted, .. } if path.starts_with(deleted) => return false, + FileEdit::Create { path: created, .. } if path == created.to_path_buf() => return true, + _ => {} + } + } + exists(&path) +} + +pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> { + let mut files = Vec::new(); + for file in &edit.files { + match file { + FileEdit::Rename { from, to } if project_root(from).is_some() || project_root(to).is_some() => { + validate_asset(from)?; + validate_asset(to)?; + let source = meta_path(from); + let destination = meta_path(to); + no_links(&source)?; + no_links(&destination)?; + if present(&destination, &files) && key(&source) != key(&destination) { + return Err(IdeError::invalid(format!("{} already exists", destination.display()))); + } + if present(&source, &files) { + files.push(FileEdit::Rename { from: source.into(), to: destination.into() }); + } + } + FileEdit::Delete { path, .. } if project_root(path).is_some() => { + validate_asset(path)?; + let meta = meta_path(path); + no_links(&meta)?; + if present(&meta, &files) { + files.push(FileEdit::Delete { path: meta.into(), recursive: false }); + } + } + _ => {} + } + files.push(file.clone()); + } + edit.files = files; + Ok(()) +} diff --git a/crates/ide-workspace/tests/file_operations.rs b/crates/ide-workspace/tests/file_operations.rs new file mode 100644 index 000000000..6999b0384 --- /dev/null +++ b/crates/ide-workspace/tests/file_operations.rs @@ -0,0 +1,147 @@ +use ide_workspace::file_ops; +use std::{fs, path::Path}; + +fn metadata_path(path: &Path) -> std::path::PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".meta"); + name.into() +} + +fn unity_project(root: &Path) { + fs::create_dir_all(root.join("Assets")).unwrap(); + fs::create_dir_all(root.join("ProjectSettings")).unwrap(); + fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 2022.3.40f1").unwrap(); +} + +fn directory_alias(target: &Path, alias: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, alias).unwrap(); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let output = std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(alias.to_string_lossy().replace('/', "\\")) + .arg(target.to_string_lossy().replace('/', "\\")) + .creation_flags(0x08000000) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } +} + +#[test] +fn ordinary_rename_through_an_alias_keeps_vfs_same_file_behavior() { + let dir = tempfile::tempdir().unwrap(); + let aliases = tempfile::tempdir().unwrap(); + let alias = aliases.path().join("project"); + directory_alias(dir.path(), &alias); + let from = dir.path().join("File.txt"); + let to = alias.join("File.txt"); + fs::write(&from, "keep").unwrap(); + ide_vfs::rename(&from, &to).unwrap(); + file_ops::rename(&from, &to).unwrap(); + let copied = alias.join("Copy.txt"); + file_ops::copy(&to, &copied).unwrap(); + assert_eq!(fs::read_to_string(copied).unwrap(), "keep"); + assert_eq!(fs::read_to_string(from).unwrap(), "keep"); +} + +#[test] +fn plain_files_and_non_assets_in_unity_keep_adjacent_metadata_untouched() { + for unity in [false, true] { + let dir = tempfile::tempdir().unwrap(); + if unity { + unity_project(dir.path()); + } + let from = dir.path().join("File.txt"); + let copied = dir.path().join("Copy.txt"); + let renamed = dir.path().join("file.txt"); + fs::write(&from, "data").unwrap(); + fs::write(metadata_path(&from), "unrelated").unwrap(); + file_ops::copy(&from, &copied).unwrap(); + assert!(!metadata_path(&copied).exists()); + file_ops::rename(&from, &renamed).unwrap(); + assert_eq!(fs::read_to_string(&renamed).unwrap(), "data"); + assert_eq!(file_ops::deletion_paths(&renamed).unwrap(), [renamed]); + assert_eq!(fs::read_to_string(metadata_path(&from)).unwrap(), "unrelated"); + } +} + +#[test] +fn mixed_move_and_undo_apply_rules_only_to_unity_assets() { + use ide_core::{LanguageId, WorkspaceEdit}; + use ide_workspace::{Documents, FileMove, FileMoves}; + use std::sync::Arc; + + let unity = tempfile::tempdir().unwrap(); + unity_project(unity.path()); + let plain = tempfile::tempdir().unwrap(); + let aliases = tempfile::tempdir().unwrap(); + let alias = aliases.path().join("plain"); + directory_alias(plain.path(), &alias); + let source = unity.path().join("Assets/Player.cs"); + let destination = unity.path().join("Assets/Hero.cs"); + let ordinary = alias.join("plain.txt"); + let ordinary_to = alias.join("moved.txt"); + fs::write(&source, "class Player {}").unwrap(); + fs::write(metadata_path(&source), "guid: 11111111111111111111111111111111\n").unwrap(); + fs::write(&ordinary, "plain").unwrap(); + fs::write(metadata_path(&ordinary), "unrelated").unwrap(); + let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext"))); + let moves = FileMoves::new(); + let token = moves + .perform( + &docs, + None, + &[ + FileMove { from: ordinary.clone().into(), to: ordinary_to.clone().into() }, + FileMove { from: source.clone().into(), to: destination.clone().into() }, + ], + &WorkspaceEdit::default(), + "Move", + &|_| Ok(()), + ) + .unwrap(); + assert!(destination.exists() && metadata_path(&destination).exists()); + assert!(ordinary_to.exists() && !metadata_path(&ordinary_to).exists()); + assert!(metadata_path(&ordinary).exists()); + moves.undo(&docs, token).unwrap(); + assert!(source.exists() && metadata_path(&source).exists()); + assert!(ordinary.exists() && metadata_path(&ordinary).exists()); + assert!(!destination.exists() && !metadata_path(&destination).exists()); + + let blocker = alias.join("blocked"); + fs::write(&blocker, "not a directory").unwrap(); + assert!( + moves + .perform( + &docs, + None, + &[ + FileMove { from: source.clone().into(), to: destination.clone().into() }, + FileMove { from: ordinary.clone().into(), to: blocker.join("file.txt").into() }, + ], + &WorkspaceEdit::default(), + "Move", + &|_| Ok(()), + ) + .is_err() + ); + assert!(source.exists() && metadata_path(&source).exists()); + assert!(ordinary.exists() && metadata_path(&ordinary).exists()); + assert!(!destination.exists() && !metadata_path(&destination).exists()); +} + +#[test] +fn vfs_operations_remain_unaware_of_unity_metadata() { + let dir = tempfile::tempdir().unwrap(); + unity_project(dir.path()); + let from = dir.path().join("Assets/Player.cs"); + let to = dir.path().join("Assets/Hero.cs"); + fs::write(&from, "code").unwrap(); + fs::write(metadata_path(&from), "metadata").unwrap(); + ide_vfs::rename(&from, &to).unwrap(); + assert!(to.exists() && !metadata_path(&to).exists()); + assert!(metadata_path(&from).exists()); +} diff --git a/crates/ide-workspace/tests/workspace_edits.rs b/crates/ide-workspace/tests/workspace_edits.rs index e88257d97..e58d7f52e 100644 --- a/crates/ide-workspace/tests/workspace_edits.rs +++ b/crates/ide-workspace/tests/workspace_edits.rs @@ -9,6 +9,95 @@ fn documents() -> Documents { Documents::new(Arc::new(NullSink), Box::new(|_, _| LanguageId::plain_text())) } +fn meta(path: &std::path::Path) -> std::path::PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".meta"); + name.into() +} + +fn unity_project() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("Assets")).unwrap(); + std::fs::create_dir_all(tmp.path().join("ProjectSettings")).unwrap(); + std::fs::write(tmp.path().join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 2022.3.40f1\n").unwrap(); + tmp +} + +#[test] +fn unity_rename_chain_and_delete_share_metadata_undo() { + let tmp = unity_project(); + let original = tmp.path().join("Assets/Player.cs"); + let renamed = tmp.path().join("Assets/Hero.cs"); + std::fs::write(&original, "old").unwrap(); + std::fs::write(meta(&original), "guid: unchanged").unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![ + FileEdit::Rename { from: original.clone().into(), to: renamed.clone().into() }, + FileEdit::Delete { path: renamed.clone().into(), recursive: false }, + ], + ..Default::default() + }) + .unwrap(); + for path in [&original, &renamed, &meta(&original), &meta(&renamed)] { + assert!(!path.exists(), "{} should be removed", path.display()); + } + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert_eq!(std::fs::read_to_string(&original).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(meta(&original)).unwrap(), "guid: unchanged"); + assert!(!renamed.exists() && !meta(&renamed).exists()); +} + +#[test] +fn unity_metadata_conflict_keeps_earlier_edits_unchanged() { + let tmp = unity_project(); + let original = tmp.path().join("Assets/Player.cs"); + let renamed = tmp.path().join("Assets/Hero.cs"); + std::fs::write(&original, "old").unwrap(); + std::fs::write(meta(&original), "guid: original").unwrap(); + std::fs::write(meta(&renamed), "guid: orphan").unwrap(); + assert!( + documents() + .apply_workspace_edit(&WorkspaceEdit { + files: vec![ + edit(&original, "new"), + FileEdit::Rename { from: original.clone().into(), to: renamed.clone().into() } + ], + ..Default::default() + }) + .is_err() + ); + assert_eq!(std::fs::read_to_string(&original).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(meta(&original)).unwrap(), "guid: original"); + assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), "guid: orphan"); +} + +#[test] +fn unity_folder_rename_then_child_rename_preserves_metadata() { + let tmp = unity_project(); + let folder = tmp.path().join("Assets/Before"); + let moved = tmp.path().join("Assets/After"); + std::fs::create_dir(&folder).unwrap(); + std::fs::write(meta(&folder), "folder guid").unwrap(); + std::fs::write(folder.join("Player.cs"), "old").unwrap(); + std::fs::write(meta(&folder.join("Player.cs")), "script guid").unwrap(); + let (docs, events) = recorded_documents(); + docs.apply_workspace_edit(&WorkspaceEdit { + files: vec![ + FileEdit::Rename { from: folder.clone().into(), to: moved.clone().into() }, + FileEdit::Rename { from: moved.join("Player.cs").into(), to: moved.join("Hero.cs").into() }, + ], + ..Default::default() + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(meta(&moved)).unwrap(), "folder guid"); + assert_eq!(std::fs::read_to_string(meta(&moved.join("Hero.cs"))).unwrap(), "script guid"); + assert!(!meta(&moved.join("Player.cs")).exists()); + docs.undo_workspace_edit(undo_id(&events)).unwrap(); + assert_eq!(std::fs::read_to_string(meta(&folder.join("Player.cs"))).unwrap(), "script guid"); + assert!(!moved.exists() && !meta(&moved).exists()); +} + fn edit(path: &Path, text: &str) -> FileEdit { FileEdit::Edit { path: path.to_owned().into(), diff --git a/docs/unity.md b/docs/unity.md new file mode 100644 index 000000000..28c9eba22 --- /dev/null +++ b/docs/unity.md @@ -0,0 +1,23 @@ +# Unity projects + +Open the folder containing `Assets` and `ProjectSettings/ProjectVersion.txt`. +OpenIDEA detects Unity without requiring a generated solution and shows the editor +version in the Unity project view. + +The Unity view hides `.meta` files, generated C# project files and cache/build +folders. **Show Meta Files** and **Show Generated Files** reveal them independently; +the regular Project view is still available. Generated folders are excluded from +indexing and search, including folders Unity creates after the project is opened. + +Operations on assets inside `Assets` include their `.meta` files. Rename and move +preserve GUIDs, deletion removes both files, and copy generates new GUIDs and updates +references within the copied group. References to assets outside the group stay +unchanged. File move undo and transactional workspace edits include metadata too. +Destination metadata conflicts are rejected before making changes. + +Projects may be opened through a linked root or parent directory, including macOS +`/var` and `/tmp`. Links inside the project (including Windows junctions) are not +followed by asset operations. For moves/copies to or from ordinary directories, +the external path must have no linked ancestors. Cross-volume asset moves and +copies that require rewriting binary serialized assets are rejected; use Unity +for those operations. diff --git a/languages/csharp.toml b/languages/csharp.toml index fc677e31e..ef8d6bd4a 100644 --- a/languages/csharp.toml +++ b/languages/csharp.toml @@ -10,7 +10,7 @@ tree-sitter = "csharp" [[language-servers]] name = "csharp-ls" command = "csharp-ls" -root-markers = ["*.sln", "*.slnx", "*.csproj", "global.json", "Directory.Build.props"] +root-markers = ["*.sln", "*.slnx", "*.csproj", "global.json", "Directory.Build.props", "ProjectSettings/ProjectVersion.txt"] install-hint = "dotnet tool install --global csharp-ls" [debugger] diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 2e41aebfa..fbecf3c25 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -65,8 +65,8 @@ pub fn fs_create_dir(path: PathBuf) -> IdeResult<()> { #[tauri::command] #[specta::specta] -pub fn fs_rename(from: PathBuf, to: PathBuf) -> IdeResult<()> { - ide_vfs::rename(&from, &to) +pub async fn fs_rename(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> { + fs::rename(&state.host, from, to).await } /// Toggle Read-only Attribute: sets the writable bit (and the open document's diff --git a/src/bindings.ts b/src/bindings.ts index bb7aa5cd4..a4813315d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -42,6 +42,7 @@ export const commands = { isGit: boolean, settings: Settings_Serialize, workspace?: Workspace_Serialize | null, + unity?: UnityProjectInfo | null, } | null>("project_current"), closeProject: () => __TAURI_INVOKE("close_project"), languages: () => __TAURI_INVOKE("languages"), @@ -5646,6 +5647,7 @@ export type ProjectInfo_Deserialize = { isGit: boolean, settings: Settings_Deserialize, workspace?: Workspace_Deserialize | null, + unity?: UnityProjectInfo | null, }; export type ProjectInfo_Serialize = { @@ -5655,6 +5657,7 @@ export type ProjectInfo_Serialize = { isGit: boolean, settings: Settings_Serialize, workspace?: Workspace_Serialize | null, + unity?: UnityProjectInfo | null, }; export type ProjectRoots = { @@ -7163,6 +7166,13 @@ export type UndoOutcome = { conflicts: IpcPath[], }; +export type UnityProjectInfo = { + editorVersion: string | null, + /** File names relative to the Unity root, in stable order. */ + solutions: string[], + projects: string[], +}; + export type UpdateCheck = { /** False when no update endpoint is configured (no request was made). */ enabled: boolean, diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx index c82e41c13..4c979fc06 100644 --- a/src/features/project-tree/HeaderActions.tsx +++ b/src/features/project-tree/HeaderActions.tsx @@ -6,14 +6,22 @@ import type { SortKey } from "./model"; /** The title's "Project ▾" pane switcher (IDEA: Project / Project Files / Open Files / …). */ export function useProjectTitleMenu(): { label: string; items: MenuItem[] } { const mode = useProjectTree((s) => s.mode); + const unity = useProjectTree((s) => s.unity); return { label: VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "Project", - items: VIEW_MODES.map(([id, label]) => ({ id, label, checked: id === mode, run: () => setViewMode(id) })), + items: VIEW_MODES.filter(([id]) => id !== "unity" || unity).map(([id, label]) => ({ + id, + label, + checked: id === mode, + run: () => setViewMode(id), + })), }; } /** The on / off view options. */ -export type ToggleOption = { [K in keyof ViewOptions]: ViewOptions[K] extends boolean ? K : never }[keyof ViewOptions]; +export type ToggleOption = { + [K in keyof ViewOptions]-?: ViewOptions[K] extends boolean ? K : never; +}[keyof ViewOptions]; export const OPTION_LABELS: Array<[ToggleOption, string]> = [ ["showHidden", "Show Hidden Files"], @@ -54,8 +62,15 @@ export const SORT_LABELS: Array<[SortKey, string]> = [ /** View options in the header's "⋮" menu (IDEA: Project › Options › Appearance / Behavior). */ export function useProjectHeaderMenu(): MenuItem[] { const options = useProjectTree((s) => s.options); + const unity = useProjectTree((s) => s.mode === "unity"); return [ { kind: "header", label: "Appearance" }, + ...(unity + ? [ + option("showUnityMeta", "Show Meta Files", options), + option("showUnityGenerated", "Show Generated Files", options), + ] + : []), ...OPTION_LABELS.slice(0, 5).map(([key, label]) => option(key, label, options)), { kind: "separator" }, ...SORT_LABELS.map(([sortBy, label]): MenuItem => ({ diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index 96ec9a6c9..4354bfc09 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -6,6 +6,15 @@ flex-direction: column; } +.unityNotice { + flex: none; + padding: 8px 12px; + color: var(--fg-muted); + font-size: 12px; + line-height: 1.5; + border-bottom: 1px solid var(--border); +} + .content { flex: 1; min-width: 0; diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 8a2e4b90c..f3223ccab 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -95,6 +95,47 @@ function setup() { } describe("ProjectView", () => { + it("toggles Unity metadata and generated files independently in the rendered tree", async () => { + fs = new MemoryFs( + { + "/w/acme/Assets/Player.cs": "code", + "/w/acme/Assets/Player.cs.meta": "metadata", + "/w/acme/Library/cache": "cache", + "/w/acme/Game.sln": "", + }, + ROOT, + ); + setProjectFsForTests(fs, ROOT); + initProjectTree(ROOT, "Game", null, { editorVersion: "6000.0.62f1", projects: [], solutions: [] }); + const { rows } = setup(); + act(() => useProjectTree.setState({ expanded: new Set([ROOT, `${ROOT}/Assets`]) })); + await waitFor(() => expect(rows()).toContain("Player.cs")); + expect(rows()).not.toContain("Player.cs.meta"); + expect(rows()).not.toContain("Library"); + act(() => setViewOption("showUnityMeta", true)); + await waitFor(() => expect(rows()).toContain("Player.cs.meta")); + expect(rows()).not.toContain("Game.sln"); + act(() => setViewOption("showUnityGenerated", true)); + await waitFor(() => expect(rows()).toContain("Game.sln")); + expect(rows()).toContain("Library"); + act(() => setViewOption("showUnityMeta", false)); + expect(rows()).not.toContain("Player.cs.meta"); + expect(rows()).toContain("Library"); + }); + + it("shows Unity version and generation guidance, then updates when project files arrive", async () => { + initProjectTree(ROOT, "Game", null, { editorVersion: "6000.0.62f1", projects: [], solutions: [] }); + const { row } = setup(); + expect(screen.getByText(/C# project files are missing/)).toHaveAttribute("role", "status"); + await waitFor(() => expect(row("Game")).toHaveTextContent("Unity 6000.0.62f1")); + act(() => { + useProjectTree.setState({ + unity: { editorVersion: "6000.0.62f1", projects: ["Assembly-CSharp.csproj"], solutions: ["Game.sln"] }, + }); + }); + expect(screen.queryByText(/C# project files are missing/)).not.toBeInTheDocument(); + }); + it("shows the root with its path and loads children lazily", async () => { const { rows, row, tree } = setup(); await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "README.md"])); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index bb8e0392b..0d796cfa1 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -426,6 +426,7 @@ export function ProjectView() { const info = useMemo(() => visibleInfo(model, expanded), [model, expanded]); const mode = useProjectTree((s) => s.mode); + const unity = useProjectTree((s) => s.unity); const outlines = useProjectTree((s) => s.outlines); const showMembers = options.showMembers; @@ -755,7 +756,7 @@ export function ProjectView() { editing={editing} cut={cutPaths.has(node.path)} dropTarget={dropTarget === node.key} - detail={node.root && !node.icon ? tildify(node.path, home) : (node.detail ?? null)} + detail={node.detail ?? (node.root && !node.icon ? tildify(node.path, home) : null)} onEditDone={focusTree} onClick={onRowClick} onDoubleClick={onRowDoubleClick} @@ -781,6 +782,11 @@ export function ProjectView() { onFocus={(e) => setContext("projectViewFocus", (e.target as HTMLElement).getAttribute("role") === "tree")} onBlur={() => setContext("projectViewFocus", false)} > + {mode === "unity" && unity && !unity.projects.length && ( +
+ {tx`C# project files are missing. In Unity, select an external script editor in Preferences > External Tools, then regenerate project files.`} +
+ )} {!model.roots.length && (
{txTemplate(`Nothing to show in ${VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "this view"}`)} diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index efcc5d9af..bbe46457b 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -1,6 +1,6 @@ /** Project view: the "project" tool window, its file operations and actions. */ import { onEditorEvent, openEditorPaths } from "@/editor/api"; -import { onIdeEvent } from "@/platform/ipc"; +import { commands, isTauri, onIdeEvent } from "@/platform/ipc"; import { useBuildModel } from "@/platform/buildModel"; import { validateProjectStructure, type RootFolder } from "@/platform/languageSettings"; import { useProject } from "@/platform/project"; @@ -33,22 +33,60 @@ import { reloadLoadedDirs, setChangedFiles, setOpenFiles, + setViewMode, useProjectTree, } from "./store"; export function activate(): () => void { loadHomeDir(); + let unityTimer: ReturnType | undefined; + let unityGeneration = 0; const sync = () => { const p = useProject.getState().project; const s = useProjectTree.getState(); if ((p?.root ?? null) !== s.root || p?.workspace?.projects !== (s.members ?? undefined)) - initProjectTree(p?.root ?? null, p?.name ?? "", p?.workspace?.projects ?? null); - else if (p && p.name !== s.name) useProjectTree.setState({ name: p.name }); + initProjectTree(p?.root ?? null, p?.name ?? "", p?.workspace?.projects ?? null, p?.unity ?? null); + else if (p) { + useProjectTree.setState({ name: p.name, unity: p.unity ?? null }); + if (p.unity && !s.unity && s.mode === "project") setViewMode("unity"); + if (!p.unity && s.mode === "unity") setViewMode("project"); + } }; sync(); const unsubProject = useProject.subscribe((s, prev) => { + if (s.project?.root !== prev.project?.root) { + unityGeneration++; + clearTimeout(unityTimer); + } if (s.project !== prev.project) sync(); }); + // Unity regenerates these files while the IDE stays open. Refresh only metadata, + // retaining the current settings and ignoring replies from a previous project. + const unsubUnity = onIdeEvent("fsChanged", (e) => { + const project = useProject.getState().project; + if (!project || !isTauri()) return; + const relevant = [...e.created, ...e.modified, ...e.removed].some((p) => { + if (!p.startsWith(project.root + "/")) return false; + const rel = p.slice(project.root.length + 1); + return ( + /^(Assets|Packages|ProjectSettings)(\/ProjectVersion\.txt)?$/.test(rel) || + /^[^/]+\.(csproj|sln|slnx)$/i.test(rel) + ); + }); + if (!relevant) return; + const generation = ++unityGeneration; + clearTimeout(unityTimer); + unityTimer = setTimeout(async () => { + try { + const fresh = await commands.projectCurrent(); + const current = useProject.getState().project; + if (generation !== unityGeneration || current?.root !== project.root || fresh?.root !== project.root) return; + useProject.setState({ project: { ...current, unity: fresh.unity } }); + } catch { + // A project may have closed while the metadata request was in flight. + } + }, 150); + }); // Excluded folders / exclude globs change which entries are `ignored`. const unsubExcludes = useProject.subscribe((s, prev) => { const ex = (x: typeof s.settings) => @@ -132,6 +170,11 @@ export function activate(): () => void { }); const disposers = [ + () => { + clearTimeout(unityTimer); + unityGeneration++; + unsubUnity(); + }, () => clearTimeout(openTimer), unsubEditor, unsubDecorations, diff --git a/src/features/project-tree/model.ts b/src/features/project-tree/model.ts index 832109d34..385868e56 100644 --- a/src/features/project-tree/model.ts +++ b/src/features/project-tree/model.ts @@ -8,6 +8,10 @@ import { isIgnoredName } from "@/platform/ignoredNames"; import { ancestorsWithin, isInside, isSameOrInside, nameOf, parentOf, relativeTo } from "./paths"; export interface TreeOptions { + /** Applied only by the Unity pane, never persisted as a user preference. */ + unityRoot?: string; + showUnityMeta: boolean; + showUnityGenerated: boolean; /** Show ignored names (`files.ignoredNames`: `.git`, `.DS_Store`, …). Dotfiles are always shown. */ showHidden: boolean; /** Show entries matched by .gitignore / exclude settings (dimmed). */ @@ -22,6 +26,8 @@ export interface TreeOptions { export type SortKey = "name" | "type" | "modified"; export const DEFAULT_TREE_OPTIONS: TreeOptions = { + showUnityMeta: false, + showUnityGenerated: false, showHidden: false, showExcluded: true, compact: true, @@ -69,7 +75,23 @@ export interface TreeModel { export type Listings = ReadonlyMap; +export function unityPathVisible(path: string, opts: TreeOptions): boolean { + if (opts.unityRoot && path.startsWith(opts.unityRoot + "/")) { + const relative = path.slice(opts.unityRoot.length + 1); + // The full Project / Project Files panes retain access to generated files. + if (!opts.showUnityGenerated) { + if (/^(Library|Temp|Obj|obj|Logs|Build|Builds|UserSettings|\.vs|\.idea|\.vscode)(\/|$)/.test(relative)) + return false; + if (!relative.includes("/") && /\.(csproj|sln|slnx|suo|user|pidb|booproj|unityproj)$/i.test(relative)) + return false; + } + if (!opts.showUnityMeta && /\.meta$/i.test(relative)) return false; + } + return true; +} + export function isVisible(entry: FileEntry, opts: TreeOptions): boolean { + if (!unityPathVisible(entry.path, opts)) return false; // Ignored names are also excluded on the backend; only "Show Hidden" brings them back. if (isIgnoredName(entry.name)) return opts.showHidden; if (!opts.showExcluded && entry.ignored) return false; diff --git a/src/features/project-tree/operations.test.ts b/src/features/project-tree/operations.test.ts index de1d9ea02..2204d9984 100644 --- a/src/features/project-tree/operations.test.ts +++ b/src/features/project-tree/operations.test.ts @@ -8,6 +8,7 @@ import { refreshOsClipboard, selectionAfterDelete, transfer, + topLevelPaths, } from "./operations"; import { IpcError } from "@/platform/ipc"; import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs"; @@ -50,6 +51,50 @@ describe("selectionAfterDelete", () => { }); }); +describe("Unity asset selections", () => { + beforeEach(async () => { + fs = new MemoryFs( + { + "/p/Assets/Player.cs": "code", + "/p/Assets/Player.cs.meta": "metadata", + "/p/Assets/Player copy.cs.meta": "orphan", + }, + "/p", + ); + setProjectFsForTests(fs, "/p"); + initProjectTree("/p", "Game", null, { editorVersion: "6000.0.1f1", projects: [], solutions: [] }); + provideNotificationService({ notify }); + notify.mockClear(); + await loadDir("/p/Assets"); + }); + + afterEach(() => { + setProjectFsForTests(null); + initProjectTree(null); + }); + + it("treats an asset and its selected metadata as one operation", () => { + expect(topLevelPaths([node("/p/Assets/Player.cs"), node("/p/Assets/Player.cs.meta")])).toEqual([ + "/p/Assets/Player.cs", + ]); + expect( + topLevelPaths([ + node("/p/Assets/Group", true), + node("/p/Assets/Group.meta"), + node("/p/Assets/Group/Inner.cs.meta"), + ]), + ).toEqual(["/p/Assets/Group"]); + expect(topLevelPaths([node("/p/Assets/Player.cs.meta")])).toEqual(["/p/Assets/Player.cs.meta"]); + }); + + it("skips orphan metadata names when choosing a duplicate asset name", async () => { + const copy = vi.spyOn(fs, "copy"); + await transfer(["/p/Assets/Player.cs", "/p/Assets/Player.cs.meta"], "/p/Assets", "copy"); + expect(copy).toHaveBeenCalledTimes(1); + expect(copy).toHaveBeenCalledWith("/p/Assets/Player.cs", "/p/Assets/Player copy 2.cs"); + }); +}); + // Local OS paths and remote project paths may be identical strings. describe("OS file imports", () => { beforeEach(async () => { diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 0ff55c87a..b34122fd7 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -84,10 +84,24 @@ export function leadNode(): ProjectNode | null { /** Selected paths without entries nested in other selected folders. */ export function topLevelPaths(nodes: ProjectNode[] = selectedNodes()): string[] { - const paths = nodes.map((n) => n.path); + const paths = assetPaths(nodes.map((n) => n.path)); return paths.filter((p) => !paths.some((q) => q !== p && isInside(p, q))); } +/** A visible sidecar is already included by the backend asset operation. */ +function assetPaths(paths: string[]): string[] { + const { root, unity } = get(); + if (!root || !unity) return paths; + return paths.filter( + (p) => !(isInside(p, joinPath(root, "Assets")) && p.endsWith(".meta") && paths.includes(p.slice(0, -5))), + ); +} + +function isUnityAssetFolder(path: string): boolean { + const { root, unity } = get(); + return !!(root && unity && isSameOrInside(path, joinPath(root, "Assets"))); +} + /** Folder that receives new / pasted items: the selected folder or the selected file's parent. */ export function targetFolder(node: ProjectNode | null = leadNode()): string | null { if (!node && get().lead?.startsWith(MEMBER_PREFIX)) return null; @@ -667,13 +681,15 @@ export async function transfer( name?: string, source: "project" | "local" = "project", ): Promise { + paths = assetPaths(paths); if (mode === "move") return moveInto(paths, target); const { root } = get(); if (!root) return false; const fs = projectFs(root); await loadDir(target); const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n))); - const isTaken = (n: string) => taken.has(caseInsensitive ? n.toLowerCase() : n); + const hasName = (n: string) => taken.has(caseInsensitive ? n.toLowerCase() : n); + const isTaken = (n: string) => hasName(n) || (isUnityAssetFolder(target) && hasName(`${n}.meta`)); const touched = new Set([target]); const errors: string[] = []; let first: string | null = null; @@ -726,6 +742,7 @@ export async function transfer( * the options last used in the Move dialog). Refuses to overwrite. */ async function moveInto(paths: string[], target: string): Promise { + paths = assetPaths(paths); await loadDir(target); const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n))); const errors: string[] = []; diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 993971856..df53ff6ff 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -4,6 +4,7 @@ * Kept outside React so actions (keyboard, Find Action, context menu) share it. */ import { create } from "zustand"; +import type { UnityProjectInfo } from "@/bindings"; import { call } from "@/platform/ipc"; import { commands, type FileEntry, type OutlineItem } from "@/bindings"; import { withDocument } from "@/editor/lsp/files"; @@ -25,6 +26,7 @@ import { mergeListing, planRefresh, visibleEntries, + unityPathVisible, type Placeholder, type ProjectNode, type TreeModel, @@ -67,9 +69,10 @@ function storedOptions(): ViewOptions { } /** IDEA's Project view panes ("Project ▾" in the tool window header). */ -export type ViewMode = "project" | "projectFiles" | "openFiles" | "changedFiles" | "scratches"; +export type ViewMode = "unity" | "project" | "projectFiles" | "openFiles" | "changedFiles" | "scratches"; export const VIEW_MODES: ReadonlyArray<[ViewMode, string]> = [ + ["unity", "Unity"], ["project", "Project"], ["projectFiles", "Project Files"], ["openFiles", "Open Files"], @@ -94,6 +97,7 @@ export interface Clipboard { } interface ProjectTreeState { + unity: UnityProjectInfo | null; root: string | null; name: string; members: readonly string[] | null; @@ -140,6 +144,7 @@ function writeJson(key: string, value: unknown) { } export const useProjectTree = create(() => ({ + unity: null, root: null, name: "", members: null, @@ -170,6 +175,7 @@ const set = (patch: Partial) => useProjectTree.setState(patch) // Derived model (memoised on its inputs so React and actions share one instance) let memo: { + unity: UnityProjectInfo | null; root: string | null; name: string; members: readonly string[] | null; @@ -187,10 +193,15 @@ const EMPTY_MODEL: TreeModel = { roots: [], children: new Map(), byKey: new Map( const filesOf = (s: ProjectTreeState) => s.mode === "openFiles" ? s.openFiles : s.mode === "changedFiles" ? s.changedFiles : null; +function treeOptions(s: ProjectTreeState): TreeOptions { + return s.mode === "unity" && s.unity && s.root ? { ...s.options, compact: false, unityRoot: s.root } : s.options; +} + export function selectModel(s: ProjectTreeState): TreeModel { const files = filesOf(s); if ( memo && + memo.unity === s.unity && memo.root === s.root && memo.name === s.name && memo.members === s.members && @@ -205,6 +216,7 @@ export function selectModel(s: ProjectTreeState): TreeModel { const placeholder: Placeholder | null = s.editing?.kind === "new" ? { parent: s.editing.parent, kind: s.editing.nodeKind } : null; const extras = useProjectViewRoots.getState().roots; + const options = treeOptions(s); const contributed = projectViewRoots(extras); const memberRoots = s.members?.map((path) => ({ path, @@ -214,8 +226,8 @@ export function selectModel(s: ProjectTreeState): TreeModel { })); const project = () => memberRoots - ? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, s.options, placeholder) - : buildModel({ path: s.root!, name: s.name }, s.listings, s.options, placeholder); + ? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, options, placeholder) + : buildModel({ path: s.root!, name: s.name }, s.listings, options, placeholder); let model = EMPTY_MODEL; if (s.root) { if (s.mode === "projectFiles") model = project(); @@ -233,13 +245,16 @@ export function selectModel(s: ProjectTreeState): TreeModel { files, s.options.foldersFirst, ); - else model = withExtraRoots(project(), contributed, s.listings, s.options, placeholder); + else model = withExtraRoots(project(), contributed, s.listings, options, placeholder); + if (s.unity && s.mode === "unity" && model.roots[0]) + model.roots[0].detail = s.unity.editorVersion ? `Unity ${s.unity.editorVersion}` : "Unity"; const libraries = extraFolders(contributed).flatMap((r) => r.readOnly && r.path !== undefined && isSameOrInside(r.path, s.root!) ? [r.path] : [], ); model = withReadOnlyFolders(model, libraries); } memo = { + unity: s.unity, root: s.root, name: s.name, members: s.members, @@ -264,19 +279,29 @@ const inflight = new Map>(); let persistTimer: ReturnType | undefined; /** Resets the view for a newly opened project, restoring its expansion state. */ -export function initProjectTree(root: string | null, name = "", members: readonly string[] | null = null) { +export function initProjectTree( + root: string | null, + name = "", + members: readonly string[] | null = null, + unity: UnityProjectInfo | null = null, +) { generation++; inflight.clear(); outlineInflight.clear(); clearTimeout(persistTimer); modeExpanded.clear(); autoExpanded.clear(); + const previousMode = get().mode; + const mode = + unity && previousMode === "project" ? "unity" : !unity && previousMode === "unity" ? "project" : previousMode; set({ + unity, + mode, root, name, members, listings: new Map(), - expanded: initialExpanded(root, get().mode, members), + expanded: initialExpanded(root, mode, members), selected: new Set(), lead: null, editing: null, @@ -304,23 +329,25 @@ function initialExpanded(root: string | null, mode: ViewMode, members = get().me // The tree as the user left it, like IDEA: a collapsed project root stays collapsed, and // nodes outside it (content roots, contributed roots) stay open. // Keys of nodes that no longer exist are inert. Only a first open expands the roots. - const persisted = mode === "project" ? readJson(expandedKey(root)) : null; + const persisted = mode === "project" || mode === "unity" ? readJson(expandedKey(root)) : null; if (persisted) return new Set(persisted); return new Set(members ?? [root]); } function persistExpanded() { - if (get().mode !== "project") return; + if (get().mode !== "project" && get().mode !== "unity") return; clearTimeout(persistTimer); persistTimer = setTimeout(() => { const { root, expanded, mode } = get(); - if (root && mode === "project") writeJson(expandedKey(root), [...expanded].slice(0, MAX_PERSISTED)); + if (root && (mode === "project" || mode === "unity")) + writeJson(expandedKey(root), [...expanded].slice(0, MAX_PERSISTED)); }, 300); } /** Switches the pane ("Project ▾"), remembered across sessions. */ export function setViewMode(mode: ViewMode) { const s = get(); + if (mode === "unity" && !s.unity) return; if (s.mode === mode) return; modeExpanded.set(s.mode, s.expanded); autoExpanded.clear(); @@ -409,8 +436,8 @@ export function loadDir(path: string, force = false): Promise { putListing(path, entries); // Compact Middle Packages needs to look one level down a single-folder chain. const s = get(); - if (s.options.compact) { - const visible = visibleEntries(entries, s.options); + if (treeOptions(s).compact) { + const visible = visibleEntries(entries, treeOptions(s)); const only = visible.length === 1 ? visible[0]! : null; if (only && isDirEntry(only) && !only.ignored && !s.listings.has(only.path)) void loadDir(only.path); } @@ -590,6 +617,8 @@ export function modeShows(path: string): boolean { const { mode, root } = get(); if (!root) return false; switch (mode) { + case "unity": + return unityPathVisible(path, treeOptions(get())); case "project": return true; case "projectFiles": @@ -655,7 +684,7 @@ export async function expandAll(from?: string, limit = 400) { await loadDir(path); if (gen !== generation) return; const s = get(); - for (const e of visibleEntries(s.listings.get(path) ?? [], s.options)) + for (const e of visibleEntries(s.listings.get(path) ?? [], treeOptions(s))) if (isDirEntry(e) && !e.ignored && e.kind !== "symlink") queue.push(keyLike(dir, e.path)); } setExpanded(toExpand); diff --git a/src/features/project-tree/unity.test.ts b/src/features/project-tree/unity.test.ts new file mode 100644 index 000000000..b7b9bafb7 --- /dev/null +++ b/src/features/project-tree/unity.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { FileEntry } from "@/bindings"; +import { DEFAULT_TREE_OPTIONS, visibleEntries } from "./model"; +import { currentModel, initProjectTree, modeShows, setViewMode, useProjectTree } from "./store"; + +const unity = { editorVersion: "2022.3.62f1", solutions: [], projects: [] }; +const entry = (path: string, dir = false): FileEntry => ({ + path, + name: path.slice(path.lastIndexOf("/") + 1), + kind: dir ? "dir" : "file", + size: 1, + mtimeMs: null, + ignored: false, + excluded: false, + hidden: false, +}); +const files = [ + entry("/game/Assets", true), + entry("/game/Packages", true), + entry("/game/ProjectSettings", true), + entry("/game/Library", true), + entry("/game/Temp", true), + entry("/game/Assets.meta"), + entry("/game/Game.sln"), + entry("/game/Assembly-CSharp.csproj"), + entry("/game/README.md"), +]; + +beforeEach(() => { + localStorage.clear(); + initProjectTree(null); + setViewMode("project"); +}); +afterEach(() => initProjectTree(null)); + +describe("Unity project view", () => { + it("opens in Unity mode and keeps the full project view available", () => { + initProjectTree("/game", "Game", null, unity); + useProjectTree.setState({ listings: new Map([["/game", files]]) }); + expect(useProjectTree.getState().mode).toBe("unity"); + const model = currentModel(); + expect(model.roots[0]?.detail).toBe("Unity 2022.3.62f1"); + expect(model.children.get("/game")?.map((n) => n.label)).toEqual([ + "Assets", + "Packages", + "ProjectSettings", + "README.md", + ]); + setViewMode("projectFiles"); + expect(currentModel().children.get("/game")).toHaveLength(files.length); + }); + + it("does not leak Unity mode or metadata into the next ordinary project", () => { + initProjectTree("/game", "Game", null, unity); + initProjectTree("/app", "App"); + expect(useProjectTree.getState()).toMatchObject({ mode: "project", unity: null }); + setViewMode("unity"); + expect(useProjectTree.getState().mode).toBe("project"); + }); + + it("hides only root caches, keeps asset folders with matching names, and can reveal metadata", () => { + const options = { ...DEFAULT_TREE_OPTIONS, unityRoot: "/game" }; + const entries = [entry("/game/Assets/Library", true), entry("/game/Assets/P.cs.meta"), entry("/other/P.cs.meta")]; + expect(visibleEntries(entries, options).map((e) => e.path)).toEqual(["/game/Assets/Library", "/other/P.cs.meta"]); + expect(visibleEntries(entries, { ...options, showHidden: true })).toHaveLength(2); + expect(visibleEntries(entries, { ...options, showUnityMeta: true })).toHaveLength(3); + expect(visibleEntries(files, { ...options, showUnityGenerated: true }).map((e) => e.name)).toContain("Library"); + expect(visibleEntries(files, { ...options, showUnityGenerated: true }).map((e) => e.name)).toContain("Game.sln"); + expect(visibleEntries(files, DEFAULT_TREE_OPTIONS)).toHaveLength(files.length); + }); + + it("keeps navigation to unloaded scripts in Unity mode", () => { + initProjectTree("/game", "Game", null, unity); + expect(modeShows("/game/Assets/Scripts/Player.cs")).toBe(true); + expect(modeShows("/game/Library/Cached.cs")).toBe(false); + expect(modeShows("/game/Assets/Player.cs.meta")).toBe(false); + }); +}); diff --git a/src/features/project-tree/unityRefresh.test.ts b/src/features/project-tree/unityRefresh.test.ts new file mode 100644 index 000000000..061a664cb --- /dev/null +++ b/src/features/project-tree/unityRefresh.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { ProjectInfo } from "@/bindings"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { activate } from "./index"; +import { useProjectTree } from "./store"; + +const current = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + projectCurrent: current, + projectRoots: async () => ({ status: "ok", data: { roots: [], detected: [] } }), + }, + }; +}); + +const project = (root = "/game"): ProjectInfo => ({ + root, + name: "Game", + trusted: false, + isGit: false, + settings: {} as ProjectInfo["settings"], + unity: { editorVersion: "2022.3.62f1", projects: [], solutions: [] }, +}); +let dispose: () => void; +beforeEach(() => { + vi.useFakeTimers(); + current.mockReset(); + useProject.setState({ project: project() }); + dispose = activate(); +}); +afterEach(() => { + dispose(); + useProject.setState({ project: null }); + vi.useRealTimers(); +}); +const changed = () => + emitLocalIdeEvent({ type: "fsChanged", created: ["/game/Assembly-CSharp.csproj"], modified: [], removed: [] }); + +it("refreshes generated C# files through backend events without overwriting a project rename", async () => { + let resolve!: (value: unknown) => void; + current.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + changed(); + await vi.advanceTimersByTimeAsync(160); + expect(current).toHaveBeenCalledTimes(1); + useProject.setState({ project: { ...project(), name: "Renamed" } }); + resolve({ ...project(), unity: { ...project().unity, projects: ["Assembly-CSharp.csproj"] } }); + await vi.advanceTimersByTimeAsync(0); + expect(useProjectTree.getState().unity?.projects).toEqual(["Assembly-CSharp.csproj"]); + expect(useProject.getState().project?.name).toBe("Renamed"); +}); + +it("discards a late reply after switching to another project", async () => { + let resolve!: (value: unknown) => void; + current.mockReturnValue( + new Promise((r) => { + resolve = r; + }), + ); + changed(); + await vi.advanceTimersByTimeAsync(160); + useProject.setState({ project: { ...project("/app"), unity: undefined } }); + resolve(project()); + await vi.advanceTimersByTimeAsync(0); + expect(useProjectTree.getState()).toMatchObject({ root: "/app", unity: null, mode: "project" }); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 044339f3c..fb0947396 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,8 @@ { + "Unity": "Unity 项目", + "Show Meta Files": "显示 Meta 文件", + "Show Generated Files": "显示生成的文件", + "C# project files are missing. In Unity, select an external script editor in Preferences > External Tools, then regenerate project files.": "缺少 C# 项目文件。请在 Unity 的偏好设置 > 外部工具中选择外部脚本编辑器,然后重新生成项目文件。", "Edit {name}": "编辑 {name}", "Whitespace-only changes": "仅空白字符更改", " · Whitespace-only changes": " · 仅空白字符更改",