From a26ec126c9eb8a9697759fbf82162e2bf9bbe7c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:27:32 -0800 Subject: [PATCH 1/2] Links: the open's notes come back after the last missing-link question After the last answer in Linked File Not Found (Replace with a path, a picked file or Apply to All, Ignore with or without Apply to All, or Relink in the Find Linked Files dialog its Find in Folder... opens), the status bar shows the open's notes again, so hovering it lists them even when the relink or another action showed a message meanwhile. With Update Links: Manually the line about modified files still comes first; with Ask When Modified the notes show while the update question opens. Cancel in either dialog stops the remaining questions and shows the open's notes the same way. A canceled file picker leaves the question open. An open without notes leaves the message as it is. --- crates/ui-egui/src/dialogs.rs | 1 + .../ui-egui/src/dialogs/find_links/tests.rs | 36 +++++ crates/ui-egui/src/dialogs/missing_links.rs | 126 +++++++++++++++++- docs/target-app-parity.md | 1 + 4 files changed, 157 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 04e58d452e..6b99a6e007 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -679,6 +679,7 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == script_run::NOTICE_KIND { script_run::notice_cancel(app, &d); } + missing_links::cancelled(app, &d); // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 1c79b48ce7..650f403542 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -154,6 +154,42 @@ fn files_not_relinked_go_back_to_linked_file_not_found() { assert_eq!(d.fields["notes"], json!(["a note"])); super::super::cancel(&mut app); assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 2); + let opened = crate::io::opened_with_notes(1, "a note"); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), vec!["a note".to_string()])))); +} + +/// Relink, and Cancel, in the Find Linked Files dialog that Linked File Not Found's Find in Folder… +/// opened: the status bar shows the open's notes again and keeps them for its hover. Cancel of the +/// Links panel's Find in Folder… leaves the message as it is. +#[test] +fn the_last_find_in_folder_answer_keeps_the_open_notes() { + let root = folder("notes"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png"]); + let found = at(&root, "found/a.png"); + write(&found, &bytes[0]); + let missing = rows(app.ui.dialog.as_ref().unwrap(), "missing"); + let notes = vec!["a note".to_string(), "another note".to_string()]; + let opened = crate::io::opened_with_notes(2, "a note"); + let find = |app: &mut VectorcraftApp, back: bool| { + let mut d = dialog(app, missing.clone(), vec![], back).unwrap(); + d.fields.insert("notes".into(), json!(notes)); + d.fields.insert("picks".into(), json!([found])); + app.ui.dialog = Some(d); + app.status("Something else"); + }; + find(&mut app, false); + super::super::cancel(&mut app); + assert_eq!(app.ui.status, "Something else"); + find(&mut app, true); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Cancel"); + find(&mut app, true); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 0); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Relink"); } /// A file is chosen when the choice is clear: the only same file, else the only file. A row the diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 28a6e57828..67f1dfacc0 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -8,8 +8,8 @@ //! [`vectorcraft_engine::link_watch`]). //! //! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of -//! images whose file was modified), `notes` (the open's notes, shown with the line about the -//! modified files), `applyToAll`, `path` (the replacement file; picked when empty), `discard` +//! images whose file was modified), `notes` (the open's notes, shown again after the last question +//! or Cancel), `applyToAll`, `path` (the replacement file; picked when empty), `discard` //! (Ignore) and `searchFolder` (set it and confirm for Find in Folder… on that folder). use serde_json::{Value, json}; @@ -117,15 +117,24 @@ pub(super) fn notes(d: &Dialog) -> Vec { notes.filter_map(Value::as_str).map(str::to_string).collect() } +/// Cancel of the dialog `d`: when it is this dialog, or the Find Linked Files dialog its Find in +/// Folder… opened, the rest of the questions are not asked and the status bar shows the open's +/// notes again ([`crate::io::show_opened`]). +pub(super) fn cancelled(app: &mut VectorcraftApp, d: &Dialog) { + if d.kind == KIND || (d.kind == super::find_links::KIND && d.bool("back")) { + crate::io::show_opened(app, None, notes(d)); + } +} + /// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for -/// update. A line about the modified files is followed by the open's `notes` -/// ([`crate::io::show_opened`]). +/// update. After the last question the status bar shows the open's `notes` again, after the line +/// about the modified files when there is one ([`crate::io::show_opened`]), so their hover lists +/// them whatever message the answers showed. pub(crate) fn ask_next(app: &mut VectorcraftApp, rest: Vec, modified: Vec, notes: Vec) { if rest.is_empty() { app.ui.dialog = None; - if let Some(notice) = ask_update(app, modified) { - crate::io::show_opened(app, Some(notice), notes); - } + let notice = ask_update(app, modified); + crate::io::show_opened(app, notice, notes); } else { open(app, rest, modified, ¬es); } @@ -476,6 +485,109 @@ mod tests { } } + /// After the last question of the Linked File Not Found dialog the status bar shows the open's + /// notes again and keeps them for its hover, also when another message was shown meanwhile: + /// after Ignore, Ignore with Apply to All and Cancel, with either Update Links choice, and with + /// Ask When Modified while the update question opens. An open without notes leaves the message + /// as it is. + #[test] + fn the_last_missing_link_answer_keeps_the_open_notes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let notes = vec!["a note".to_string(), "another note".to_string()]; + let opened = crate::io::opened_with_notes(2, "a note"); + let row = |n: u64| json!({ "name": format!("{n}.png"), "path": format!("/gone/{n}.png"), "ids": [n] }); + let answer = |app: &mut VectorcraftApp, fields: &[(&str, bool)]| { + app.status("Something else"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields { + d.fields.insert(k.to_string(), json!(v)); + } + super::super::confirm(app).unwrap(); + }; + for prefs in ["manually", "askWhenModified"] { + app.session.prefs.update_links = prefs.into(); + after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), notes.clone(), false); + answer(&mut app, &[("discard", true)]); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Ignore, {prefs}"); + + after_open_for(&mut app, &json!({ "missingLinks": [row(1), row(2)] }), notes.clone(), false); + answer(&mut app, &[("discard", true), ("applyToAll", true)]); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Apply to All, {prefs}"); + + after_open_for(&mut app, &json!({ "missingLinks": [row(1), row(2)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false); + app.status("Something else"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel asks nothing more"); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Cancel, {prefs}"); + } + + after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false); + answer(&mut app, &[("discard", true)]); + let d = app.ui.dialog.as_ref().expect("asks"); + assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string())); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone())))); + app.ui.dialog = None; + + after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), vec![], false); + answer(&mut app, &[("discard", true)]); + assert_eq!(app.ui.status, "Something else"); + after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), vec![], false); + app.status("Something else"); + super::super::cancel(&mut app); + assert_eq!(app.ui.status, "Something else"); + } + + /// Replace with a file given by path, with a picked file and with Apply to All: after the last + /// one the status bar shows the open's notes again. A canceled file picker leaves the question + /// open and the message as it is. + #[test] + fn the_last_replace_keeps_the_open_notes() { + let notes = vec!["a note".to_string(), "another note".to_string()]; + let opened = crate::io::opened_with_notes(2, "a note"); + let (mut app, dir, _) = opened_without_links("notes"); + let found = dir.join("found"); + std::fs::create_dir_all(&found).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::write(found.join(n), png([0, 0, 200])).unwrap(); + } + app.ui.dialog.as_mut().unwrap().fields.insert("notes".into(), json!(notes)); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(found.join("a.png").to_string_lossy())); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().expect("asks about the next").fields["missing"][0]["name"], "b.png"); + + app.services.pick_open = Some(Box::new(|_: &crate::FilePick| None)); + app.status("Something else"); + assert_eq!(super::super::confirm(&mut app), Err("cancelled".to_string())); + assert_eq!(app.ui.dialog.as_ref().expect("still asks").fields["missing"][0]["name"], "b.png"); + assert_eq!(app.ui.status, "Something else"); + + let b = found.join("b.png").to_string_lossy().into_owned(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(b.clone()))); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "picked"); + let _ = std::fs::remove_dir_all(dir); + + let (mut app, dir, _) = opened_without_links("notes-all"); + let found = dir.join("found"); + std::fs::create_dir_all(&found).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::write(found.join(n), png([0, 0, 200])).unwrap(); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("notes".into(), json!(notes)); + d.fields.insert("applyToAll".into(), json!(true)); + d.fields.insert("path".into(), json!(found.join("a.png").to_string_lossy())); + app.status("Something else"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "both relinked"); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Apply to All"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn apply_to_all_replaces_the_others_from_the_same_folder() { let (mut app, dir, _) = opened_without_links("all"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..83d56e1829 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1884,6 +1884,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Links panel menu's Sort by Size ([Links panel](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/links-panel-in-illustrator.html)) lists the links by file size, smallest first (`links.list {sort: "size"}`). VectorCraft's own rules: a file's size is the one Link Info shows, rows without one (embedded images, a missing file whose size the document does not keep) come last, and equal sizes go by name. - When a document opens with notes and with linked files that changed on disk (Update Links: Manually) or, on the web, can't be read, the status bar shows the line about the linked files, then counts the notes and names the first, and hovering it lists the notes. The same holds when the line about modified files follows the Linked File Not Found dialog, and File › New from Template… shows the notes of the file it opens as File › Open does (`an_open_with_modified_links_keeps_link_notice_and_import_notes_reachable`, `new_from_template_preserves_open_warnings`, `an_open_with_unreadable_links_keeps_import_notes_reachable`, `modified_links_after_the_missing_ones_keep_the_open_notes`, `combined_open_and_link_status_lists_notes_on_hover_and_for_accessibility`). - On the web, when a document opens with linked files that can't be read and linked files that changed on disk (Update Links: Manually), the status bar shows the line about the unreadable files, and the line about the changed files is the first of the notes its hover lists, ahead of the open's notes. With Ask When Modified the update question opens and the line about the unreadable files stays (`a_web_open_with_unreadable_and_modified_links_keeps_both_lines`). + - After the last answer in the Linked File Not Found dialog (Replace, with a path or a picked file, Ignore, either with Apply to All, or Relink in the Find Linked Files dialog its Find in Folder… opens), and after Cancel in either dialog, the status bar shows the open's notes again, after the line about modified files when Update Links is Manually, and hovering it lists them, also when the relink or another action showed a message meanwhile. A canceled file picker leaves the question open (`the_last_missing_link_answer_keeps_the_open_notes`, `the_last_replace_keeps_the_open_notes`, `the_last_find_in_folder_answer_keeps_the_open_notes`). - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. - Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) get Links panel rows of their own the same way, one per linked or placed object in each brush tile, naming the brush; Relink, Update, Embed, Placement Options, Link Info and Go To act on that art and write it back into the brush library, and the ids they are listed by are kept there. Evidence: `links_in_stored_brush_art_are_listed_and_checked_with_ids`, `links_commands_act_on_images_in_stored_brush_art_by_id`, `links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay`. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). From 829c5973d139fce6e9774039bc373d8ad8ab4d7d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 20:23:47 -0800 Subject: [PATCH 2/2] Links: Cancel of a missing-link question keeps the line about modified files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Update Links: Manually, Cancel in Linked File Not Found, or in the Find Linked Files dialog its Find in Folder… opened, shows the line about the linked files that changed on disk before the open's notes, as the last answer does. With Ask When Modified, Cancel still asks nothing more. --- crates/ui-egui/src/dialogs/missing_links.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 67f1dfacc0..5cf08aa84a 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -119,10 +119,13 @@ pub(super) fn notes(d: &Dialog) -> Vec { /// Cancel of the dialog `d`: when it is this dialog, or the Find Linked Files dialog its Find in /// Folder… opened, the rest of the questions are not asked and the status bar shows the open's -/// notes again ([`crate::io::show_opened`]). +/// notes again ([`crate::io::show_opened`]), after the line about the modified files when Update +/// Links doesn't ask about them. pub(super) fn cancelled(app: &mut VectorcraftApp, d: &Dialog) { if d.kind == KIND || (d.kind == super::find_links::KIND && d.bool("back")) { - crate::io::show_opened(app, None, notes(d)); + let modified = d.fields.get("modified").and_then(Value::as_array).map_or(0, Vec::len); + let notice = (modified > 0 && app.session.prefs.update_links != "askWhenModified").then(|| changed_on_disk_status(modified)); + crate::io::show_opened(app, notice, notes(d)); } } @@ -521,7 +524,9 @@ mod tests { app.status("Something else"); super::super::cancel(&mut app); assert!(app.ui.dialog.is_none(), "Cancel asks nothing more"); - assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Cancel, {prefs}"); + let cancelled = + if prefs == "manually" { crate::io::opened_after_notice(&changed_on_disk_status(1), 2, "a note") } else { opened.clone() }; + assert_eq!((&app.ui.status, &app.ui.status_notes), (&cancelled, &Some((cancelled.clone(), notes.clone()))), "Cancel, {prefs}"); } after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false); @@ -538,6 +543,11 @@ mod tests { app.status("Something else"); super::super::cancel(&mut app); assert_eq!(app.ui.status, "Something else"); + app.session.prefs.update_links = "manually".into(); + after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), vec![], false); + app.status("Something else"); + super::super::cancel(&mut app); + assert_eq!(app.ui.status, changed_on_disk_status(1), "Cancel keeps the line about the modified files"); } /// Replace with a file given by path, with a picked file and with Apply to All: after the last