From 0404448f74f762c6997a31936eb5e8289b223563 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 00:54:33 +0200 Subject: [PATCH] M1: keep imported file bytes out of the command journal Pasting or dropping a file runs file.import with the whole file as Base64 data, and every successful command was journaled with its parameters, so each imported file stayed in memory a second time for the session. Byte imports, including ones wrapped in command.batch, are no longer journaled. Imports by path and all other commands are unchanged. --- crates/engine/src/import_journal_tests.rs | 51 +++++++++++++++++++++++ crates/engine/src/lib.rs | 28 +++++++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/import_journal_tests.rs diff --git a/crates/engine/src/import_journal_tests.rs b/crates/engine/src/import_journal_tests.rs new file mode 100644 index 000000000..f8fa84efc --- /dev/null +++ b/crates/engine/src/import_journal_tests.rs @@ -0,0 +1,51 @@ +//! Imported file bytes already live in the document: the command log keeps no second copy. + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::Session; + +const SVG: &str = r##""##; + +fn payload() -> String { + base64::engine::general_purpose::STANDARD.encode(SVG) +} + +fn holds_payload(journal: &[(String, Value)]) -> bool { + let payload = payload(); + journal.iter().any(|(_, params)| params.to_string().contains(&payload)) +} + +#[test] +fn byte_imports_keep_no_payload_in_the_journal() { + let mut s = Session::new(); + let before = s.doc().unwrap().node_count(); + s.execute("file.import", &json!({"data": payload(), "name": "pasted.svg"})).unwrap(); + assert!(!holds_payload(&s.journal), "the journal must not retain the Base64 file"); + // Not journaling is no change to the edit itself: still one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node_count(), before); +} + +#[test] +fn batch_wrappers_do_not_retain_import_payloads() { + let mut s = Session::new(); + let nested = + json!({"command": "command.batch", "params": {"commands": [{"command": "file.import", "params": {"data": payload(), "name": "a.svg"}}]}}); + s.execute("command.batch", &json!({"commands": [nested]})).unwrap(); + assert!(!holds_payload(&s.journal)); +} + +#[test] +fn path_imports_and_ordinary_edits_are_still_journaled() { + let dir = std::env::temp_dir().join(format!("figcraft-import-journal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kit.svg"); + std::fs::write(&path, SVG).unwrap(); + let mut s = Session::new(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let ids: Vec<_> = s.journal.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["file.import", "shape.rectangle"]); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5f..b84012a6a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -721,7 +721,8 @@ pub struct Session { /// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no /// theme, so what scripts make is the same everywhere. pub ui_dark: Option, - /// Command log (id + params) for actions/automation. + /// Command log (id + params) for actions/automation. File imports that carry the file bytes + /// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file. pub journal: Vec<(String, Value)>, /// Recently used colours. pub recent_colors: Vec, @@ -746,6 +747,19 @@ impl Default for Session { } } +/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`. +fn command_carries_import_bytes(id: &str, params: &Value) -> bool { + match id { + "file.import" => params::get(params, "data").is_some(), + "command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| { + commands.iter().any(|c| { + c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params)) + }) + }), + _ => false, + } +} + impl Session { /// A session with one new document. pub fn new() -> Self { @@ -885,9 +899,13 @@ impl Session { if spec.history && id != "edit.repeat" { self.last_command = Some((id.to_string(), params.clone())); } - self.journal.push((id.to_string(), params.clone())); - if self.journal.len() > 5000 { - self.journal.drain(..1000); + // Imported bytes already live in the document: a Base64 copy in the log would stay + // for the whole session. + if !command_carries_import_bytes(id, params) { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 5000 { + self.journal.drain(..1000); + } } } Err(_) => { @@ -1550,6 +1568,8 @@ mod html_codegen_tests; #[cfg(test)] mod import_export_tests; #[cfg(test)] +mod import_journal_tests; +#[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] mod interaction_rollback_tests;