From 11eb27b1276aa9775a5193131cda1de3b999d4fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 01:05:35 +0000 Subject: [PATCH 1/2] Fix #3455: Home recent reopen focuses existing document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary document.open with a path that is already open in another tab now activates that document instead of reading the file again and opening a second window, matching Illustrator Home › Recent Files when the file is already open. The result includes activated: true; unsaved edits and the active tab are kept. ## Tests open_same_path_activates_existing_document in cmd::fileio::tests. Closes storytold/vectorcraft#3455 Co-authored-by: nrq6993-a11y --- crates/engine/src/cmd/fileio/load.rs | 24 ++++++++++++++++++++++++ crates/engine/src/cmd/fileio/tests.rs | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index b7bb78098..1ef1b1e8c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -369,6 +369,11 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { + if let Some(path) = path.as_deref() + && let Some(index) = document_index_for_path(s, path) + { + return activate_open_document(s, index); + } let update_links = s.prefs.update_links == "automatically"; open_read(s, read_for_open(name, bytes, path, p, update_links)?) } @@ -704,3 +709,22 @@ fn same_file(a: &str, b: &str) -> bool { _ => a == b, } } + +/// The open document whose saved path is the same file as `path`, if any. +fn document_index_for_path(s: &Session, path: &str) -> Option { + s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, path))) +} + +/// Bring an already-open document to the front instead of reading the file again (Home › Recent). +fn activate_open_document(s: &mut Session, index: usize) -> Result { + s.set_active(index); + let st = &s.documents()[index]; + Ok(json!({ + "index": index, + "title": st.title(), + "format": st.format, + "warnings": [], + "restored": false, + "activated": true, + })) +} diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index e656965e1..dab9c929e 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -363,6 +363,25 @@ fn templates_open_untitled() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn open_same_path_activates_existing_document() { + let mut s = Session::new(); + let dir = tmp_dir("reopen"); + let path = dir.join("doc.vectorcraft").to_string_lossy().to_string(); + let mut fresh = Session::new(); + fresh.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + std::fs::write(&path, b64(&fresh.execute("document.serialize", &json!({})).unwrap())).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.documents().len(), 1); + s.execute("shape.rectangle", &json!({"x": 1, "y": 2, "width": 10, "height": 10})).unwrap(); + let r = s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(r["activated"], true); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.active_index(), Some(0)); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn open_exts_cover_every_readable_format() { for f in FORMATS.iter().filter(|f| f.read) { From e4db022f494af12f0c5241c9df2de6f5dd2fc2a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 03:57:03 +0000 Subject: [PATCH 2/2] Fix #3455: focus open tab from Home/Open Recent only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Home › Recent Files and File › Open Recent switch to an already-open document tab instead of calling document.open again. document.open is unchanged for File › Open, MCP, and save-and-reopen tests. ## Tests - home_recent_reopen_focuses_an_already_open_document (ui-egui) - Removed engine open_same_path_activates_existing_document Closes storytold/vectorcraft#3455 Co-authored-by: nrq6993-a11y --- crates/engine/src/cmd/fileio/load.rs | 24 ----------------- crates/engine/src/cmd/fileio/tests.rs | 19 -------------- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/io.rs | 38 +++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/tests_home.rs | 28 ++++++++++++++++++++ 6 files changed, 68 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1ef1b1e8c..b7bb78098 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -369,11 +369,6 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { - if let Some(path) = path.as_deref() - && let Some(index) = document_index_for_path(s, path) - { - return activate_open_document(s, index); - } let update_links = s.prefs.update_links == "automatically"; open_read(s, read_for_open(name, bytes, path, p, update_links)?) } @@ -709,22 +704,3 @@ fn same_file(a: &str, b: &str) -> bool { _ => a == b, } } - -/// The open document whose saved path is the same file as `path`, if any. -fn document_index_for_path(s: &Session, path: &str) -> Option { - s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, path))) -} - -/// Bring an already-open document to the front instead of reading the file again (Home › Recent). -fn activate_open_document(s: &mut Session, index: usize) -> Result { - s.set_active(index); - let st = &s.documents()[index]; - Ok(json!({ - "index": index, - "title": st.title(), - "format": st.format, - "warnings": [], - "restored": false, - "activated": true, - })) -} diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index dab9c929e..e656965e1 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -363,25 +363,6 @@ fn templates_open_untitled() { let _ = std::fs::remove_dir_all(dir); } -#[test] -fn open_same_path_activates_existing_document() { - let mut s = Session::new(); - let dir = tmp_dir("reopen"); - let path = dir.join("doc.vectorcraft").to_string_lossy().to_string(); - let mut fresh = Session::new(); - fresh.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); - std::fs::write(&path, b64(&fresh.execute("document.serialize", &json!({})).unwrap())).unwrap(); - s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(s.documents().len(), 1); - s.execute("shape.rectangle", &json!({"x": 1, "y": 2, "width": 10, "height": 10})).unwrap(); - let r = s.execute("document.open", &json!({"path": path})).unwrap(); - assert_eq!(r["activated"], true); - assert_eq!(s.documents().len(), 1); - assert_eq!(s.active_index(), Some(0)); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); - let _ = std::fs::remove_dir_all(dir); -} - #[test] fn open_exts_cover_every_readable_format() { for f in FORMATS.iter().filter(|f| f.read) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8c0555ea9..9895f1c56 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3094,7 +3094,7 @@ fn recent(app: &mut VectorcraftApp, ui: &mut Ui) { for path in files { if recent_row(ui, &path).clicked() { // A failure is reported to the user there. - let _ = crate::io::open_reporting(app, &path); + let _ = crate::io::open_recent_reporting(app, &path); } } } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index ae01b0305..7290da43b 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -214,6 +214,44 @@ pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result Result { + if let Some(r) = try_activate_open_document(app, path)? { + return Ok(r); + } + open_reporting(app, path) +} + +fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result, String> { + let Some(index) = document_index_for_path(app, path) else { + return Ok(None); + }; + let st = &app.session.documents()[index]; + let r = json!({ + "index": index, + "title": st.title(), + "format": st.format, + "warnings": [], + "restored": false, + "activated": true, + }); + app.run("document.activate", json!({ "index": index }))?; + opened(app, &r); + Ok(Some(r)) +} + +fn document_index_for_path(app: &VectorcraftApp, path: &str) -> Option { + app.session.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_open_path(p, path))) +} + +fn same_open_path(a: &str, b: &str) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + fn read(app: &VectorcraftApp, path: &str) -> Result, String> { app.services.read.as_ref().ok_or("no file reader")?(path) } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 16d1fd808..022c85ba4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1266,7 +1266,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { - Some(path) => io::open_reporting(app, &path), + Some(path) => io::open_recent_reporting(app, &path), None => Err("no such recent file".into()), }, "type.findFont" => { diff --git a/crates/ui-egui/src/tests_home.rs b/crates/ui-egui/src/tests_home.rs index 4725552cc..86785557f 100644 --- a/crates/ui-egui/src/tests_home.rs +++ b/crates/ui-egui/src/tests_home.rs @@ -297,3 +297,31 @@ fn home_scrolls_in_a_short_window() { visible_home_texts(&mut app, &ctx, size, 4.1, vec![press(false)]); assert_eq!(app.session.documents().len(), 1, "the card opened its preset"); } + +/// Home › Recent Files and File › Open Recent focus an open tab instead of opening a second copy +/// (#3455); `document.open` is unchanged. +#[test] +fn home_recent_reopen_focuses_an_already_open_document() { + let services = crate::Services { + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + let d = std::env::temp_dir().join(format!("vc-home-reopen-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("doc.vectorcraft").to_string_lossy().to_string(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 2, "width": 10, "height": 10})).unwrap(); + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active_index(), Some(1)); + let r = crate::io::open_recent_reporting(&mut app, &path).unwrap(); + assert_eq!(r["activated"], true); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active_index(), Some(0)); + assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + let _ = std::fs::remove_dir_all(d); +}