дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
2013ddaac2
коміт
7e9acdc59b
39 files changed
+2178
-57
No files matched your search
Створено
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")?)
|
||||
})
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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(()),
|
||||
|
||||
@@ -21,6 +21,7 @@ blake3.workspace = true
|
||||
tracing.workspace = true
|
||||
tempfile.workspace = true
|
||||
globset.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(&[
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]));
|
||||
|
||||
@@ -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"}`)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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": " · 仅空白字符更改",
|
||||
|
||||
Reference in new issue
Заблокувати користувача