From 7b34dbab007461d6befa2eb45ff8eb14f8328328 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:48:30 +0100 Subject: [PATCH] Keep library links working when the project folder is moved or cloned (#2132) Fixes #2122 Co-authored-by: Claude Opus 5.5 --- apps/figcraft-cli/src/files.rs | 2 +- apps/figcraft-cli/src/perf.rs | 8 +- crates/doc/src/library.rs | 5 ++ crates/engine/src/assets/library.rs | 92 ++++++++++++++++++++- crates/engine/src/assets/library/tests.rs | 54 ++++++++++++ crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/file/load.rs | 4 +- crates/engine/src/cmd/file/save.rs | 16 +++- crates/engine/src/cmd/library.rs | 8 +- crates/engine/src/components_scale_tests.rs | 8 +- crates/engine/src/doc_list_paths_tests.rs | 2 + crates/engine/src/library_beacon_tests.rs | 2 +- crates/ui-egui/src/library_beacon_tests.rs | 8 +- 13 files changed, 201 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 8f277e304..165057086 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -464,7 +464,7 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?; if ext(out) == "figcraft" { // Through the engine, so the version history is kept. - write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?; + write(out, &figcraft_engine::cmd::file::file_bytes_at(ds, out).map_err(|e| CliError::Failed(e.to_string()))?)?; } else { write_doc(&ds.doc, ds.page, out)?; } diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 30c801207..4c34a5027 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -508,7 +508,13 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes let lib = figcraft_engine::library::Cache::default().load(&path).map_err(failed)?; let mut doc = Document::new("Consumer"); let m = &lib.manifest; - doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version }); + doc.libraries_mut().enabled.push(figcraft_doc::LibraryLink { + id: m.id.clone(), + name: m.name.clone(), + path: path.clone(), + version: m.version, + ..Default::default() + }); let map = figcraft_engine::library::import(&mut doc, &lib, &lib_ds.assets(), false).map_err(failed)?; let mut local = lib_ds.remap(&map); local.theme = doc.libraries.collections.iter().find(|r| r.key == lib_ds.theme).map(|r| r.local.clone()).unwrap_or_default(); diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 9fe1c75b7..719549c96 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -152,6 +152,11 @@ pub struct LibraryLink { pub name: String, /// Where the library file is. pub path: String, + /// Where the library file is from the folder of the document that links it ('/'-separated; + /// written on save): followed first when the document is opened, so the link survives moving + /// or cloning the folder that holds both. Empty for server links and older files. + #[serde(skip_serializing_if = "String::is_empty")] + pub relative: String, /// The library version this document last saw (when it was added or its updates accepted). pub version: u64, } diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index d9107ad63..2fb64a26d 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -628,6 +628,90 @@ pub fn absolute_path(path: &str) -> String { path.to_string() } +/// `path` with `.` and `..` taken out without asking the file system (`..` at the root stays there). +fn lexical(path: &std::path::Path) -> std::path::PathBuf { + use std::path::Component; + let mut out = std::path::PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir if matches!(out.components().next_back(), Some(Component::Normal(_))) => { + out.pop(); + } + Component::ParentDir if out.has_root() => {} + c => out.push(c), + } + } + out +} + +/// The folder of the document saved at `doc_path`, absolute. +fn doc_folder(doc_path: &str) -> Option { + lexical(std::path::Path::new(&absolute_path(doc_path))).parent().map(std::path::Path::to_path_buf) +} + +/// Library file `lib` from the folder of the document at `doc_path`, '/'-separated; `None` when +/// there is no such path (another drive, say). +fn relative_to(doc_path: &str, lib: &str) -> Option { + use std::path::Component; + let folder = doc_folder(doc_path)?; + let lib = lexical(std::path::Path::new(&absolute_path(lib))); + let (from, to): (Vec<_>, Vec<_>) = (folder.components().collect(), lib.components().collect()); + let same = from.iter().zip(&to).take_while(|(a, b)| a == b).count(); + // Not even the root in common: no relative path leads there. + if same == 0 || !matches!(to.get(same..), Some([_, ..])) { + return None; + } + let up = from.get(same..).unwrap_or_default().iter().map(|_| Some("..")); + let down = to.get(same..).unwrap_or_default().iter().map(|c| match c { + Component::Normal(s) => s.to_str(), + _ => None, + }); + up.chain(down).collect::>>().map(|parts| parts.join("/")) +} + +/// Before saving the document to `doc_path`: each library file link also says where the file is +/// from the document's folder ([`LibraryLink::relative`]). +pub fn relate_links(doc: &mut Document, doc_path: &str) { + if doc.libraries.enabled.is_empty() { + return; + } + for l in &mut doc.libraries_mut().enabled { + l.relative = if crate::library_server::is_server_link(&l.path) { None } else { relative_to(doc_path, &l.path) }.unwrap_or_default(); + } +} + +/// After opening the document at `doc_path`: library file links go by its folder. A library +/// found at its [relative](LibraryLink::relative) place is followed (the folder was moved or +/// cloned), and a relative `path` is taken from the document's folder, not the working directory. +/// The open document keeps only the absolute `path`. +pub fn resolve_links(doc: &mut Document, doc_path: &str) { + if doc.libraries.enabled.iter().all(|l| crate::library_server::is_server_link(&l.path) && l.relative.is_empty()) { + return; + } + let Some(folder) = doc_folder(doc_path) else { return }; + for l in &mut doc.libraries_mut().enabled { + if crate::library_server::is_server_link(&l.path) { + l.relative.clear(); + continue; + } + let moved = Some(l.relative.as_str()).filter(|r| !r.is_empty()).map(|r| lexical(&folder.join(r))); + // Only a place on this computer is looked at (see `Session::fetch_library`). + if let Some(p) = + moved.filter(|p| p.to_str().is_some_and(craft_docpath::is_local) && crate::file_access::metadata(p).is_ok_and(|m| m.is_file())) + { + l.path = p.to_string_lossy().into_owned(); + } else if !l.path.is_empty() + && std::path::Path::new(&l.path).is_relative() + && let Some(p) = lexical(&folder.join(&l.path)).to_str().filter(|p| craft_docpath::is_local(p)) + { + l.path = p.to_string(); + } + // Written again on each save, from `path`. + l.relative.clear(); + } +} + /// The enabled library `key` (its id, name or path). pub fn find_link<'a>(doc: &'a Document, key: &str) -> Option<&'a LibraryLink> { let l = &doc.libraries.enabled; @@ -1257,7 +1341,13 @@ pub fn adopt_pasted(doc: &mut Document, src: &Document, src_path: Option<&str>, if doc.libraries.link(&m.id).is_none() && let Some(path) = src_path { - doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version }); + doc.libraries_mut().enabled.push(LibraryLink { + id: m.id.clone(), + name: m.name.clone(), + path: path.to_string(), + version: m.version, + relative: String::new(), + }); } } } diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index 733c3ba8c..4c329cb5f 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -641,3 +641,57 @@ fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() { assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) })); assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) })); } + +/// Library `lid` is listed without an error, and `library.updates` reports no errors. +fn link_found(s: &mut Session, lid: &str) { + let list = ex(s, "library.list", json!({})); + let l = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == lid).unwrap_or_else(|| panic!("{list}")); + assert!(l.get("error").is_none_or(Value::is_null), "{l}"); + let u = ex(s, "library.updates", json!({})); + assert_eq!(u["errors"], json!([]), "{u}"); +} + +/// A library next to the documents that use it stays linked when their folder is moved or cloned; +/// files from before relative links, and relative paths written by hand, keep working too. +#[test] +fn library_links_follow_a_moved_folder() { + let d = dir("moved-folder"); + let (a, b) = (d.join("a"), d.join("b")); + std::fs::create_dir_all(a.join("lib")).unwrap(); + std::fs::create_dir_all(a.join("pages")).unwrap(); + let mut lib = Session::new(); + kit(&mut lib); + let lid = publish_to(&mut lib, &path_str(&a.join("lib").join("kit.figcraft"))); + + let mut s = Session::new(); + ex(&mut s, "file.saveAs", json!({"path": path_str(&a.join("pages").join("Page.figcraft"))})); + ex(&mut s, "library.add", json!({"path": path_str(&a.join("lib").join("kit.figcraft"))})); + ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0})); + ex(&mut s, "file.save", json!({})); + // The folder moves (or is cloned elsewhere): the library keeps its place next to the page. + std::fs::rename(&a, &b).unwrap(); + let mut moved = Session::empty(); + ex(&mut moved, "file.open", json!({"path": path_str(&b.join("pages").join("Page.figcraft"))})); + link_found(&mut moved, &lid); + + // A file from before relative links: the absolute path is followed. + let old = d.join("old"); + std::fs::create_dir_all(&old).unwrap(); + let mut doc = Document::new("Old"); + let abs = path_str(&b.join("lib").join("kit.figcraft")); + doc.libraries_mut().enabled.push(LibraryLink { id: lid.clone(), name: "Kit".into(), path: abs, version: 1, ..Default::default() }); + std::fs::write(old.join("Old.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap(); + let mut s = Session::empty(); + ex(&mut s, "file.open", json!({"path": path_str(&old.join("Old.figcraft"))})); + link_found(&mut s, &lid); + + // A relative path written by hand goes by the document's folder, not the working directory. + let mut doc = Document::new("Hand"); + let rel = "../lib/kit.figcraft".to_string(); + doc.libraries_mut().enabled.push(LibraryLink { id: lid.clone(), name: "Kit".into(), path: rel, version: 1, ..Default::default() }); + std::fs::write(b.join("pages").join("Hand.figcraft"), figcraft_format::save_bytes(&doc, None).unwrap()).unwrap(); + let mut s = Session::empty(); + ex(&mut s, "file.open", json!({"path": path_str(&b.join("pages").join("Hand.figcraft"))})); + link_found(&mut s, &lid); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2916d4aac..0c84f3458 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -41,7 +41,7 @@ use open::append_pages; pub(crate) use open::open; use open::{import, open_recent, revert}; pub(crate) use save::save_ends_preview; -pub use save::{FileSnapshot, file_bytes, thumbnail}; +pub use save::{FileSnapshot, file_bytes, file_bytes_at, thumbnail}; use save::{color_profile, mark_saved, save, save_as}; pub use version::MAX_AUTOSAVES; pub(crate) use version::{add_version, keep_in_history, read_version, unique_id}; diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index ab5ae43e8..d204931ac 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -75,7 +75,9 @@ pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec) -> Result<(figcraft_format::Loaded, bool)> { let bytes = read(path)?; let sibling = if variables.is_none() { sibling_variables(path) } else { None }; - load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) + let (mut loaded, rest) = load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path))?; + crate::library::resolve_links(&mut loaded.doc, path); + Ok((loaded, rest)) } /// Load from bytes. Returns the document with its notes (repairs of a native file, or import diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs index 4b8939c7d..975976537 100644 --- a/crates/engine/src/cmd/file/save.rs +++ b/crates/engine/src/cmd/file/save.rs @@ -42,6 +42,11 @@ pub fn file_bytes(ds: &DocState) -> Result> { FileSnapshot::of(ds).encode() } +/// [`file_bytes`] for the file at `path` (library links are stored relative to its folder too). +pub fn file_bytes_at(ds: &DocState, path: &str) -> Result> { + FileSnapshot::of(ds).encode_at(Some(path)) +} + /// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the /// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then /// [`save`](Self::save) the bytes back on the session's thread. @@ -73,8 +78,17 @@ impl FileSnapshot { /// The file's bytes: content, thumbnail and version history. pub fn encode(&self) -> Result> { + self.encode_at(self.path.as_deref()) + } + + /// [`encode`](Self::encode) for the file at `path`: library links also say where their + /// files are from its folder. + fn encode_at(&self, path: Option<&str>) -> Result> { let mut doc = (*self.doc).clone(); doc.current_page = Some(self.page); + if let Some(path) = path { + crate::library::relate_links(&mut doc, path); + } let thumb = thumbnail(&doc); figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) .map_err(|e| EngineError::Other(e.to_string())) @@ -177,7 +191,7 @@ fn save_to(s: &mut Session, path: Option) -> Result { { Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } - let bytes = snapshot.encode()?; + let bytes = snapshot.encode_at(path.as_deref().or(snapshot.path.as_deref()))?; let Some(path) = path else { // The download can still be refused: the caller marks the file saved once it was delivered. let name = format!("{}.figcraft", ds.doc.name); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 8aeb12d49..d8e2e6893 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -646,7 +646,13 @@ fn add(s: &mut Session, p: &Value) -> Result { l.path = path.clone(); l.name = m.name.clone(); } - None => libs.enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.clone(), version: m.version }), + None => libs.enabled.push(LibraryLink { + id: m.id.clone(), + name: m.name.clone(), + path: path.clone(), + version: m.version, + relative: String::new(), + }), } } let mut out = assets_json(&lib); diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index 68465932b..6e527532d 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -453,7 +453,13 @@ fn library_setup(path: &str) -> (Session, DesignSystem, Session, DesignSystem, V let lib = crate::library::Cache::default().load(path).unwrap(); let mut doc = Document::new("Consumer"); let m = &lib.manifest; - doc.libraries_mut().enabled.push(LibraryLink { id: m.id.clone(), name: m.name.clone(), path: path.to_string(), version: m.version }); + doc.libraries_mut().enabled.push(LibraryLink { + id: m.id.clone(), + name: m.name.clone(), + path: path.to_string(), + version: m.version, + ..Default::default() + }); let map = crate::library::import(&mut doc, &lib, &ds.assets(), false).unwrap(); let mut local = ds.remap(&map); local.theme = doc.libraries.collections.iter().find(|r| r.key == ds.theme).map(|r| r.local.clone()).unwrap(); diff --git a/crates/engine/src/doc_list_paths_tests.rs b/crates/engine/src/doc_list_paths_tests.rs index 0d73d0736..f6d8dc197 100644 --- a/crates/engine/src/doc_list_paths_tests.rs +++ b/crates/engine/src/doc_list_paths_tests.rs @@ -56,6 +56,7 @@ fn a_plugin_gets_library_names_but_not_their_links() { name: "Kit".into(), path: LINK.into(), version: 3, + ..Default::default() }); let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))"; install(&mut s, manifest("org.test.libs", "read", &[]), &script(&[("library.list", json!({}))], OnRefusal::Fail, tail)); @@ -73,6 +74,7 @@ fn a_plugin_gets_a_fixed_message_for_library_update_failures() { name: "Kit".into(), path: LINK.into(), version: 3, + ..Default::default() }); let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))"; install(&mut s, manifest("org.test.upd", "read", &[]), &script(&[("library.updates", json!({}))], OnRefusal::Fail, tail)); diff --git a/crates/engine/src/library_beacon_tests.rs b/crates/engine/src/library_beacon_tests.rs index 43629005f..6645e01b1 100644 --- a/crates/engine/src/library_beacon_tests.rs +++ b/crates/engine/src/library_beacon_tests.rs @@ -61,7 +61,7 @@ impl LiveLink for Link { /// A session with `prefs` that opened a file (from disk) whose libraries include [`LINK`]. fn opened(prefs: Prefs, tag: &str) -> (Session, Arc>, std::path::PathBuf) { let mut d = Document::new("Someone else's file"); - d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3 }); + d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3, ..Default::default() }); let path = std::env::temp_dir().join(format!("figcraft-beacon-{}-{tag}.figcraft", std::process::id())); std::fs::write(&path, figcraft_format::save_bytes(&d, None).unwrap()).unwrap(); let mut s = Session::new(); diff --git a/crates/ui-egui/src/library_beacon_tests.rs b/crates/ui-egui/src/library_beacon_tests.rs index e3b001627..3f5949b3e 100644 --- a/crates/ui-egui/src/library_beacon_tests.rs +++ b/crates/ui-egui/src/library_beacon_tests.rs @@ -13,7 +13,13 @@ const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret"; #[test] fn the_assets_panel_checks_an_unknown_library_server_only_when_asked() { let mut s = Session::new(); - s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 1 }); + s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink { + id: "lib".into(), + name: "Kit".into(), + path: LINK.into(), + version: 1, + ..Default::default() + }); let mut u = Ui::new(s); u.app.ui.left_tab = crate::LeftTab::Assets; u.app.ui.libraries = true;