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;