feat(unity): detect projects and preserve asset metadata (#1207)

Unity projects (a folder with Assets and ProjectSettings/ProjectVersion.txt)
are detected without a generated solution: Assets / Packages become source
roots, ProjectSettings resources, and Unity's cache and build folders are
excluded before they exist. A Unity pane in the Project view shows the
editor version and hides .meta files, generated C# project files and
caches (each can be shown again).

Operations on assets under Assets keep their .meta files consistent:
rename and move carry the metadata (same GUID), delete removes both, copy
generates new GUIDs and updates references inside the copied group, and
file-move undo and workspace edits include the metadata. Destination
metadata conflicts and links inside the project are rejected before
anything changes.

Co-authored-by: hacbit <[email protected]>
Цей коміт міститься в:
Hacbit authored and GitHub committed 2026-11-05 02:16:06 +08:00
1 джерело 2013ddaac2
коміт 7e9acdc59b
39 files changed
+2178 -57

No files matched your search

Створено
+1
Переглянути файл
@@ -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",
]
+10 -3
Переглянути файл
@@ -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.
+3
Переглянути файл
@@ -17,6 +17,8 @@ pub struct ProjectInfo {
pub settings: Settings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<ide_workspace::workspace::Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
}
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),
}
}
+8 -1
Переглянути файл
@@ -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) {
+1 -1
Переглянути файл
@@ -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::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("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")?)
})
+100
Переглянути файл
@@ -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");
}
+5
Переглянути файл
@@ -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<()> {
+9 -1
Переглянути файл
@@ -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(()),
+1
Переглянути файл
@@ -21,6 +21,7 @@ blake3.workspace = true
tracing.workspace = true
tempfile.workspace = true
globset.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
+3
Переглянути файл
@@ -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 {
+16 -27
Переглянути файл
@@ -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<FileMove> = 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()),
}
+94
Переглянути файл
@@ -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<Vec<PathBuf>> {
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<Vec<FileMove>> {
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)
}
+52
Переглянути файл
@@ -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<IdeResult<()>> {
has_move_rules(from, to).then(|| assets::copy(from, to))
}
pub(super) fn delete(path: &Path) -> Option<IdeResult<()>> {
assets::project_root(path).map(|_| assets::delete(path))
}
pub(super) fn deletion_paths(path: &Path) -> Option<IdeResult<Vec<PathBuf>>> {
assets::project_root(path).map(|_| assets::deletion_paths(path))
}
pub(super) fn rename_entry(from: &Path, to: &Path) -> Option<IdeResult<()>> {
has_move_rules(from, to).then(|| assets::move_one(from, to))
}
pub(super) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
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(())
}
+2
Переглянути файл
@@ -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;
+64 -1
Переглянути файл
@@ -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<SourceFolder> {
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<String> = Vec::new();
let mut dirs: Vec<String> = 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(&[
+94
Переглянути файл
@@ -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<String>,
/// File names relative to the Unity root, in stable order.
pub solutions: Vec<String>,
pub projects: Vec<String>,
}
pub fn is_project(root: &Path) -> bool {
root.join("Assets").is_dir() && root.join(VERSION_FILE).is_file()
}
pub fn detect(root: &Path) -> Option<UnityProjectInfo> {
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());
}
}
+254
Переглянути файл
@@ -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<PathBuf> {
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<Vec<FileMove>> {
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<Vec<PathBuf>> {
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<PathBuf> {
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
}
+236
Переглянути файл
@@ -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<PathBuf>) -> 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<String> {
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<String, String>) -> 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, String>) -> 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")
}
+217
Переглянути файл
@@ -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<PathBuf> =
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"));
}
+233
Переглянути файл
@@ -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");
}
}
+99
Переглянути файл
@@ -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(())
}
+147
Переглянути файл
@@ -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());
}
+89
Переглянути файл
@@ -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(),
+23
Переглянути файл
@@ -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.
+1 -1
Переглянути файл
@@ -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]
+2 -2
Переглянути файл
@@ -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
+10
Переглянути файл
@@ -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<void>("close_project"),
languages: () => __TAURI_INVOKE<LanguageInfo_Serialize[]>("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,
+17 -2
Переглянути файл
@@ -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 => ({
+9
Переглянути файл
@@ -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;
+41
Переглянути файл
@@ -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"]));
+7 -1
Переглянути файл
@@ -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 && (
<div className={styles.unityNotice} role="status">
{tx`C# project files are missing. In Unity, select an external script editor in Preferences > External Tools, then regenerate project files.`}
</div>
)}
{!model.roots.length && (
<div className={styles.empty}>
{txTemplate(`Nothing to show in ${VIEW_MODES.find(([id]) => id === mode)?.[1] ?? "this view"}`)}
+46 -3
Переглянути файл
@@ -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<typeof setTimeout> | 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,
+22
Переглянути файл
@@ -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<string, readonly FileEntry[]>;
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;
+45
Переглянути файл
@@ -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 () => {
+19 -2
Переглянути файл
@@ -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<string | boolean> {
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<string>([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<boolean> {
paths = assetPaths(paths);
await loadDir(target);
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
const errors: string[] = [];
+41 -12
Переглянути файл
@@ -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<ProjectTreeState>(() => ({
unity: null,
root: null,
name: "",
members: null,
@@ -170,6 +175,7 @@ const set = (patch: Partial<ProjectTreeState>) => 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<string, Promise<void>>();
let persistTimer: ReturnType<typeof setTimeout> | 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<string[]>(expandedKey(root)) : null;
const persisted = mode === "project" || mode === "unity" ? readJson<string[]>(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<void> {
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);
+78
Переглянути файл
@@ -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);
});
});
+75
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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" });
});
+4
Переглянути файл
@@ -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": " · 仅空白字符更改",