From 9135c7f6f013de8d1608c39e5d65f1c96815255a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 17:07:42 +0200 Subject: [PATCH 01/42] Ribbon: fall back to Home when the active contextual tab is gone (fixes #2329) --- crates/ui-egui/src/ribbon.rs | 7 ++ crates/ui-egui/tests/contextual_ribbon_tab.rs | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 crates/ui-egui/tests/contextual_ribbon_tab.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 356381d66..45856565f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -204,6 +204,11 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let collapsed = app.ui.ribbon_collapsed; let h = if collapsed { 34.0 } else { 34.0 + 92.0 }; + // A contextual tab exists only while its object is selected (Excel hides Table Design once the + // cell leaves the table); a remembered one that is gone would draw an empty ribbon. + if CONTEXTUAL_TABS.contains(&app.ui.ribbon_tab.as_str()) && !contextual_tabs(app).contains(&app.ui.ribbon_tab.as_str()) { + app.ui.ribbon_tab = "Home".to_string(); + } egui::Panel::top("ribbon") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 })) @@ -322,6 +327,8 @@ fn theme_choices(app: &mut SheetApp, ui: &mut Ui) { } } +const CONTEXTUAL_TABS: [&str; 3] = ["Table Design", "Chart Design", crate::paper_view::TAB]; + fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { let mut v = Vec::new(); if let Some(d) = app.session.active() diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/contextual_ribbon_tab.rs new file mode 100644 index 000000000..6e1626dd5 --- /dev/null +++ b/crates/ui-egui/tests/contextual_ribbon_tab.rs @@ -0,0 +1,74 @@ +//! A contextual ribbon tab (Table Design, Chart Design) only exists while its object is selected. +//! The tab remembered in the saved UI state, or left behind when the selection moves away, must +//! not leave the ribbon blank: it falls back to Home. + +use egui::vec2; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn session_with_table() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + session.execute("selection.set", json!({"range": "A2"})).unwrap(); + session +} + +fn app_restored_on(tab: &str, session: Session) -> Harness { + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = tab.into(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +#[test] +fn a_saved_table_design_tab_without_a_table_opens_on_home() { + let mut session = Session::new(); + session.new_workbook(); + let h = app_restored_on("Table Design", session); + assert_eq!(h.state().ui.ribbon_tab, "Home"); +} + +#[test] +fn a_saved_chart_design_tab_without_a_chart_opens_on_home() { + let mut session = Session::new(); + session.new_workbook(); + let h = app_restored_on("Chart Design", session); + assert_eq!(h.state().ui.ribbon_tab, "Home"); +} + +#[test] +fn leaving_the_table_while_on_table_design_returns_to_home() { + let mut h = harness(session_with_table()); + h.state_mut().ui.ribbon_tab = "Table Design".into(); + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the tab stays while the cell is in the table"); + h.state_mut().session.execute("selection.set", json!({"range": "D10"})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Home"); +} From a0ceea1468a72baf4654950cf9d84611e649dd91 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 17:16:30 +0200 Subject: [PATCH 02/42] Open on the Home tab every launch, as in Excel (fixes #2329) --- apps/gridcraft-web/src/prefs.rs | 2 +- apps/gridcraft/src/main.rs | 2 +- crates/ui-egui/src/lib.rs | 8 +++++++ crates/ui-egui/tests/contextual_ribbon_tab.rs | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-web/src/prefs.rs b/apps/gridcraft-web/src/prefs.rs index 7d8c0abf8..987343c56 100644 --- a/apps/gridcraft-web/src/prefs.rs +++ b/apps/gridcraft-web/src/prefs.rs @@ -22,7 +22,7 @@ pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) { { // No usable saved language: keep the browser's, as the desktop keeps the system's. let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language); - app.ui = state; + app.ui = state.at_startup(); app.ui.language = language; } if let Some(s) = prefs.filter(readable) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6..6c0d2f676 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -111,7 +111,7 @@ fn load_prefs(app: &mut SheetApp) { { let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v)); let language = saved.unwrap_or(app.ui.language); - app.ui = ui; + app.ui = ui.at_startup(); app.ui.language = language; } if let Ok(b) = std::fs::read(dir.join("prefs.json")) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index faae9bfae..676359355 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,14 @@ impl Default for UiState { } impl UiState { + /// The state saved by an earlier run, as the app starts with it. The ribbon tab is written to + /// the preferences but not restored: Excel opens on Home every launch, so a contextual tab + /// (Table Design) cannot come back with nothing selected to justify it. + pub fn at_startup(mut self) -> Self { + self.ribbon_tab = "Home".into(); + self + } + pub fn theme_preference(&self) -> egui::ThemePreference { if self.system_theme { egui::ThemePreference::System diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/contextual_ribbon_tab.rs index 6e1626dd5..bdd8b5c93 100644 --- a/crates/ui-egui/tests/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/contextual_ribbon_tab.rs @@ -72,3 +72,25 @@ fn leaving_the_table_while_on_table_design_returns_to_home() { h.run_steps(2); assert_eq!(h.state().ui.ribbon_tab, "Home"); } + +#[test] +fn startup_opens_on_home_not_the_saved_tab() { + let mut saved = gridcraft_ui_egui::UiState::default(); + saved.ribbon_tab = "Table Design".into(); + saved.dark = true; + let json = serde_json::to_string(&saved).unwrap(); + assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}"); + let restored = serde_json::from_str::(&json).unwrap().at_startup(); + assert_eq!(restored.ribbon_tab, "Home", "the saved tab is not restored"); + assert!(restored.dark, "the other preferences are"); +} + +#[test] +fn an_ordinary_tab_stays_put_in_a_session() { + let mut session = Session::new(); + session.new_workbook(); + let mut h = harness(session); + h.state_mut().ui.ribbon_tab = "Data".into(); + h.run_steps(3); + assert_eq!(h.state().ui.ribbon_tab, "Data"); +} From ba980399887494f0539d30cceaf52a5b8b12d05c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:33:31 +0200 Subject: [PATCH 03/42] Keep the in-cell editor wide enough for the next character at high zoom (#2336) --- crates/ui-egui/src/grid.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..acef3f8cb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3087,6 +3087,13 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } } +/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so +/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, +/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. +fn editor_slack(size: f32) -> f32 { + 14.0 + size +} + fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { let Some(ed) = app.editor.as_ref() else { return }; if ed.sheet != wb.active_sheet { @@ -3113,7 +3120,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; // Excel keeps the cell's height for one line and only grows for extra lines. let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - ((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), h) + ((text_w + editor_slack(size)).max(rect.width()).min(geo.cells.right() - rect.left()), h) }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); @@ -4002,6 +4009,15 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { #[cfg(test)] mod tests { + #[test] + fn editor_slack_fits_one_more_glyph_at_high_zoom() { + use super::editor_slack; + // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. + for size in [14.7_f32, 25.0, 60.0] { + assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); + } + } + #[test] fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { use super::{HAlign, painted_halign}; From 599762f534bb47e6505fa6044d6aa2c17bc8fa4e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:33:55 +0200 Subject: [PATCH 04/42] AutoFilter: grow the range when a value is entered in the row below it (#2342) --- crates/engine/src/cmd/data.rs | 42 +++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 6 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 52a716cff..8fe74779c 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -652,6 +652,24 @@ fn filter_blank(v: &Value) -> bool { v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) } +/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). +/// A table's own filter is left to the table, and a row typed after a blank row does not count. +/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. +pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { + if !sh.cells.get(at).is_some_and(|c| !c.value.is_empty() || c.formula.is_some()) { + return; + } + let Some(af) = sh.autofilter.as_ref() else { return }; + let r = af.range; + if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { + return; + } + let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); + if let Some(af) = sh.autofilter.as_mut() { + af.range = grown; + } +} + /// Hides rows of the autofilter range that fail any criterion. pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(sh) = wb.sheet(sheet) else { return }; @@ -1554,6 +1572,30 @@ mod tests { } } + #[test] + fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); + s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); + assert_eq!(range(&s), Some("A1:B4".to_string())); + s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A row typed after a blank row does not grow it. + s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A cell outside the filter's columns does not either. + s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A formula and Ctrl+Enter (range.fill) are entries too. + s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); + assert_eq!(range(&s), Some("A1:B6".to_string())); + s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); + assert_eq!(range(&s), Some("A1:B7".to_string())); + } + #[test] fn list_validation_without_formula1_is_an_error() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1998f0b2c..352797eab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { sh.remove_cell(at); } } + super::data::grow_autofilter(sh, at); cx.touch(sheet, at); Ok(Json::Null) })?; @@ -350,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { sh.remove_cell(at); } } + super::data::grow_autofilter(sh, at); cx.touch(sheet, at); count += 1; } @@ -390,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); } - cx.sheet_mut(sheet)?.set_cell(at, cell); + let sh = cx.sheet_mut(sheet)?; + sh.set_cell(at, cell); + super::data::grow_autofilter(sh, at); cx.touch(sheet, at); } } From 8f2a60d17638aeee39d5152fc48df9a6be7ba292 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:16 +0200 Subject: [PATCH 05/42] Keep the in-cell editor the cell's width while the text fits (#2336) --- crates/ui-egui/src/grid.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index acef3f8cb..859cf1053 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3094,6 +3094,12 @@ fn editor_slack(size: f32) -> f32 { 14.0 + size } +/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the +/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. +fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { + if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } +} + fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { let Some(ed) = app.editor.as_ref() else { return }; if ed.sheet != wb.active_sheet { @@ -3120,7 +3126,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; // Excel keeps the cell's height for one line and only grows for extra lines. let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - ((text_w + editor_slack(size)).max(rect.width()).min(geo.cells.right() - rect.left()), h) + (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); @@ -4009,6 +4015,14 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { #[cfg(test)] mod tests { + #[test] + fn editor_matches_the_cell_while_the_text_fits() { + use super::editor_width; + assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); + } + #[test] fn editor_slack_fits_one_more_glyph_at_high_zoom() { use super::editor_slack; From 45ec9dfbfe6b1a0a82f4c6ddf92fbe68a3e6e608 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 18:44:23 +0200 Subject: [PATCH 06/42] Build the saved UiState in the test with struct update syntax (clippy field_reassign_with_default) --- crates/ui-egui/tests/contextual_ribbon_tab.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/contextual_ribbon_tab.rs index bdd8b5c93..5221f2d60 100644 --- a/crates/ui-egui/tests/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/contextual_ribbon_tab.rs @@ -75,9 +75,7 @@ fn leaving_the_table_while_on_table_design_returns_to_home() { #[test] fn startup_opens_on_home_not_the_saved_tab() { - let mut saved = gridcraft_ui_egui::UiState::default(); - saved.ribbon_tab = "Table Design".into(); - saved.dark = true; + let saved = gridcraft_ui_egui::UiState { ribbon_tab: "Table Design".into(), dark: true, ..Default::default() }; let json = serde_json::to_string(&saved).unwrap(); assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}"); let restored = serde_json::from_str::(&json).unwrap().at_startup(); From 39450b80d1eebfd78b4a0729fb4d57c2b1da832f Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:48:45 +0200 Subject: [PATCH 07/42] Hyperlinks: Ctrl+Alt+click on a link extends the selection again (#2368) --- crates/ui-egui/src/grid.rs | 3 ++- crates/ui-egui/tests/it/hyperlink_ctrl_click.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..75858ad4e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2397,9 +2397,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - // Ctrl+click on a hyperlink's text follows it instead of adding the cell to the selection. + // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. if let Some(h) = sh.hyperlinks.get(&c).cloned() && primary_clicked + && !mods.alt && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 { follow_link(app, &h.target); diff --git a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs index 2e557e12e..a61350d3d 100644 --- a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs +++ b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs @@ -83,3 +83,15 @@ fn ctrl_click_beside_the_link_text_still_extends_the_selection() { assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection"); assert!(h.state().dialog.is_none(), "no link was followed"); } + +#[test] +fn ctrl_alt_click_on_the_link_text_still_extends_the_selection() { + let mut h = harness(); + click_at(&mut h, "B2", 0.9, Modifiers::NONE); + click_at(&mut h, "B4", 0.3, Modifiers { alt: true, ..Modifiers::COMMAND }); + let d = h.state().session.active().unwrap(); + let ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + assert_eq!(ranges, ["B2", "B4"], "Ctrl+Alt+click adds the hyperlink cell to the selection"); + assert_eq!(d.selection.active.a1(), "B4", "the link was not followed"); + assert!(h.state().dialog.is_none(), "no link dialog"); +} From 49f26bdd79f88ee12637a6728022557ac749f327 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:51:53 +0200 Subject: [PATCH 08/42] Fill handle: the Auto Fill Options button on a right-to-left sheet is checked in screen space (#2368) --- crates/ui-egui/src/grid.rs | 3 +- .../ui-egui/tests/it/fill_options_button.rs | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 75858ad4e..ee9eb600b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3396,7 +3396,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet let size = vec2(18.0, 18.0); let corner = geo.range_rect(sh, tag.target).right_bottom(); let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); - if !geo.cells.contains_rect(button) { + // `button` is in screen space, so compare it with the mirrored cells area. + if !geo.mirror_rect(geo.cells).contains_rect(button) { return; } let lang = crate::i18n::current(ui.ctx()); diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs index 2cb209fa9..d735f846a 100644 --- a/crates/ui-egui/tests/it/fill_options_button.rs +++ b/crates/ui-egui/tests/it/fill_options_button.rs @@ -209,3 +209,65 @@ fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() { let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap()); assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner"); } + +/// Right-to-left sheet with column A made `chars` wide (so column B's right edge, where the fill +/// range B1:B6 ends, sits far enough right to be scrolled to either edge of the cells area). +fn rtl_wide_a(chars: u32) -> Harness { + harness(|s| { + one_two(s); + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": chars})).unwrap(); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + }) +} + +/// Logical right edge of the filled column B (scroll-dependent) and the current geometry. +fn b_right(h: &Harness) -> (f32, Geo) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + (geo.cell_rect(sheet, CellRef::parse("B6").unwrap()).right(), geo) +} + +fn scroll_b_right_to(h: &mut Harness, logical_x: f32) { + let (now, geo) = b_right(h); + h.state_mut().view_mut().unwrap().scroll.x += (now - logical_x) / geo.z; + h.run_steps(3); + let got = b_right(h).0; + assert!((got - logical_x).abs() < 1.0, "setup: column B ends at logical x {got}, wanted {logical_x}"); +} + +/// Logical x of the range's corner is `cells.right() - 25`: the 18 px button (at corner + 2 .. +/// corner + 20) fits in the cells area, so on screen it sits at the left edge of the mirrored cells +/// area, left of the filled range. `fill_options_tag` tested the mirrored button against the +/// unmirrored cells, i.e. against the row-heading strip it does not own, and hid it. +#[test] +fn the_button_shows_near_the_left_screen_edge_on_a_right_to_left_sheet() { + let mut h = rtl_wide_a(220); + let (_, geo) = b_right(&h); + scroll_b_right_to(&mut h, geo.cells.right() - 25.0); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + let (_, geo) = b_right(&h); + let cells = h.state().grid.cells_rect.unwrap(); + assert!(geo.header_w > 20.0 && geo.cells.right() - 25.0 + 20.0 > geo.cells.right() - geo.header_w, "setup: in the zone the old check hides"); + let button = h.query_by_label("Auto Fill Options").expect("the button fits inside the cells area, so it shows").rect(); + assert!(cells.contains_rect(button), "{button:?} outside {cells:?}"); +} + +/// The corner is 10 px left of `cells.left()` (scrolled partly behind the row headings, which are +/// on the right on screen). The button would start 8 px behind the headings, so it must not be +/// drawn there. The old check let it through because the mirrored button fits the unmirrored cells. +#[test] +fn the_button_stays_off_the_row_headings_on_a_right_to_left_sheet() { + let mut h = rtl_wide_a(20); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some(), "setup: the button shows before scrolling"); + let (_, geo) = b_right(&h); + scroll_b_right_to(&mut h, geo.cells.left() - 10.0); + + let cells = h.state().grid.cells_rect.unwrap(); + if let Some(b) = h.query_by_label("Auto Fill Options") { + let button = b.rect(); + assert!(cells.contains_rect(button), "{button:?} overlaps the row headings, cells area is {cells:?}"); + } +} From 0c14f595550ca0970a10fa963bc35f3f3cf60e2f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:21:27 +0200 Subject: [PATCH 09/42] PivotTables: Show Details, double-click a value cell to list its source records on a new sheet (#2364) --- crates/engine/src/cmd/pivot.rs | 207 +++++++++++++++++- crates/engine/src/pivot.rs | 32 +++ crates/ui-egui/src/grid.rs | 7 + crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/pivot_show_details.rs | 76 +++++++ docs/gaps.md | 6 +- 6 files changed, 317 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/it/pivot_show_details.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 43c3aa4b5..80f8d0205 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -179,6 +179,15 @@ pub fn specs() -> Vec { has_doc, change_source ), + cmd!( + "pivot.showDetails", + "Show Details", + [], + None, + "{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}", + has_doc, + show_details + ), cmd!( "pivot.delete", "Delete PivotTable", @@ -654,16 +663,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { let (si, anchor) = match target { Some(t) => t, None => { - let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); - let nm = cx.wb.next_sheet_name(); - cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); - for n in cx.wb.names.iter_mut() { - if let Some(sc) = n.scope.as_mut() - && *sc >= at - { - *sc += 1; - } - } + let at = insert_sheet_before_active(cx); new_sheet_at = Some(at); (at, CellRef::new(2, 0)) } @@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { Ok(res) } +/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a +/// Show Details sheet); returns its index. +fn insert_sheet_before_active(cx: &mut Ctx) -> usize { + let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); + let nm = cx.wb.next_sheet_name(); + cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); + for n in cx.wb.names.iter_mut() { + if let Some(sc) = n.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + at +} + +/// Show Details (double-click a value cell): the source records behind it, as values with their +/// column's number format, under the source headers on a new sheet. +fn show_details(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let active = d.wb.active_sheet; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let pt = sh + .pivots + .iter() + .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; + let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + if d.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } + let count = records.len(); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let si = insert_sheet_before_active(cx); + new_sheet_at = Some(si); + let base = cx.wb.styles.get(StyleId::default()).clone(); + let fmts: Vec = src + .formats + .iter() + .map(|f| { + let mut st = base.clone(); + st.num_fmt = NumFmt::new(f); + cx.wb.styles.intern(st) + }) + .collect(); + // Columns wide enough for their text, as the PivotTable report's are (dates would show + // as ####); the first rows are enough to judge a column. + let font = base.font.clone(); + let widths: Vec = src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); + let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); + let widest = texts + .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) + .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); + (widest + 14.0).min(320.0) + }) + .collect(); + let sh = cx.sheet_mut(si)?; + for (c, w) in widths.into_iter().enumerate() { + if w > sh.col_width(c as u32) { + sh.cols.entry(c as u32).or_default().size = Some(w); + } + } + for (c, h) in src.headers.iter().enumerate() { + sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); + } + for (r, i) in records.iter().enumerate() { + let Some(rec) = src.rows.get(*i) else { continue }; + for (c, v) in rec.iter().enumerate() { + let style = fmts.get(c).copied().unwrap_or_default(); + if v.is_empty() && style == StyleId::default() { + continue; + } + sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); + } + } + let name = sh.name.clone(); + cx.wb.active_sheet = si; + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(json!({"sheet": name, "records": count})) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + fn summarize_table(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let name = match str_param(p, "table") { @@ -2236,6 +2334,7 @@ mod tests { "data.refreshAll", "pivot.changeSource", "pivot.delete", + "pivot.showDetails", ]; let hostile = [ json!({}), @@ -2363,4 +2462,92 @@ mod tests { assert_eq!(l[0]["anchor"], "A3"); s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); } + + /// The active sheet's first five columns as shown, down to the first blank row. + fn sheet_rows(s: &mut Session) -> Vec> { + let mut out = Vec::new(); + for r in 0..30u32 { + let row: Vec = (0..5) + .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) + .collect(); + if row.iter().all(String::is_empty) { + break; + } + out.push(row); + } + out + } + + #[test] + fn show_details_lists_a_value_cells_records_on_a_new_sheet() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 is East × Apples (100 + 60); the active cell is the default. + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let r = s.execute("pivot.showDetails", json!({})).unwrap(); + assert_eq!(r["records"], 2); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 3); + assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's"); + assert_eq!(d.wb.active().unwrap().name, "Sheet3"); + assert_eq!( + sheet_rows(&mut s), + vec![ + vec!["Region", "Product", "Date", "Units", "Sales"], + vec!["East", "Apples", "1/15/2026", "10", "100"], + vec!["East", "Apples", "9/9/2026", "6", "60"], + ] + ); + // The dates fit their column instead of showing ####. + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); + // One undo step takes the sheet away. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + } + + #[test] + fn show_details_on_a_total_takes_every_record_it_adds_up() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + let records = |s: &mut Session, cell: &str| { + let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + n + }; + assert_eq!(records(&mut s, "E5"), 5, "East's row total"); + assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); + assert_eq!(records(&mut s, "E8"), 12, "the grand total"); + // A subtotal at the top of its group, in a two-level row axis. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(records(&mut s, "B4"), 5); + assert_eq!(text(&s, "A6"), "Pears"); + assert_eq!(records(&mut s, "B6"), 2); + } + + #[test] + fn show_details_leaves_out_the_records_the_report_filters_out() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); + assert_eq!(r["records"], 2); + let rows = sheet_rows(&mut s); + assert_eq!(rows.len(), 3); + assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); + } + + #[test] + fn show_details_needs_a_value_cell() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { + assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } } diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 08ec15dfb..43a77c11a 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -803,6 +803,30 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) - /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; + compute_from(wb, pt, &src, None) +} + +/// Show Details: the source list and the indices of its records behind the value cell at +/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when +/// that cell holds no value (a label, a header, a blank). +pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; + compute_from(wb, pt, &src, Some(&mut probe))?; + let Some((rp, cp)) = probe.hit else { return Ok(None) }; + let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); + Ok(Some((src, records))) +} + +/// What [`detail_records`] asks of a report computation: the row and column item paths of the +/// value cell at `at`, and the paths of every record the filters let through. +struct Probe { + at: (usize, usize), + hit: Option<(Vec, Vec)>, + paths: Vec<(usize, Vec, Vec)>, +} + +fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); @@ -914,6 +938,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result raw, }, }; + if let Some(pr) = probe.as_deref_mut() + && pr.at == (r, l + j) + { + pr.hit = Some((rp.to_vec(), cp.to_vec())); + } g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..1b72576e5 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2386,6 +2386,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && !mods.shift { app.grid.last_click = None; + // A PivotTable's value cell shows its source records on a new sheet (Excel's Show + // Details); its labels and headers still edit. + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) + && app.run("pivot.showDetails", json!({"cell": c.a1()})).is_ok() + { + return; + } // Following a hyperlink requires a single click in Excel; double-click edits. app.begin_edit(None, false); return; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b89253bb9..9b04d3869 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -59,6 +59,7 @@ mod page_layout_view; mod page_setup_margins; mod paste_special_dialog; mod pivot_pane; +mod pivot_show_details; mod popup_dismissal; mod protect_password; mod right_to_left; diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs new file mode 100644 index 000000000..e78ca53ec --- /dev/null +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -0,0 +1,76 @@ +//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's +//! Show Details); a label of the report still opens the cell for editing. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap(); + // Report at D3: D4 East, E4 its Sum of Sales. + session + .execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn double_click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + for _ in 0..2 { + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); + } +} + +#[test] +fn double_click_on_a_value_cell_shows_its_records_on_a_new_sheet() { + let mut h = harness(); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + let wb = &app.session.active().unwrap().wb; + assert_eq!(wb.sheets.len(), 2); + let sheet = wb.active().unwrap(); + assert_eq!(sheet.name, "Sheet2"); + let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display(); + assert_eq!( + [text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")], + ["Region", "Sales", "East", "100", "East", "30", ""] + ); +} + +#[test] +fn double_click_on_a_row_label_still_edits() { + let mut h = harness(); + let pos = center(&h, "D4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_some()); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7e2e40dc0..b07727991 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (§8: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -84,7 +84,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields - and values on rows done). + and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists + its source records on a new sheet; the "Enable show details" option isn't modelled yet. - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -178,6 +179,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | | 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) | From 7111812b8793dbf0cd04aeed8c8e2da02722d691 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:41:47 +0200 Subject: [PATCH 10/42] PivotTables: Show Details refuses an out-of-date report, and reports its errors instead of editing the cell --- crates/engine/src/cmd/pivot.rs | 20 +++++++++++++++-- crates/engine/src/pivot.rs | 22 +++++++++++++++++-- crates/ui-egui/src/grid.rs | 15 +++++++++---- crates/ui-egui/tests/it/pivot_show_details.rs | 14 ++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 80f8d0205..3b6ed83a4 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -753,10 +753,10 @@ fn show_details(s: &mut Session, p: &Json) -> Result { .pivots .iter() .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) - .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; - let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; if d.wb.protected_structure { return Err(other("The workbook is protected and cannot be changed.")); } @@ -2541,6 +2541,22 @@ mod tests { assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); } + #[test] + fn show_details_refuses_a_report_its_source_has_moved_on_from() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 still shows East × Apples (160), but the first East record is now Central's. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // After a refresh East × Apples (now B6) has the one record left. + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "A6"), "East"); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); + } + #[test] fn show_details_needs_a_value_cell() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 43a77c11a..45c4eb0a5 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -806,14 +806,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result)>, String> { let src = read_source(wb, &pt.source, pivot_sheet)?; let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; - compute_from(wb, pt, &src, Some(&mut probe))?; + let out = compute_from(wb, pt, &src, Some(&mut probe))?; let Some((rp, cp)) = probe.hit else { return Ok(None) }; + // Excel reads its PivotTable cache; without one, the report written by the last refresh + // must still be what the source gives. + let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; + let current = pt.last_range.is_some_and(|r| { + r.height() == out.height().max(1) + && r.width() == out.width.max(1) + && out.grid.iter().enumerate().all(|(ri, line)| { + line.iter().enumerate().all(|(ci, oc)| sh.value(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)) == oc.value) + }) + }); + if !current { + return Err("The PivotTable report is out of date. Refresh it first.".into()); + } let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); Ok(Some((src, records))) } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 1b72576e5..9fbdfc911 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2388,10 +2388,17 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.last_click = None; // A PivotTable's value cell shows its source records on a new sheet (Excel's Show // Details); its labels and headers still edit. - if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) - && app.run("pivot.showDetails", json!({"cell": c.a1()})).is_ok() - { - return; + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { + let not_a_value_cell = + gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); + match app.run("pivot.showDetails", json!({"cell": c.a1()})) { + Ok(_) => return, + Err(e) if e == not_a_value_cell => {} + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + } } // Following a hyperlink requires a single click in Excel; double-click edits. app.begin_edit(None, false); diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index e78ca53ec..629774b04 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -74,3 +74,17 @@ fn double_click_on_a_row_label_still_edits() { assert!(app.editor.is_some()); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } + +#[test] +fn double_click_on_a_value_cell_of_a_protected_workbook_says_why_and_does_not_edit() { + let mut h = harness(); + h.state_mut().session.execute("review.protectWorkbook", json!({"on": true})).unwrap(); + h.run_steps(2); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("protected")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} From 43a5a3c435f1c8829c974a8180800ba4bb4685d5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:00:07 +0200 Subject: [PATCH 11/42] PivotTables: Show Details checks the report is up to date before looking at the cell --- crates/engine/src/cmd/pivot.rs | 14 ++++++++++++++ crates/engine/src/pivot.rs | 13 ++++++++----- crates/ui-egui/tests/it/pivot_show_details.rs | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 3b6ed83a4..229ff22b6 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -2557,6 +2557,20 @@ mod tests { assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); } + #[test] + fn show_details_refuses_a_report_that_would_now_be_shorter() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total. + s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap(); + assert_eq!(text(&s, "A8"), "Grand Total"); + let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } + #[test] fn show_details_needs_a_value_cell() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 45c4eb0a5..397dd5ad2 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -812,26 +812,29 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; /// Show Details: the source list and the indices of its records behind the value cell at /// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when -/// that cell holds no value (a label, a header, a blank). An error when the report on the sheet -/// is no longer what the source computes: the cell's records would be another cell's. +/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report +/// on the sheet is no longer what the source computes: the cell's records would be another cell's. pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { let src = read_source(wb, &pt.source, pivot_sheet)?; let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; let out = compute_from(wb, pt, &src, Some(&mut probe))?; - let Some((rp, cp)) = probe.hit else { return Ok(None) }; // Excel reads its PivotTable cache; without one, the report written by the last refresh - // must still be what the source gives. + // must still be what the source gives. Checked first: in a stale report even a cell that + // computes as a label may show a value. let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; let current = pt.last_range.is_some_and(|r| { r.height() == out.height().max(1) && r.width() == out.width.max(1) && out.grid.iter().enumerate().all(|(ri, line)| { - line.iter().enumerate().all(|(ci, oc)| sh.value(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)) == oc.value) + line.iter().enumerate().all(|(ci, oc)| { + sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value + }) }) }); if !current { return Err("The PivotTable report is out of date. Refresh it first.".into()); } + let Some((rp, cp)) = probe.hit else { return Ok(None) }; let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); Ok(Some((src, records))) } diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index 629774b04..9f11b29eb 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -88,3 +88,19 @@ fn double_click_on_a_value_cell_of_a_protected_workbook_says_why_and_does_not_ed assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("protected")), "{:?}", app.message); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } + +#[test] +fn double_click_in_an_out_of_date_report_asks_for_a_refresh() { + let mut h = harness(); + // West goes away without a refresh: E6, the Grand Total on the sheet, has no cell in the + // report the source now gives. + h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap(); + h.run_steps(2); + let pos = center(&h, "E6"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the cell must not open for editing"); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} From 15a705e23457d502f053e2b7b9fac1876830d403 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:59 -0400 Subject: [PATCH 12/42] i18n: missing translations are reported, never a failure (#2383) A string a catalog lacks shows in English. Coverage, key-parity and per-language count tests and tests of one language's exact wording are removed; UI wiring tests take their expected text from the catalog. cargo xtask i18n-report lists the missing strings per language and always passes; cargo xtask ci runs it first. --- crates/ui-egui/src/backstage.rs | 5 +- crates/ui-egui/src/formula_locale.rs | 7 - crates/ui-egui/src/i18n.rs | 210 ++--------------- crates/ui-egui/src/i18n/ja.rs | 132 +---------- crates/ui-egui/tests/it/file_menu.rs | 24 +- crates/ui-egui/tests/it/formula_locale.rs | 5 - crates/ui-egui/tests/it/interaction.rs | 50 ++-- .../tests/it/translated_form_fields.rs | 17 +- docs/localization-parity.md | 8 +- xtask/Cargo.toml | 2 +- xtask/src/i18n_report.rs | 218 ++++++++++++++++++ xtask/src/main.rs | 11 +- 12 files changed, 297 insertions(+), 392 deletions(-) create mode 100644 xtask/src/i18n_report.rs diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c2acf025b..f5bdc20ab 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1084,11 +1084,8 @@ mod tests { } assert!(age(Language::En, f64::MAX).ends_with(" years ago")); // Other languages put the count where their own words want it. - assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前"); - assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen"); - assert_eq!(age(Language::Fr, 90.0), "il y a 1 min"); - assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa"); for lang in Language::ALL { + assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY)); for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] { let words = age(lang, seconds); assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}"); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 699167934..d53c5fa34 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> { Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) } -/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the -/// function help tables are checked against them. -#[cfg(test)] -pub(crate) fn argument_names(signature: &str) -> Vec<&str> { - split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default() -} - /// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's /// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). fn argument_name(arg: &str) -> &str { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4156c2cd3..345037b52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String { mod tests { use super::*; - /// Help tables are sorted, unique, and keyed by text the function catalog still has. + /// Help tables are sorted and unique: lookups binary-search them. #[test] - fn function_help_is_sorted_and_keyed_by_current_catalog_text() { - // Lookups binary-search the tables. A key that no longer matches the catalog (an English - // description reworded, an argument renamed) would quietly show English again. - let catalog = gridcraft_engine::cmd::formulas::function_list(); - let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); - let arguments: std::collections::HashSet<&str> = - catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect(); + fn function_help_tables_are_sorted_and_unique() { for language in Language::ALL { let Some((description_table, argument_table)) = language.function_help() else { continue }; - for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { + for table in [description_table, argument_table] { assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); for (english, translated) in table { - assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use"); assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); } } @@ -343,105 +336,27 @@ mod tests { } #[test] - fn translations_are_unique_and_preserve_unknown_text() { - for (i, (en, ja)) in ja::CORE.iter().enumerate() { - assert!(!ja.is_empty()); - assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label)); - } + fn untranslated_text_falls_back_to_english() { + let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label); for (english, _) in ja::CORE { - assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}"); - assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); - assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); - assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); - assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}"); - assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}"); - assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}"); - assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); - assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}"); - assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}"); - assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}"); - assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}"); - assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}"); - assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}"); - assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}"); - assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}"); - assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}"); + assert_eq!(Language::En.tr(english), label(english)); } - assert_eq!(Language::Ja.tr("Data"), "データ"); - assert_eq!(Language::Zh.tr("Data"), "数据"); - assert_eq!(Language::Ko.tr("Data"), "데이터"); - assert_eq!(Language::Ru.tr("Data"), "Данные"); - assert_eq!(Language::PtBr.tr("Data"), "Dados"); - assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); - assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); - assert_eq!(Language::Es.tr("Data"), "Datos"); - assert_eq!(Language::El.tr("Data"), "Δεδομένα"); - assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας"); - assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}"); - assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); - assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::De.tr("Data"), "Daten"); - assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Fr.tr("Data"), "Données"); - assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Tr.tr("Data"), "Veri"); - assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::PtBr.tr("Date"), "Data"); - assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1"); - // Czech "Data" is spelled as in English, so probe it with a label that differs. - assert_eq!(Language::Cs.tr("Data"), "Data"); - assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); - assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Tr.tr("Formulas"), "Formüller"); - // A `context|label` key shows only its label in English and splits the translation. + for language in Language::ALL { + assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated"); + } + // A `context|label` key shows only its label in English. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); - assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); - assert_eq!(Language::Ja.tr("Orientation"), "方向"); - assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); - assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); - assert_eq!(Language::Sr.tr("Data"), "Podaci"); - assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija"); - assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija"); - assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü"); - assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme"); - assert_eq!(Language::Pl.tr("Data"), "Dane"); - assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja"); - assert_eq!(Language::It.tr("Data"), "Dati"); - assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri"); - assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento"); - assert_eq!(Language::It.tr("Orientation"), "Orientamento"); - assert_eq!(Language::Uk.tr("Data"), "Дані"); - assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Vi.tr("Data"), "Dữ liệu"); - assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy"); - assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ"); - assert_eq!(Language::Nl.tr("Data"), "Gegevens"); - assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand"); - assert_eq!(Language::Nl.tr("Orientation"), "Richting"); - assert_eq!(Language::Ro.tr("Data"), "Date"); - assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare"); - assert_eq!(Language::Ro.tr("Orientation"), "Orientare text"); + // Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole. + assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first.")); + assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); + assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); assert_eq!(Language::parse("xx"), None); } /// A catalog whose translations slip onto their neighbours' keys still covers every key, so /// check the shape of each translation against its English: placeholders survive, and a - /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French - /// once shipped with over a hundred translations shifted this way ("Close" read "Société"). + /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. #[test] fn translations_keep_the_shape_of_their_english() { let placeholders = @@ -457,106 +372,19 @@ mod tests { } } } - for (english, french) in [ - ("Close", "Fermer"), - ("File", "Fichier"), - ("Save", "Enregistrer"), - ("Animations", "Animations"), - ("Company", "Société"), - ("Show Detail", "Afficher le détail"), - ("Solver", "Solveur"), - ] { - assert_eq!(Language::Fr.tr(english), french); - } - } - - #[test] - fn german_cell_and_header_context_menus_are_fully_translated() { - for label in [ - "Cut", - "Copy", - "Paste", - "Paste Special…", - "Insert…", - "Delete…", - "Clear Contents", - "Filter by Selected Cell's Value", - "Sort A to Z", - "Sort Z to A", - "New Comment", - "New Note", - "Format Cells…", - "Pick From Drop-down List…", - "Define Name…", - "Insert Copied Cells", - "Insert Copied Cells…", - "Insert Cut Cells", - "Insert", - "Delete", - "Row Height…", - "AutoFit Row Height", - "Column Width…", - "AutoFit Column Width", - "Hide", - "Unhide", - "Group", - ] { - assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}"); - } - } - - #[test] - fn sheet_tab_menu_and_autosave_labels_are_translated() { - let labels = [ - "Insert Sheet", - "Delete", - "Rename", - "Move or Copy…", - "Duplicate", - "Tab Color", - "No Color", - "Hide", - "Unhide…", - "Protect Sheet…", - "Unprotect Sheet…", - "Select All Sheets", - "AutoSave", - "AutoSave: save after every change (workbooks saved to a file)", - "Previous sheet", - "Next sheet", - "All sheets", - "New sheet (⇧F11)", - ]; - for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] { - for label in labels { - assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code()); - } - } - assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado"); } #[test] fn translations_never_show_the_context_prefix() { // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its // value shows it on screen (File › Options read "Options|Général" in French). - for language in Language::ALL { - for (english, _) in ja::CORE { + let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]); + for english in keys { + for language in Language::ALL { let shown = language.tr(english); assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code()); } } - assert_eq!(Language::Fr.tr("Options|General"), "Général"); - assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer"); - assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage"); - assert_eq!(Language::Sr.tr("Options|General"), "Opšte"); - assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj"); - assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz"); - assert_eq!(Language::Nl.tr("Options|General"), "Algemeen"); - assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan"); - assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven"); - assert_eq!(Language::Ro.tr("Options|General"), "General"); - assert_eq!(Language::Ro.tr("Options|Save"), "Salvare"); - assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare"); } #[test] diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 482852d8d..2df4cfc11 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1,12 +1,12 @@ //! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's //! Japanese wording, and anything absent falls back to English. //! -//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs, -//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels, -//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian -//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap. +//! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog +//! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items, +//! template names and native-menu strings. The two lists never overlap. A string a language doesn't +//! translate shows in English; `cargo xtask i18n-report` lists them per language. -/// English label → Japanese, the shared reference set every catalog covers. +/// English label → Japanese, the shared reference set. pub(super) const CORE: &[(&str, &str)] = &[ ("Paste", "貼り付け"), ("Paste Values", "値の貼り付け"), @@ -1150,99 +1150,17 @@ mod tests { DIALOGS.iter().chain(CORE).map(|(english, _)| *english) } - /// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and - /// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are - /// decoded, so the keys compare equal to the ones in the tables above. - fn string_literal(source: &str, start: usize) -> Option<(String, usize)> { - if !source[start..].starts_with('"') { - return None; - } - let mut value = String::new(); - let mut chars = source[start + 1..].char_indices(); - while let Some((offset, c)) = chars.next() { - match c { - '"' => return Some((value, start + 1 + offset + 1)), - '\\' => match chars.next().map(|(_, c)| c) { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('r') => value.push('\r'), - Some('0') => value.push('\0'), - Some('\\') => value.push('\\'), - Some('"') => value.push('"'), - Some('\'') => value.push('\''), - Some('u') => { - let mut hex = String::new(); - for (_, c) in chars.by_ref() { - if c == '}' { - break; - } - if c != '{' { - hex.push(c); - } - } - if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - value.push(c); - } - } - // A line continuation (backslash, newline, indentation) adds nothing. - _ => {} - }, - _ => value.push(c), - } - } - None - } - - /// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a - /// string literal whose `=>` is followed by another string literal, which covers the block form - /// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from - /// drifting apart again instead of checking a remembered count. - fn portuguese_keys() -> Vec { - let source = include_str!("pt.rs"); - let mut literals: Vec<(String, usize, usize)> = Vec::new(); - let mut i = 0; - while i < source.len() { - let rest = &source[i..]; - if rest.starts_with("//") { - i += rest.find('\n').unwrap_or(rest.len()); - continue; - } - if rest.starts_with('"') { - let (value, end) = string_literal(source, i).unwrap(); - literals.push((value, i, end)); - i = end; - continue; - } - i += rest.chars().next().unwrap().len_utf8(); - } - let mut keys = Vec::new(); - for pair in literals.windows(2) { - let (key, _, end) = &pair[0]; - let start = pair[1].1; - let gap = source[*end..start].trim(); - if gap == "=>" || gap == "=> {" { - keys.push(key.clone()); - } - } - keys - } - #[test] - fn keys_are_unique_and_every_value_is_translated() { + fn keys_are_unique_and_no_value_is_blank() { let mut seen = HashSet::new(); for (english, japanese) in DIALOGS.iter().chain(CORE) { - assert!(!japanese.is_empty(), "empty translation for {english:?}"); assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}"); assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}"); } - assert_eq!(seen.len(), catalog_keys().count()); - assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers"); - assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it"); - assert_eq!(catalog_keys().count(), 954); } #[test] - fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { + fn extra_labels_do_not_overlap_the_catalog() { let catalog: HashSet<&str> = catalog_keys().collect(); let mut seen = HashSet::new(); for (english, japanese) in EXTRA { @@ -1252,40 +1170,4 @@ mod tests { assert_eq!(translate(english), Some(*japanese)); } } - - #[test] - fn covers_every_key_the_portuguese_catalog_translates() { - let portuguese = portuguese_keys(); - assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count"); - let japanese: HashSet<&str> = catalog_keys().collect(); - for key in &portuguese { - assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not"); - } - assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set"); - } - - #[test] - fn high_value_strings_use_excel_japanese_wording() { - for (english, japanese) in [ - ("OK", "OK"), - ("Cancel", "キャンセル"), - ("Don't Save", "保存しない"), - ("Text Box", "テキスト ボックス"), - ("Row Height", "行の高さ"), - ("Column Width", "列の幅"), - ("Sort", "並べ替え"), - ("Filter", "フィルター"), - ("Conditional Formatting", "条件付き書式"), - ("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"), - ("Save a copy as .{format}", ".{format} としてコピーを保存"), - ("Format Cells|Fill", "塗りつぶし"), - ("Insert\nSlicer", "スライサーの\n挿入"), - ] { - assert_eq!(translate(english), Some(japanese), "{english:?}"); - assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}"); - } - // A `context|label` key shows only its label in English; an unknown label keeps the text. - assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill"); - assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog"); - } } diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 047efe1b1..092876b8d 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{Services, SheetApp}; fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { @@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { #[test] fn translated_options_headings_show_no_context_prefix() { - for (code, headings, prefixed, (view, view_prefixed)) in [ - ("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")), - ("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")), - ("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")), - ("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")), - ] { + for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] { let mut h = harness(Services::default()); - h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap(); + h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap(); h.state_mut().run("file.options", serde_json::json!({})).unwrap(); h.run_steps(3); assert_eq!(page(&h), Some("options")); - for heading in headings { - assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading"); - } - for label in prefixed { - assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context"); + for key in ["Options|General", "Options|Save"] { + let heading = language.tr(key); + assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}"); + assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading"); } // Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same // prefix. The View tab may share the word, so count at least the two checkboxes. h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); h.state_mut().ui.ribbon_tab = "Page Layout".into(); h.run_steps(3); - assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context"); - assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}"); + let view = language.tr("Sheet Options|View"); + assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}"); + assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}"); } } diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 16f4dac15..98ab70d57 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -176,14 +176,12 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"pt-BR"})).unwrap(); assert_eq!(app.ui.language, Language::PtBr); - assert_eq!(app.ui.language.tr("Data"), "Dados"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); assert!(!enter(&mut app, "=SUMA(1;2)")); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::PtBr); - assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación"); } #[test] @@ -193,7 +191,6 @@ fn czech_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap(); assert_eq!(app.ui.language, Language::Cs); - assert_eq!(app.ui.language.tr("Formulas"), "Vzorce"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); @@ -225,8 +222,6 @@ fn turkish_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"tr-TR"})).unwrap(); assert_eq!(app.ui.language, Language::Tr); - assert_eq!(app.ui.language.tr("Data"), "Veri"); - assert_eq!(app.ui.language.tr("Formulas"), "Formüller"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 46e981d0a..94fefe6d3 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, PointerButton}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; @@ -400,8 +401,7 @@ fn renders_every_ribbon_tab_without_panicking() { } #[test] -fn language_switch_translates_the_ribbon_and_persists() { - use gridcraft_ui_egui::i18n::Language; +fn language_switch_renders_the_ribbon_and_persists() { let mut h = harness(blank()); // English by default whatever the host's locale: only the desktop app reads the system language. assert_eq!(h.state().ui.language, Language::En); @@ -415,31 +415,13 @@ fn language_switch_translates_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - for (language, expected) in [ - (Language::Zh, "数据"), - (Language::Ja, "データ"), - (Language::Ko, "데이터"), - (Language::Ru, "Данные"), - (Language::PtBr, "Dados"), - (Language::De, "Daten"), - (Language::Fr, "Données"), - (Language::Tr, "Veri"), - (Language::It, "Dati"), - ] { + for language in Language::ALL { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking assert_eq!(h.state().ui.language, language); - assert_eq!(language.tr("Data"), expected); let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); assert_eq!(restored.language, language); } - // Czech spells "Data" as English does, so the Czech table is checked on a label that differs. - h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap(); - h.run_steps(2); - assert_eq!(h.state().ui.language, Language::Cs); - assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); - let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); - assert_eq!(restored.language, Language::Cs); h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); assert_eq!(h.state().ui.language, Language::En); } @@ -452,16 +434,21 @@ fn pivot_fields_pane_follows_the_interface_language() { h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap(); h.run_steps(4); - for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] { - assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`"); + let es = |english| Language::Es.tr(english); + for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] { + assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english)); + } + if es("PivotTable Fields") != "PivotTable Fields" { + assert!(h.query_by_label("PivotTable Fields").is_none()); } - assert!(h.query_by_label("PivotTable Fields").is_none()); h.get_by_label("Region ▾").click(); h.run_steps(2); - for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] { - assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`"); + for english in ["Move to Column Labels", "Group by Years", "Remove Field"] { + assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english)); + } + if es("Remove Field") != "Remove Field" { + assert!(h.query_by_label("Remove Field").is_none()); } - assert!(h.query_by_label("Remove Field").is_none()); } #[test] @@ -492,9 +479,12 @@ fn format_cells_draws_its_labels_in_the_interface_language() { h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); h.run_steps(2); - assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); - assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); - h.get_by_label("Wissenschaftlich").click(); + let scientific = Language::De.tr("Scientific"); + assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated"); + if scientific != "Scientific" { + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + } + h.get_by_label(scientific).click(); h.run_steps(2); let style = &h.state().dialog.as_ref().unwrap().values["style"]; assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); diff --git a/crates/ui-egui/tests/it/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs index 35847a425..b284bf786 100644 --- a/crates/ui-egui/tests/it/translated_form_fields.rs +++ b/crates/ui-egui/tests/it/translated_form_fields.rs @@ -4,6 +4,7 @@ use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; use serde_json::json; fn harness() -> egui_kittest::Harness<'static, SheetApp> { @@ -23,19 +24,17 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { #[test] fn form_fields_are_translated() { - for (dialog, translated, english) in [ - ("pageSetup", ["Margem esquerda (pol.):", "Rodapé (pol.):"], ["Left margin (in):", "Footer (in):"]), - ("insertPaste", ["Deslocar células para a direita", "Deslocar células para baixo"], ["Shift cells right", "Shift cells down"]), - ] { + for (dialog, labels) in [("pageSetup", ["Left margin (in):", "Footer (in):"]), ("insertPaste", ["Shift cells right", "Shift cells down"])] { let mut h = harness(); h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap(); h.state_mut().open_dialog(dialog, json!({})); h.run_steps(3); - for label in translated { - assert!(h.query_by_label_contains(label).is_some(), "{dialog}: no {label}"); - } - for label in english { - assert!(h.query_by_label_contains(label).is_none(), "{dialog}: {label} is untranslated"); + for english in labels { + let translated = Language::PtBr.tr(english); + assert!(h.query_by_label_contains(translated).is_some(), "{dialog}: no {translated}"); + if translated != english { + assert!(h.query_by_label_contains(english).is_none(), "{dialog}: {english} is untranslated"); + } } } } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 979ac1b81..39a9b3f91 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus; Dutch interface language; Romanian interface catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -96,8 +96,9 @@ language (none so far) and of localized function names. ## Infrastructure (do first) 1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes, - status and error messages, command labels, function descriptions), with a missing-key check - in `cargo xtask ci`. Language switching and the system-language default already exist (#31). + status and error messages, command labels, function descriptions). A string a catalog lacks + shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings + per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31). 2. Locale-aware formulas: localized function-name tables and separators at the formula-bar boundary only (files keep en-US invariant names), locale number and date formats, list separators in CSV. @@ -109,6 +110,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | | 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) | diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index cc3da3bb2..c40c97697 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false -description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | version | stats | ico" +description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | i18n-report | version | stats | ico" [lints] workspace = true diff --git a/xtask/src/i18n_report.rs b/xtask/src/i18n_report.rs new file mode 100644 index 000000000..d72a887fa --- /dev/null +++ b/xtask/src/i18n_report.rs @@ -0,0 +1,218 @@ +//! `cargo xtask i18n-report`: the interface strings each language doesn't translate yet, read from +//! the catalogs in `crates/ui-egui/src/i18n/`. A string a catalog lacks shows in English, so the +//! report always succeeds; `cargo xtask ci` runs it first so the list shows in the gate log. +//! +//! The reference set is every key any catalog translates. A catalog is a source file with a +//! `fn translate`; its keys are the string literals that start a match arm (`"key" => …`) or open a +//! table row (`("key", …)`). With the `engine` feature it also lists function help rows keyed by +//! English the function catalog no longer has: those show the English help. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::path::Path; + +/// Missing keys listed per language; the rest are counted. +const LIST_CAP: usize = 50; + +pub fn run(root: &Path) -> Result<(), String> { + let dir = root.join("crates/ui-egui/src/i18n"); + let mut catalogs = BTreeMap::new(); + let mut help = BTreeMap::new(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => { + println!("i18n-report: can't read {}: {e}", dir.display()); + return Ok(()); + } + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { continue }; + if let Some(language) = stem.strip_suffix("_functions") { + help.insert(language.to_owned(), catalog_keys(&source)); + } else if source.contains("fn translate(") { + catalogs.insert(stem, catalog_keys(&source)); + } + } + print!("{}", render(&catalogs)); + print!("{}", function_help(&help)); + Ok(()) +} + +/// Per language: how many reference keys it lacks, then the first [`LIST_CAP`] of them. +pub fn render(catalogs: &BTreeMap>) -> String { + let reference: BTreeSet<&String> = catalogs.values().flatten().collect(); + let mut out = + format!("i18n-report: {} interface strings across {} catalogs; a string a catalog lacks shows in English\n", reference.len(), catalogs.len()); + for (language, keys) in catalogs { + let missing: Vec<&&String> = reference.iter().filter(|key| !keys.contains(key.as_str())).collect(); + let _ = writeln!(out, " {language}: {} missing", missing.len()); + list(&mut out, missing.iter().map(|key| key.as_str()), missing.len()); + } + out +} + +fn list<'a>(out: &mut String, keys: impl Iterator, count: usize) { + for key in keys.take(LIST_CAP) { + let _ = writeln!(out, " {key:?}"); + } + if count > LIST_CAP { + let _ = writeln!(out, " … and {} more", count - LIST_CAP); + } +} + +/// Function help rows whose English the function catalog no longer has (an English description +/// reworded, an argument renamed). +#[cfg(feature = "engine")] +fn function_help(help: &BTreeMap>) -> String { + let catalog = gridcraft_engine::cmd::formulas::function_list(); + let mut known: BTreeSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); + known.extend(catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(argument_names)); + let mut out = String::new(); + for (language, keys) in help { + let stale: Vec<&str> = keys.iter().map(String::as_str).filter(|key| !known.contains(key)).collect(); + let _ = writeln!(out, " {language} function help: {} rows, {} keyed by English the function catalog no longer has", keys.len(), stale.len()); + list(&mut out, stale.iter().copied(), stale.len()); + } + out +} + +#[cfg(not(feature = "engine"))] +fn function_help(_: &BTreeMap>) -> String { + String::new() +} + +/// `SUM(number1, [number2], ...)` -> `number`, `number`: brackets, index and `...` dropped. +#[cfg(feature = "engine")] +fn argument_names(signature: &str) -> Vec<&str> { + let Some(inner) = signature.split_once('(').and_then(|(_, rest)| rest.strip_suffix(')')) else { return Vec::new() }; + inner + .split(", ") + .map(|arg| { + let arg = arg.strip_prefix('[').unwrap_or(arg); + let arg = arg.strip_suffix(']').unwrap_or(arg); + arg.trim_end_matches(|c: char| c.is_ascii_digit()) + }) + .filter(|name| !name.is_empty() && *name != "...") + .collect() +} + +/// The keys of one catalog source: string literals that start a match arm or open a table row. +/// Comments and the test module are skipped. +pub fn catalog_keys(source: &str) -> BTreeSet { + let source = source.split("#[cfg(test)]").next().unwrap_or(source); + let mut keys = BTreeSet::new(); + for (value, start, end) in literals(source) { + let before = source.get(..start).unwrap_or("").trim_end(); + let after = source.get(end..).unwrap_or("").trim_start(); + if after.starts_with("=>") || (before.ends_with('(') && after.starts_with(',')) { + keys.insert(value); + } + } + keys +} + +/// Every string literal outside `//` comments: its decoded value, and the byte range from its +/// opening quote to just past its closing one. +fn literals(source: &str) -> Vec<(String, usize, usize)> { + let mut out = Vec::new(); + let mut chars = source.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if c == '/' && source.get(i..).is_some_and(|rest| rest.starts_with("//")) { + for (_, c) in chars.by_ref() { + if c == '\n' { + break; + } + } + continue; + } + if c != '"' { + continue; + } + let mut value = String::new(); + let mut end = source.len(); + while let Some((j, c)) = chars.next() { + match c { + '"' => { + end = j + 1; + break; + } + '\\' => match chars.next().map(|(_, c)| c) { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('0') => value.push('\0'), + Some(c @ ('\\' | '"' | '\'')) => value.push(c), + Some('u') => { + let mut hex = String::new(); + for (_, c) in chars.by_ref() { + match c { + '}' => break, + '{' => {} + c => hex.push(c), + } + } + if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + value.push(c); + } + } + // A line continuation skips the newline and the next line's indentation. + Some('\n') => { + while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { + chars.next(); + } + } + _ => {} + }, + c => value.push(c), + } + } + out.push((value, i, end)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_match_arms_and_table_rows_and_lists_what_each_catalog_lacks() { + let matched = r#" +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Einfügen", // "Commented" => "x", + "Line\nbreak \"quoted\"" => { + "Zeile" + } + _ => return None, + }) +} +#[cfg(test)] +mod tests { const T: &[(&str, &str)] = &[("Test only", "x")]; } +"#; + let table = r#" +pub(super) const CORE: &[(&str, &str)] = &[ + ("Paste", "貼り付け"), + ( + "Long \u{2026}", + "長い", + ), +]; +pub(super) fn translate(text: &str) -> Option<&'static str> { None } +"#; + let de = catalog_keys(matched); + assert_eq!(de.iter().map(String::as_str).collect::>(), ["Line\nbreak \"quoted\"", "Paste"]); + let ja = catalog_keys(table); + assert_eq!(ja.iter().map(String::as_str).collect::>(), ["Long …", "Paste"]); + + let report = render(&BTreeMap::from([("de".to_owned(), de), ("ja".to_owned(), ja)])); + assert!(report.starts_with("i18n-report: 3 interface strings across 2 catalogs"), "{report}"); + assert!(report.contains(" de: 1 missing\n \"Long …\"\n"), "{report}"); + assert!(report.contains(" ja: 1 missing\n \"Line\\nbreak \\\"quoted\\\"\"\n"), "{report}"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0c9f5547d..fe54b0f4e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,6 +6,7 @@ #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] mod assets; +mod i18n_report; mod ico; mod keytips; mod layers; @@ -21,11 +22,12 @@ const USAGE: &str = "\ usage: cargo xtask commands: - ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check, - keytips --check (stops at the first failure; the parity check runs only if - docs/parity-checklist.md exists) + ci i18n-report, fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, + parity --check, keytips --check (stops at the first failure; the parity check runs + only if docs/parity-checklist.md exists) test workspace tests (cargo nextest when installed, else cargo test), then doctests assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md + i18n-report list the interface strings each language doesn't translate yet (always passes) layers enforce the crate dependency layering (AGENTS.md \"Layering\") wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call parity [--check] [--out PATH] @@ -44,6 +46,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), + Some("i18n-report") => i18n_report::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_doctest()), @@ -194,6 +197,8 @@ fn cmd_doctest() -> Result<(), String> { fn cmd_ci() -> Result<(), String> { type Step = (&'static str, Box Result<(), String>>); let steps: Vec = vec![ + // Missing translations never fail the gate; the list goes first so every log shows it. + ("i18n-report", Box::new(|| i18n_report::run(&root()))), ( "fmt", Box::new(|| { From c746eea8764eb5919b1b0ff4ffc55e02940d5407 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:43:22 +0200 Subject: [PATCH 13/42] Fix MOD returning wrong sign and magnitude for tiny divisors Found while testing crates/functions/src/math.rs:305-319 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/math.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 7611dc410..998a1c069 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -306,8 +306,8 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } - let q = (x / d).floor(); - let r = x - d * q; + // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. + let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. if r != 0.0 && (r < 0.0) != (d < 0.0) { return Ok(r + d); @@ -1442,6 +1442,19 @@ mod tests { close(ev1("ODD", 0.0), 1.0); } + #[test] + fn mod_tiny_divisor_stays_in_range() { + for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { + match ev2("MOD", x, d) { + Value::Number(r) => { + assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); + assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); + } + other => panic!("MOD({x}, {d}) = {other:?}"), + } + } + } + #[test] fn arithmetic() { close(ev2("MOD", 3.0, 2.0), 1.0); From 7cf44d0fbbb853617a09a608d2fe988b64f5ccac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 16:05:43 -0400 Subject: [PATCH 14/42] Integration and review fixes for #2367, #2369 and #2387 - #2369: its test joins the single ui-egui integration-test binary (tests/it), as main now builds them. - #2367: a cell set to empty text is a blank entry and doesn't grow the AutoFilter (CodeRabbit). - #2387: MOD's range check also covers the sign-corrected remainder (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/data.rs | 5 ++++- crates/functions/src/math.rs | 4 +--- crates/ui-egui/tests/{ => it}/contextual_ribbon_tab.rs | 0 crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) rename crates/ui-egui/tests/{ => it}/contextual_ribbon_tab.rs (100%) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8fe74779c..8f7c47fb8 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -656,7 +656,7 @@ fn filter_blank(v: &Value) -> bool { /// A table's own filter is left to the table, and a row typed after a blank row does not count. /// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { - if !sh.cells.get(at).is_some_and(|c| !c.value.is_empty() || c.formula.is_some()) { + if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { return; } let Some(af) = sh.autofilter.as_ref() else { return }; @@ -1589,6 +1589,9 @@ mod tests { // A cell outside the filter's columns does not either. s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); assert_eq!(range(&s), Some("A1:B5".to_string())); + // Empty text is a blank entry, not a value. + s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); // A formula and Ctrl+Enter (range.fill) are entries too. s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); assert_eq!(range(&s), Some("A1:B6".to_string())); diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 998a1c069..53a92f00f 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -309,9 +309,7 @@ fn modulo(x: f64, d: f64) -> R { // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. - if r != 0.0 && (r < 0.0) != (d < 0.0) { - return Ok(r + d); - } + let r = if r != 0.0 && (r < 0.0) != (d < 0.0) { r + d } else { r }; if r.abs() >= d.abs() { return Ok(0.0); } diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs similarity index 100% rename from crates/ui-egui/tests/contextual_ribbon_tab.rs rename to crates/ui-egui/tests/it/contextual_ribbon_tab.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 9b04d3869..fc00fb520 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -16,6 +16,7 @@ mod comments_pane; mod commit_after_pointing; mod committed_text_fields; mod context_menu_stays_open; +mod contextual_ribbon_tab; mod control_timeout; mod dialog_button_row; mod dialog_confirm; From 2329507a8e14e31fd559d44035329052d4768d28 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 16:51:16 -0400 Subject: [PATCH 15/42] ci(sonar): don't cancel superseded runs on main, so coverage is published --- .github/workflows/sonarcloud.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: From bbf36000d4ec11d85daa8c0104aacd7ad74475d5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:36 -0400 Subject: [PATCH 16/42] Engine: the recorder counts a dropped step without serializing it Once a step has been dropped, or the step count is full, push serialized each later step only to count it; a file.open step holds a whole file. The checks that need no size come first. --- crates/engine/src/record.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 5b8100ed4..7206c21a3 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -49,9 +49,14 @@ pub struct Recorder { impl Recorder { /// Adds a step, or counts it as dropped once the buffer is full. pub fn push(&mut self, step: RecordedStep) { + // Once a step has been dropped, or the step count is full, the rest are counted without + // being serialized (a `file.open` step holds a whole file). + if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT { + self.dropped = self.dropped.saturating_add(1); + return; + } let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); - let fits = self.steps.len() < RECORD_LIMIT && self.bytes.saturating_add(size) <= RECORD_MAX_BYTES; - if fits && self.dropped == 0 { + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { self.bytes = self.bytes.saturating_add(size); self.steps.push(step); } else { From 43b0e39e8e5ed89c16928454ad2d91b766e22d6b Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:49 -0400 Subject: [PATCH 17/42] Engine: set_many refuses one key given twice Two items under one key, in any case, would leave two hidden names of one name, of which an xlsx file keeps one, and get would mix their chunks. A caller's mistake, refused before anything changes. Regression test. --- crates/engine/src/cmd/stash.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index e36635aff..d714bf6c8 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -76,7 +76,11 @@ pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> { MAX_ITEM_CHARS / (1024 * 1024) ))); } - keys.insert(key.to_ascii_lowercase()); + // Two items under one key (names compare without case) would leave two hidden names of + // one name, of which an xlsx file keeps one: a caller's mistake, refused. + if !keys.insert(key.to_ascii_lowercase()) { + return Err(EngineError::Other(format!("`{key}` is given twice."))); + } for (i, formula) in chunk_formulas(text).into_iter().enumerate() { planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); } @@ -375,6 +379,18 @@ mod tests { assert_eq!(wb.names, vec![own]); } + #[test] + fn set_many_refuses_one_key_twice() { + // Two items under one key, in any case, would leave two hidden names of one name. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_other_0", "\"stays\"")); + let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string(); + assert!(err.contains("given twice"), "{err}"); + assert_eq!(wb.names.len(), 1, "nothing changed"); + set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap(); + assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays"))); + } + #[test] fn a_chunk_number_with_leading_zeros_is_another_name() { // `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a From 551eda7e8eed3fe5db97dc1b2742bd93c612bae9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:08:49 -0400 Subject: [PATCH 18/42] Engine: the xlsx copy gets an empty macro list, not a copy of the shared one cleared out.macros shares its list with the workbook being saved, so clear() copied every macro first. --- crates/engine/src/macros/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 5a779758d..f0c83e549 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -108,7 +108,8 @@ pub fn with_names(wb: &Workbook) -> crate::Result> { out.names.retain(|n| !stored(n)); let items: Vec<(String, String)> = wb.macros.iter().enumerate().map(|(i, m)| (format!("{MACRO_KEY}{i}"), macro_json(m).to_string())).collect(); stash::set_many(&mut out, &items)?; - out.macros.clear(); + // An empty list of its own, not a copy of the shared one cleared. + out.macros = gridcraft_model::Macros::default(); Ok(Some(out)) } From 2b33fcf881e40ef4ad1effaea270e2762f7b008d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:49:43 -0400 Subject: [PATCH 19/42] Engine: a failed command rolls back the undo groups it opened A command that opened an undo group and then returned an error (a `?` between `group_begin` and `group_end`) left the group open for good: Undo and Redo refused with "finish or stop the running action first", later edits made no undo steps, and nothing could stop the group. `execute` now rolls back every group a failed command opened, as `group_rollback` does, restoring the workbook, selection, undo and redo history to where each group began. Only a command that succeeds keeps a group it opened, which is what a debugger's start needs. `Session::abort_groups(doc)` and the menu-less command `automate.stopAction` roll back the groups left open on a document, for a debugger the person abandons or a caller that dropped its token. The command is not undoable and works while a group is open, and the Undo refusal now names it. --- crates/engine/src/cmd/extra.rs | 16 ++++++ crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/lib.rs | 29 +++++++++++ crates/engine/src/tests/seams.rs | 89 ++++++++++++++++++++++++++++++++ docs/architecture.md | 12 +++-- 5 files changed, 143 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index a566f7f12..6197dd5e2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -212,6 +212,15 @@ pub fn specs() -> Vec { trust_scripts ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), + cmd!( + noundo "automate.stopAction", + "Stop Action", + [], + None, + "{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began", + has_doc, + stop_action + ), // Cell controls, themes, borders, styles cmd!( "insert.checkbox", @@ -1088,6 +1097,13 @@ fn run_script(s: &mut Session, p: &Json) -> Result { Ok(json!({"ran": n})) } +/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and +/// Redo work there again. +fn stop_action(s: &mut Session, _: &Json) -> Result { + let uid = s.doc()?.uid; + Ok(json!({"stopped": s.abort_groups(uid)})) +} + fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 766a41005..8a0bb1052 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -108,7 +108,7 @@ pub fn can_redo(s: &Session) -> std::result::Result<(), String> { /// Undo and Redo wait while an undo group is open on the active document (an action that spans /// several calls is still running). fn not_grouping(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first".into()) } else { Ok(()) } + if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) } } pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8a8d8bcfa..3b40d7401 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -594,6 +594,12 @@ impl Session { return Err(EngineError::Internal(id.to_string(), msg)); } }; + // A command that fails leaves none of its own undo groups open (a `?` between + // `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a + // command that succeeds keeps a group it opened (a debugger's start). + if result.is_err() { + self.rollback_groups_from(first_group); + } if spec.journal && result.is_ok() { // A command that started recording is not part of the recording; one that stopped it // finds no recorder. @@ -751,6 +757,29 @@ impl Session { Ok(changed) } + /// Stops the running action on the document with uid `doc`: every undo group open on it is + /// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo + /// and redo history as they were when the outermost one began. Their tokens are then spent + /// (ending or rolling one back reports that it has already ended). Returns whether a group + /// was open. `automate.stopAction` runs it on the active document. + pub fn abort_groups(&mut self, doc: u64) -> bool { + let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false }; + // Rolling back the outermost group ends the ones opened inside it. + self.group_rollback(GroupToken { id, doc }).is_ok() + } + + /// Rolls back every open undo group numbered `first` or later (the groups a failed command + /// opened), outermost first on each document. + fn rollback_groups_from(&mut self, first: u64) { + let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect(); + for (id, doc) in opened { + // A group inside one rolled back before it is already gone. + if self.groups.iter().any(|g| g.id == id) { + let _ = self.group_rollback(GroupToken { id, doc }); + } + } + } + /// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per /// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest. /// Only steps after the latest mark go: a running command's start, an open group's start and diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 0f1bc3072..9d32c66d1 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -643,6 +643,95 @@ fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() { } } +#[test] +fn a_command_that_fails_rolls_back_the_groups_it_opened() { + // A `?` between `group_begin` and `group_end` used to leave the group open for good: Undo + // and Redo refused, and later edits made no steps. + crate::cmd::register_undoable_test_command("test.groupThenFail", |s, _| { + let _token = s.group_begin("Half")?; + put(s, "A1", "half done")?; + Err(EngineError::Other("stopped halfway".into())) + }); + crate::cmd::register_undoable_test_command("test.editGroupThenFail", |s, _| { + put(s, "B1", "outside")?; + let _token = s.group_begin("Half")?; + put(s, "B2", "inside")?; + Err(EngineError::Other("stopped halfway".into())) + }); + let mut s = s(); + set(&mut s, "A1", "before"); + set(&mut s, "A2", "undone"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("selection.set", json!({"range": "C3"})).unwrap(); + let uid = s.doc().unwrap().uid; + let err = s.execute("test.groupThenFail", json!({})).unwrap_err().to_string(); + assert!(err.contains("stopped halfway"), "{err}"); + assert!(!s.in_group(uid), "no group is left open"); + assert_eq!(v(&s, "A1").display(), "before"); + assert_eq!(s.doc().unwrap().selection.a1(), "C3"); + assert_eq!(undo_labels(&s), ["Enter Cell"]); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history comes back"); + assert_eq!(s.execute("history.list", json!({})).unwrap()["grouping"], json!(false)); + set(&mut s, "A3", "next"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "later edits make steps again"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A3").is_empty()); + + // What the command changed before opening the group stays, as for any failed command, and is + // a step of its own; what it changed inside the group goes. + assert!(s.execute("test.editGroupThenFail", json!({})).is_err()); + assert!(!s.in_group(uid)); + assert_eq!((v(&s, "B1").display(), v(&s, "B2").display()), ("outside".into(), String::new())); + assert_eq!(undo_labels(&s), ["Enter Cell", "test.editGroupThenFail"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "B1").is_empty()); + + // A group open before the command stays open: only the command's own group goes. + let token = s.group_begin("Outer").unwrap(); + set(&mut s, "D1", "outer"); + assert!(s.execute("test.groupThenFail", json!({})).is_err()); + assert!(s.in_group(uid), "the outer group is still running"); + assert_eq!((v(&s, "A1").display(), v(&s, "D1").display()), ("before".into(), "outer".into())); + assert!(s.group_end(token).unwrap()); + assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); +} + +#[test] +fn stop_action_rolls_back_a_group_left_open() { + // A host that dropped its token, or a debugger the person wants to abandon: the message on + // Undo names the command that stops it, and the command puts the start back. + let mut s = s(); + set(&mut s, "A1", "start"); + set(&mut s, "A2", "undone"); + s.execute("edit.undo", json!({})).unwrap(); + let uid = s.doc().unwrap().uid; + let token = s.group_begin("Debug").unwrap(); + set(&mut s, "A1", "step 1"); + drop(s.group_begin("Inner").unwrap()); + set(&mut s, "A3", "step 2"); + let err = s.execute("edit.undo", json!({})).unwrap_err().to_string(); + assert!(err.contains("automate.stopAction"), "{err}"); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": true})); + assert!(!s.in_group(uid)); + assert_eq!(v(&s, "A1").display(), "start"); + assert!(v(&s, "A3").is_empty()); + assert_eq!(undo_labels(&s), ["Enter Cell"], "stopping is not a step of its own"); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history comes back"); + assert!(s.group_end(token).is_err(), "the token is spent"); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": false})); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A2").display(), "undone"); + // Other documents keep their groups. + let other = s.new_workbook(); + let other_uid = s.documents()[other].uid; + let _kept = s.group_begin("Theirs").unwrap(); + s.set_active(0); + assert_eq!(s.execute("automate.stopAction", json!({})).unwrap(), json!({"stopped": false})); + assert!(s.in_group(other_uid)); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { diff --git a/docs/architecture.md b/docs/architecture.md index f51f3a61b..63119fe16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -59,8 +59,12 @@ Actions made of many commands (macros, a debugger that runs one step per call) u closes its own groups still makes one undo step for itself, covering its edits inside and outside the groups. A command that ends a group open before it leaves the group its step and makes one of its own for what it changes after the end; a group a command opens and leaves open starts - where the command did. The history limit is applied when the outermost command finishes; until - then the steps made inside it are bounded on their own. + where the command did, but only when the command succeeds: a command that fails rolls back + every group it opened, as `group_rollback` does. `Session::abort_groups(doc)`, and the command + `automate.stopAction` on the active workbook, roll back the groups left open on a document (a + debugger the person abandons, a caller that dropped its token), so Undo works there again. The + history limit is applied when the outermost command finishes; until then the steps made inside + it are bounded on their own. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. Commands run by other commands are not recorded, and the buffer is independent of the journal @@ -103,6 +107,6 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| -| 2026-10-23 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | +| 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | From 2e72407fc68eb12ba2072e70d342279086c93960 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:51:37 -0400 Subject: [PATCH 20/42] Engine: the recorder skips a call that only opened a dialog A menu-style call with no parameters, such as `file.open`, `data.fromTextCsv` or `home.formatCells` with `{}`, only queues its dialog and succeeds. The recorder kept it as a step with empty parameters, and then kept the dialog's confirming call as well, so playing the recording back ran the command twice, or failed on the empty step when played back headless. Only `execute_headless` took the step back, and only for the open and save-as dialogs. `execute` now compares the queued UI requests before and after a top-level command and does not record one that queued a dialog. The special case in `execute_headless` goes (it keeps turning a missing file into an error), and with it `Recorder::truncate`, which had no other caller. --- crates/engine/src/lib.rs | 17 ++++++++--------- crates/engine/src/record.rs | 9 --------- crates/engine/src/tests/seams.rs | 22 ++++++++++++++++++++++ docs/architecture.md | 3 ++- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b40d7401..90de44385 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -549,6 +549,7 @@ impl Session { None => (0, 0, String::new(), Selection::default()), }); let doc_uid = before.as_ref().map(|(uid, ..)| *uid); + let requests_before = self.ui_requests.len(); let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len)); self.marks.extend(marked); self.depth = self.depth.saturating_add(1); @@ -602,8 +603,11 @@ impl Session { } if spec.journal && result.is_ok() { // A command that started recording is not part of the recording; one that stopped it - // finds no recorder. - if let Some((doc, sheet, sheet_name, selection)) = place + // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no + // file, `home.formatCells` with no style) is not recorded either: the dialog's own + // call is, so playing the recording back runs the command once, with its parameters. + let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog) && let Some(rec) = self.recorder.as_mut() { rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); @@ -878,17 +882,12 @@ impl Session { /// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no /// file yet, `data.getData`) is an error instead of a silent success. pub fn execute_headless(&mut self, id: &str, params: Json) -> Result { - let recorded = self.recorder.as_ref().map(|r| (r.steps.len(), r.dropped)); let r = self.execute(id, params); let requests = self.take_ui_requests(); let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); + // A call that only opened a dialog is not recorded (see `execute`), so the recording + // has no step to take back here. if r.is_ok() && asked_for_file { - // The caller gets an error, so the recording keeps no step for the command. - if let Some((steps, dropped)) = recorded - && let Some(rec) = self.recorder.as_mut() - { - rec.truncate(steps, dropped); - } let what = match id { "file.open" => "`path` or `base64`", "data.getData" | "data.fromTextCsv" => "`path` or `text`", diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index 7206c21a3..7804be6c9 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -63,13 +63,4 @@ impl Recorder { self.dropped = self.dropped.saturating_add(1); } } - - /// Forgets the steps after the first `steps` and puts the dropped count back to `dropped`, - /// for a command the caller received as failed after it was recorded. - pub fn truncate(&mut self, steps: usize, dropped: usize) { - for step in self.steps.drain(steps.min(self.steps.len())..) { - self.bytes = self.bytes.saturating_sub(serde_json::to_vec(&step).map_or(0, |v| v.len())); - } - self.dropped = dropped; - } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 9d32c66d1..86db0622f 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -751,6 +751,28 @@ fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() { assert_eq!(rec.dropped, 0); } +#[test] +fn a_call_that_only_opens_a_dialog_is_not_recorded() { + // Menu-style calls with no parameters only queue their dialog; the dialog's confirming call + // is the step, so playing the recording back runs each command once, with its parameters. + let mut s = s(); + s.start_recording(); + for id in ["file.open", "data.fromTextCsv", "home.formatCells"] { + s.execute(id, json!({})).unwrap_or_else(|e| panic!("{id}: {e}")); + let dialogs = s.take_ui_requests(); + assert!(dialogs.iter().any(|q| matches!(q, crate::UiRequest::Dialog(..))), "{id} opens a dialog: {dialogs:?}"); + } + assert!(recorded_ids(&s).is_empty(), "{:?}", recorded_ids(&s)); + set(&mut s, "A1", "1"); + s.execute("home.formatCells", json!({"range": "A1", "style": {"bold": true}})).unwrap(); + assert_eq!(recorded_ids(&s), ["cell.set", "home.formatCells"]); + // A dialog queued before the call, and not taken yet, does not hide the call. + s.execute("file.open", json!({})).unwrap(); + set(&mut s, "A2", "2"); + assert_eq!(recorded_ids(&s), ["cell.set", "home.formatCells", "cell.set"]); + assert_eq!(s.stop_recording().unwrap().dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); diff --git a/docs/architecture.md b/docs/architecture.md index 63119fe16..f820998f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u it are bounded on their own. - **Recording buffer** (`record.rs`). `Session::start_recording` collects each successful top-level command with the document, sheet and selection it ran in, without secret parameters. - Commands run by other commands are not recorded, and the buffer is independent of the journal + Commands run by other commands are not recorded, nor is a menu-style call that only opened a + dialog (the dialog's confirming call is the step), and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and `FromFile` for workbooks opened from a file, and for workbooks that received a paste from one From 0fa6e78863595069425af606f541fdeabd3ac898 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:53:38 -0400 Subject: [PATCH 21/42] Engine: stash items keep characters XML can't carry The xlsx writer drops characters XML 1.0 can't carry: controls other than tab, line feed and carriage return, and U+FFFE and U+FFFF. A stash item holding one lost it on save without a warning, and so did a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a stored macro parameter changed silently. `chunk_formulas` now writes each such character, and the marker itself, as the noncharacter U+FDD0 followed by four hex digits, and `get` turns them back for `_gc_` items. A marker that isn't followed by four hex digits naming a character is read as it is. An escape counts as five units against the 255-unit literal limit and is never split across literals, and the guard on the stored size allows the longest item even when every character is escaped. Legacy `_sc_` items are read unchanged. --- crates/engine/src/cmd/stash.rs | 110 ++++++++++++++++++++++++++++++--- docs/architecture.md | 6 +- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index bbef7f3ec..38a6a39fc 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -7,8 +7,16 @@ //! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep //! one name each, `_sc_`; reading accepts that form as well, so those items can move to this //! format later, and a write of the same key replaces it. +//! +//! XML can't carry every character (controls other than tab, line feed and carriage return, and +//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the +//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits +//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode +//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on +//! ordinary text. Legacy `_sc_` items are read as they are. use std::collections::BTreeMap; +use std::fmt::Write as _; use gridcraft_model::{DefinedName, Workbook}; use serde_json::Value as Json; @@ -25,6 +33,11 @@ const FORMULA_UNITS: usize = 8_192; const MAX_KEY: usize = 200; /// The longest item: 4,194,304 characters. pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; +/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character +/// takes seven (the three-byte marker and four digits). +const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7; +/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter. +pub const ESCAPE: char = '\u{FDD0}'; /// The item text stored under `key`, or `None` when there is none (or it is over the size limit). pub fn get(wb: &Workbook, key: &str) -> Option { @@ -49,14 +62,47 @@ pub fn get(wb: &Workbook, key: &str) -> Option { break; } out.push_str(&string_literals(f)); - if out.len() > MAX_ITEM_CHARS.saturating_mul(4) { + if out.len() > MAX_STORED_BYTES { return None; } } + out = unescape(&out); } (out.chars().count() <= MAX_ITEM_CHARS).then_some(out) } +/// Whether `c` is stored escaped: XML can't carry it, or it is the marker. +fn needs_escape(c: char) -> bool { + c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') +} + +/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four +/// hex digits naming a character (a hand-made name) stays as it is. +fn unescape(s: &str) -> String { + if !s.contains(ESCAPE) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find(ESCAPE) { + out.push_str(rest.get(..at).unwrap_or("")); + let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or(""); + let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok()); + match code.and_then(char::from_u32) { + Some(c) => { + out.push(c); + rest = after.get(4..).unwrap_or(""); + } + None => { + out.push(ESCAPE); + rest = after; + } + } + } + out.push_str(rest); + out +} + /// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it. pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { check_key(key)?; @@ -153,7 +199,8 @@ fn check_key(key: &str) -> Result<()> { } /// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula -/// at most 8,192 characters. Concatenating [`string_literals`] of each gives `text` back. +/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]). +/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back. pub fn chunk_formulas(text: &str) -> Vec { let mut formulas = Vec::new(); let (mut formula, mut units) = (String::new(), 0usize); @@ -175,14 +222,22 @@ pub fn chunk_formulas(text: &str) -> Vec { *lit_units = 0; }; for ch in text.chars() { - if lit_units + ch.len_utf16() > LITERAL_UNITS { + // An escaped character is the marker and four digits, kept in one literal. + let escaped = needs_escape(ch); + let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() }; + if lit_units + cost > LITERAL_UNITS { flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); } - lit_units += ch.len_utf16(); - if ch == '"' { - literal.push('"'); + lit_units += cost; + if escaped { + literal.push(ESCAPE); + let _ = write!(literal, "{:04X}", u32::from(ch)); + } else { + if ch == '"' { + literal.push('"'); + } + literal.push(ch); } - literal.push(ch); } if lit_units > 0 { flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); @@ -403,5 +458,46 @@ mod tests { assert!(warnings.is_empty(), "{warnings:?}"); assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str())); assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"]))); + + // Characters XML can't carry, and the escape marker itself, come back too, in text and + // in JSON (which leaves U+FFFE and U+FFFF unescaped). + let mut wb = Workbook::new(); + let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect(); + let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect(); + set(&mut wb, "odd", &text).unwrap(); + excel_limits_hold(&wb); + let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"}); + set_json(&mut wb, "json", &json).unwrap(); + // All escapes: each literal holds whole escapes, within the limits. + let controls = "\u{1}".repeat(9_000); + set(&mut wb, "controls", &controls).unwrap(); + excel_limits_hold(&wb); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "json"), Some(json)); + assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str())); + } + + #[test] + fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() { + // Excel's limits hold however many characters are escaped, and the longest item of + // characters that all need escaping still reads. + for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] { + let text = "\u{2}".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::()), text, "{len}"); + assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}"); + } + let mut wb = Workbook::new(); + let longest = "\u{0}".repeat(MAX_ITEM_CHARS); + set(&mut wb, "longest", &longest).unwrap(); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS)); + // A marker in a hand-made name that is not an escape stays as it is. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\"")); + assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}")); } } diff --git a/docs/architecture.md b/docs/architecture.md index f820998f1..d900be62d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,8 +80,10 @@ Actions made of many commands (macros, a debugger that runs one step per call) u per-session consent for stored `automate.*` scripts (`automate.trustScripts`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and - 8,192-character limits, so it survives an xlsx round trip. The older single-name `_sc_` - items are read as well. + 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls + other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter + U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. The older + single-name `_sc_` items are read as well. ## Agent control From b90710794a622fc25b4d848f02311d10deefbc48 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:55:07 -0400 Subject: [PATCH 22/42] Engine: list stash items in one pass over the names Each `stash::get` looks at every defined name, so listing the items with `keys` and then reading each with `get` took time quadratic in the number of names. A file with 20,000 hidden `_gc_macro__0` names took about ten seconds in a debug build, and the macro list reads its items this way from data that comes from the file. `stash::items(wb, prefix)` now groups the names by item in one pass and returns each item's text by key, and `keys` lists from the same pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys in sorted order, so a file with any number of hidden names can't make a listing unbounded. A test counts the names each listing looks at. --- crates/engine/src/cmd/stash.rs | 136 ++++++++++++++++++++++++++++----- docs/architecture.md | 5 +- 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 38a6a39fc..630e84ab6 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -38,23 +38,87 @@ pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7; /// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter. pub const ESCAPE: char = '\u{FDD0}'; +/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A +/// file can carry any number of hidden names; the rest are not listed. +pub const MAX_ITEMS: usize = 10_000; + +/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its +/// legacy `_sc_` name. +#[derive(Default)] +struct Parts<'a> { + chunks: BTreeMap, + legacy: Option<&'a str>, +} + +impl<'a> Parts<'a> { + fn add(&mut self, chunk: Option, formula: &'a str) { + match chunk { + Some(i) => { + self.chunks.entry(i).or_insert(formula); + } + None => self.legacy = self.legacy.or(Some(formula)), + } + } + + /// Whether the names make an item: a later chunk on its own does not. + fn is_item(&self) -> bool { + self.chunks.contains_key(&0) || self.legacy.is_some() + } +} + +#[cfg(test)] +thread_local! { + /// Names [`group`] has looked at on this thread, so a test can bound the work of a listing. + static NAME_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names +/// that hold them, in one pass over the names. +fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap> { + let prefix = prefix.to_ascii_lowercase(); + let mut items: BTreeMap> = BTreeMap::new(); + for n in wb.names.iter() { + #[cfg(test)] + NAME_VISITS.with(|v| v.set(v.get().saturating_add(1))); + let Some((k, chunk)) = item_of(n) else { continue }; + let k = k.to_ascii_lowercase(); + if k.starts_with(&prefix) { + items.entry(k).or_default().add(chunk, &n.formula); + } + } + items +} /// The item text stored under `key`, or `None` when there is none (or it is over the size limit). pub fn get(wb: &Workbook, key: &str) -> Option { - let mut chunks: BTreeMap = BTreeMap::new(); - let mut legacy = None; + let mut parts = Parts::default(); for n in wb.names.iter() { - match item_of(n) { - Some((k, Some(i))) if k.eq_ignore_ascii_case(key) => { - chunks.entry(i).or_insert(&n.formula); - } - Some((k, None)) if k.eq_ignore_ascii_case(key) => legacy = legacy.or(Some(&n.formula)), - _ => {} + if let Some((k, chunk)) = item_of(n) + && k.eq_ignore_ascii_case(key) + { + parts.add(chunk, &n.formula); } } + assemble(&parts) +} + +/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass +/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit. +pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap { + group(wb, prefix) + .into_iter() + .filter(|(_, parts)| parts.is_item()) + .take(MAX_ITEMS) + .filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))) + .collect() +} + +/// The text of the item held by `parts`, as [`get`] reads it. +fn assemble(parts: &Parts) -> Option { + let chunks = &parts.chunks; let mut out = String::new(); if !chunks.contains_key(&0) { - out = string_literals(legacy?); + out = string_literals(parts.legacy?); } else { // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. for (want, (i, f)) in chunks.iter().enumerate() { @@ -144,18 +208,11 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> { set(wb, key, &v.to_string()) } -/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted. +/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted +/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass +/// where a [`get`] per key would take one each. pub fn keys(wb: &Workbook, prefix: &str) -> Vec { - let mut keys: Vec = wb - .names - .iter() - // A later chunk on its own is no item, as `get` reads it. - .filter_map(|n| item_of(n).filter(|(_, chunk)| chunk.is_none_or(|i| i == 0)).map(|(k, _)| k.to_ascii_lowercase())) - .filter(|k| k.starts_with(&prefix.to_ascii_lowercase())) - .collect(); - keys.sort(); - keys.dedup(); - keys + group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() } /// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. @@ -447,6 +504,45 @@ mod tests { assert_eq!(item_key("Total"), None); } + #[test] + fn listing_many_items_takes_one_pass_and_is_capped() { + // 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each + // key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS. + let mut wb = Workbook::new(); + let total = 20_000; + for i in 0..total { + wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\""))); + } + set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap(); + wb.names.push(name("_sc_macro_legacy", "\"old\"")); + wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\"")); + wb.names.push(name("_gc_other_0", "\"not a macro\"")); + NAME_VISITS.with(|v| v.set(0)); + let all = items(&wb, "MACRO_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names"); + assert_eq!(all.len(), MAX_ITEMS); + let first: Vec<&String> = all.keys().take(2).collect(); + assert_eq!(first, ["macro_00000", "macro_00001"]); + assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999")); + assert!(!all.contains_key("macro_10000"), "past the cap"); + NAME_VISITS.with(|v| v.set(0)); + let listed = keys(&wb, "macro_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len()); + assert_eq!(listed, all.keys().cloned().collect::>(), "keys and items agree"); + + // Under the cap every item is there, read as `get` reads it. + wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1")); + let few = items(&wb, "macro_"); + let want: BTreeMap = + [("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect(); + assert_eq!(few, want); + for (k, text) in &few { + assert_eq!(get(&wb, k).as_ref(), Some(text)); + } + assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]); + assert_eq!(items(&wb, "").len(), 3, "the other item too"); + } + #[test] fn items_round_trip_through_xlsx() { let mut wb = Workbook::new(); diff --git a/docs/architecture.md b/docs/architecture.md index d900be62d..2e80f5606 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,8 +82,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls other than tab, line feed and carriage return, U+FFFE, U+FFFF) are stored as the noncharacter - U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. The older - single-name `_sc_` items are read as well. + U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` + and `stash::keys` list the items under a key prefix in one pass over the names, at most + 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. ## Agent control From 4668fc30ae985bdc028371a84f496ea863276498 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:57:14 -0400 Subject: [PATCH 23/42] Engine: the name commands leave GridCraft's data names alone `formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden chunk of a stored item with a visible name: the item vanished from `stash::get` and `stash::keys`, and its other chunks stayed behind as orphan hidden names. `formulas.deleteName` could remove chunks the same way, and `formulas.createFromSelection` with `replace` could replace one from a cell label. `stash::set` refused a clash with a visible name, but nothing guarded the other direction. Defining, renaming or deleting a name that `stash::item_key` recognises (`_gc__` and legacy `_sc_`) is now refused with an error that names `force: true`, which lets the call go ahead. Create from Selection lists such labels in `skipped` unless `force` is true. The params docs of the three commands say so. --- crates/engine/src/cmd/formulas.rs | 75 +++++++++++++++++++-- crates/engine/tests/it/stored_list_shape.rs | 3 +- docs/architecture.md | 2 + 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..c5f8a8bf8 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -32,18 +32,26 @@ pub fn specs() -> Vec { "Define Name…", ["Formulas", "Defined Names"], None, - "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)", + "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", has_doc, define_name ), - cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name), + cmd!( + "formulas.deleteName", + "Delete Name", + [], + None, + "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", + has_doc, + delete_name + ), cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager), cmd!( "formulas.createFromSelection", "Create from Selection", ["Formulas", "Defined Names"], Some("Cmd+Shift+F3"), - "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}", + "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc__` and `_sc_`, are listed in `skipped` unless force is true)}", has_doc, create_from_selection ), @@ -268,6 +276,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result { if !valid_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } + for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() { + refuse_stash_name(p, n)?; + } let d = s.doc()?; let scope = match str_param(p, "scope") { None | Some("Workbook") => None, @@ -374,8 +385,21 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc } } +/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]): +/// defining, renaming or deleting one would replace or remove part of an item (a stored macro, +/// script or view) and leave the rest behind. `force: true` lets the call go ahead. +fn refuse_stash_name(p: &Json, name: &str) -> Result<()> { + if stash::item_key(name).is_some() && !bool_param(p, "force").unwrap_or(false) { + return Err(EngineError::Other(format!( + "'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway." + ))); + } + Ok(()) +} + fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); + refuse_stash_name(p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -461,7 +485,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // reported. let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); - let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n)); + // A label of the shape GridCraft keeps its own data in would replace part of an item, or + // stand in the way of one (see `refuse_stash_name`). + let force = bool_param(p, "force").unwrap_or(false); + let ours = |n: &str| !force && stash::item_key(n).is_some(); + let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n))); let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); edit(s, |cx| { @@ -742,6 +770,45 @@ mod tests { assert_eq!(r["refersTo"], "=Sheet1!$A$1"); } + #[test] + fn the_name_commands_leave_gridcraft_data_alone() { + // Defining, renaming or deleting a name of a stored item's shape would replace or remove + // one of its chunks and leave the rest behind: refused unless `force` is true. + let mut s = Session::new(); + s.new_workbook(); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + let text = "m".repeat(9_000); + stash::set(wb, "macro_1", &text).unwrap(); + wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true }); + let names = |s: &Session| s.doc().unwrap().wb.names.clone(); + let before = names(&s); + assert!(before.len() >= 3, "the item takes two chunks"); + for params in [ + json!({"name": "_gc_macro_1_0", "refersTo": "=1"}), + json!({"name": "_SC_Watch", "refersTo": "=1"}), + json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}), + json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}), + ] { + let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string(); + assert!(err.contains("force"), "{params}: {err}"); + } + for name in ["_gc_macro_1_1", "_sc_watch"] { + let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string(); + assert!(err.contains("force"), "{name}: {err}"); + } + s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap(); + let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap(); + assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"]))); + let after = names(&s); + assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::>(), before); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str())); + // Ordinary names and forced calls go ahead. + s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap(); + assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1")); + } + #[test] fn arabic_defined_names_count_characters_not_bytes() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/stored_list_shape.rs b/crates/engine/tests/it/stored_list_shape.rs index 0d38cd5f9..fd17a4806 100644 --- a/crates/engine/tests/it/stored_list_shape.rs +++ b/crates/engine/tests/it/stored_list_shape.rs @@ -6,7 +6,8 @@ const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\ fn with_name(name: &str, refers_to: &str) -> Session { let mut s = Session::new(); s.new_workbook(); - s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap(); + // `force`: the name commands refuse GridCraft's own data names otherwise. + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to, "force": true})).unwrap(); s } diff --git a/docs/architecture.md b/docs/architecture.md index 2e80f5606..3fc6cb0b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,8 @@ Actions made of many commands (macros, a debugger that runs one step per call) u U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` and `stash::keys` list the items under a key prefix in one pass over the names, at most 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. + `formulas.defineName` and `formulas.deleteName` refuse names of either shape, and + `formulas.createFromSelection` skips labels of either shape, unless `force: true` is passed. ## Agent control From 37bf09e94a1c45042958c0d0b121519f6cf13156 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:59:45 -0400 Subject: [PATCH 24/42] Engine: queries take no copy of the undo history `execute` copied the document's undo and redo history on every call, so a panic could put it back: up to 100 entries per stack, each with a label and a selection. Queries paid for it too, although no query runs a command or changes the history, and front ends and agents run queries all the time. The copy is now skipped for queries (commands that neither journal nor make undo steps). A test runs every query, with empty and with typical parameters, and checks the history is unchanged, so a query that ever changes it fails there. Commands that can change the history, nested ones included, still take the copy: a nested command that panics is restored at its own level, and the outer command can carry on, so relying on the outermost copy would weaken that restore. --- crates/engine/src/lib.rs | 11 ++++++-- crates/engine/src/tests.rs | 5 ++++ crates/engine/src/tests/seams.rs | 47 ++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 90de44385..5c6e9f565 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -534,8 +534,15 @@ impl Session { let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); // A command that panics leaves the history as it found it: the steps commands nested in it // made or undid, and the redo history they cleared, come back. A length is not enough, - // because a nested `edit.undo` removes entries. - let history_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))); + // because a nested `edit.undo` removes entries. A query runs no commands and leaves the + // history alone (`every_query_leaves_the_history_alone` runs each one), so it needs no + // copy; front ends run queries all the time. + let query = !spec.journal && !spec.undoable; + let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { + #[cfg(test)] + tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1))); + self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone())) + }); // The groups open on the document before the command (usually none), for the same reason. let groups_before: Vec<(u64, String, Arc, Selection)> = match &before { Some((uid, ..)) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4f2fc91bb..1be90d56c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,6 +3,11 @@ use serde_json::json; use crate::Session; +thread_local! { + /// Copies of a document's undo and redo history `execute` has taken on this thread. + pub(crate) static HISTORY_COPIES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn s() -> Session { let mut s = Session::new(); s.new_workbook(); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 86db0622f..cab24426e 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -732,6 +732,53 @@ fn stop_action_rolls_back_a_group_left_open() { assert!(s.in_group(other_uid)); } +fn history_of(s: &Session) -> Vec<(String, *const gridcraft_model::Workbook)> { + let d = s.doc().unwrap(); + d.undo.iter().chain(d.redo.iter()).map(|e| (e.label.clone(), std::sync::Arc::as_ptr(&e.wb))).collect() +} + +#[test] +fn queries_take_no_copy_of_the_history() { + // `execute` copies the history so a panic can put it back; a query leaves it alone, so the + // copy is skipped for the queries front ends run all the time. + let mut s = s(); + for i in 0..50 { + set(&mut s, "A1", &i.to_string()); + } + s.execute("edit.undo", json!({})).unwrap(); + super::HISTORY_COPIES.with(|n| n.set(0)); + for _ in 0..100 { + s.execute("history.list", json!({})).unwrap(); + s.execute("cell.get", json!({"cell": "A1"})).unwrap(); + s.execute("document.inspect", json!({})).unwrap(); + } + assert_eq!(super::HISTORY_COPIES.with(|n| n.get()), 0); + set(&mut s, "A2", "x"); + assert_eq!(super::HISTORY_COPIES.with(|n| n.get()), 1, "a command that can change it still copies it"); +} + +#[test] +fn every_query_leaves_the_history_alone() { + // What lets a query skip the copy: none of them changes the undo or redo history. + let mut s = Session::new(); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + set(&mut s, "A1", "1"); + set(&mut s, "A1", "2"); + s.execute("edit.undo", json!({})).unwrap(); + let before = history_of(&s); + assert_eq!(before.len(), 2); + let mut queries = 0; + for spec in crate::command_specs().iter().filter(|c| !c.journal && !c.undoable) { + queries += 1; + for params in [json!({}), json!({"range": "A1:B3", "cell": "A1", "sheet": 0}), json!([1, 2])] { + let _ = s.execute(spec.id, params.clone()); + s.set_active(0); + assert_eq!(history_of(&s), before, "{} {params}", spec.id); + } + } + assert!(queries > 40, "{queries}"); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { From 9bbba198c6263adc1e448909bf8fa07e445c5ed5 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:01:45 -0400 Subject: [PATCH 25/42] Engine: a document's maker says where its content came from `Trust` defaulted to `Local`, and `DocState::new` set it, so a document was trusted unless its maker remembered to mark it. Every current path that reads a file goes through `file.open`, which marks it `FromFile`, but a later way of building a document from bytes (recovery, templates, a new import) would have come out trusted by omission. `DocState::new` now takes the `Trust`, and `Trust` has no default. New workbooks and samples pass `Local`, `file.open` passes `FromFile`, a new window passes the trust of its workbook, and the tests that build documents from bytes pass `FromFile`. --- crates/engine/src/cmd/extra.rs | 3 +-- crates/engine/src/cmd/file.rs | 7 +++---- crates/engine/src/lib.rs | 16 ++++++++++------ crates/engine/src/sample.rs | 2 +- crates/engine/src/tests/seams.rs | 16 ++++++++++++++++ crates/engine/tests/it/dynamicmetadata.rs | 2 +- crates/engine/tests/it/long_operator_chains.rs | 2 +- crates/ui-egui/tests/it/interaction.rs | 2 +- crates/ui-egui/tests/it/scroll_position.rs | 2 +- docs/architecture.md | 15 ++++++++------- 10 files changed, 43 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 6197dd5e2..009d77f2a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -809,10 +809,9 @@ fn split(s: &mut Session, _: &Json) -> Result { fn new_window(s: &mut Session, _: &Json) -> Result { let d = s.doc()?.clone(); - let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title())); + let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust); copy.selection = d.selection.clone(); copy.scripts_trusted = d.scripts_trusted; - copy.trust = d.trust; let i = s.add_document(copy); Ok(json!({"index": i})) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7aee50921..b3e7309e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result { Some(name) => { let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?; let title = crate::sample::title(name); - s.add_document(DocState::new(wb, None, title)) + s.add_document(DocState::new(wb, None, title, crate::Trust::Local)) } None => s.new_workbook(), }; @@ -83,12 +83,11 @@ fn open(s: &mut Session, p: &Json) -> Result { { s.close_document(0); } - let mut doc = DocState::new(wb, path, title); + // Someone else may have made the file: its active content asks before it runs. + let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile); if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { doc.content_kind = Some(crate::io::FileKind::Xlsx); } - // Someone else may have made the file: its active content asks before it runs. - doc.trust = crate::Trust::FromFile; let i = s.add_document(doc); // The decrypted-workbook notice is for the info bar, not a message box. let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c6e9f565..1fd2f267b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -134,12 +134,13 @@ pub struct Clipboard { pub trust: Trust, } -/// Whether a document's active content (macros) may run without asking the person first. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +/// Whether a document's active content (macros) may run without asking the person first. There +/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new +/// way of building one from bytes can't come out trusted by omission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum Trust { /// Made in this session: a new workbook or a sample. - #[default] Local, /// Opened from a file, or holding content pasted from one: active content asks before it /// runs. @@ -259,7 +260,10 @@ fn trim_to_budget(history: &mut Vec, byte_budget: usize) { } impl DocState { - pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { + /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything + /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook + /// made in this session. + pub fn new(mut wb: Workbook, path: Option, title: String, trust: Trust) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); let scripts_trusted = !cmd::extra::has_scripts(&wb); @@ -280,7 +284,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, scripts_trusted, - trust: Trust::Local, + trust, } } pub fn is_dirty(&self) -> bool { @@ -480,7 +484,7 @@ impl Session { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); } - self.add_document(DocState::new(wb, None, format!("Book{n}"))) + self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local)) } /// Journals a command, then trims the oldest entries past either cap. A recording in progress diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 129fe2171..90029bac3 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option { _ => return None, }; } - s.add_document(DocState::new(wb, None, title(name))); + s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local)); let ok = match name { "budget" => budget(&mut s), "sales" => sales(&mut s), diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index cab24426e..b445377b7 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -961,6 +961,22 @@ fn documents_know_where_they_came_from() { assert_eq!(trust(&s), Trust::FromFile); } +#[test] +fn whoever_makes_a_document_says_where_it_came_from() { + // No default trust: a document built from bytes outside `file.open` (a recovery copy, a + // template) is untrusted because its maker says so, not trusted because it forgot to. + let mut s = Session::new(); + let bytes = gridcraft_xlsx::write_xlsx(&gridcraft_model::Workbook::new()).unwrap(); + let (wb, _) = crate::io::open_bytes("recovered.xlsx", &bytes).unwrap(); + s.add_document(crate::DocState::new(wb, None, "Recovered".into(), Trust::FromFile)); + assert_eq!(trust(&s), Trust::FromFile); + assert_eq!(s.execute("document.inspect", json!({})).unwrap()["trust"], "fromFile"); + s.add_document(crate::DocState::new(gridcraft_model::Workbook::new(), None, "Made here".into(), Trust::Local)); + assert_eq!(trust(&s), Trust::Local); + s.new_workbook(); + assert_eq!(trust(&s), Trust::Local); +} + #[test] fn pasting_from_a_file_carries_its_trust() { let mut s = s(); // document 0: Local diff --git a/crates/engine/tests/it/dynamicmetadata.rs b/crates/engine/tests/it/dynamicmetadata.rs index e1edfc958..f88805476 100644 --- a/crates/engine/tests/it/dynamicmetadata.rs +++ b/crates/engine/tests/it/dynamicmetadata.rs @@ -26,7 +26,7 @@ fn opened() -> Session { let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap(); let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap(); let mut s = Session::new(); - s.add_document(DocState::new(back, None, "dynamic".into())); + s.add_document(DocState::new(back, None, "dynamic".into(), gridcraft_engine::Trust::FromFile)); s } diff --git a/crates/engine/tests/it/long_operator_chains.rs b/crates/engine/tests/it/long_operator_chains.rs index 97c95ce38..e5e5af4b7 100644 --- a/crates/engine/tests/it/long_operator_chains.rs +++ b/crates/engine/tests/it/long_operator_chains.rs @@ -78,7 +78,7 @@ fn longest_flat_chains_survive_editing_saving_and_reopening() { let bytes = io::save_bytes(wb, "chains.xlsx").expect("save"); let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open"); let mut session = Session::new(); - session.add_document(DocState::new(reopened, None, "chains".into())); + session.add_document(DocState::new(reopened, None, "chains".into(), gridcraft_engine::Trust::FromFile)); session.execute("formulas.calculateNow", json!({})).expect("recalc"); assert_eq!(value(&session, "C2"), Value::Number(4096.0)); assert_eq!(value(&session, "C3"), Value::Number(-4094.0)); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 46e981d0a..08317aad3 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -84,7 +84,7 @@ fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() { // Column A frozen: a page is the scrolling part only. sh.freeze = Some((0, 1)); let mut s = Session::new(); - s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into())); + s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local)); let mut h = harness(s); let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar"); assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}"); diff --git a/crates/ui-egui/tests/it/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs index 7945eb344..bcf62b1ac 100644 --- a/crates/ui-egui/tests/it/scroll_position.rs +++ b/crates/ui-egui/tests/it/scroll_position.rs @@ -18,7 +18,7 @@ fn harness(freeze: Option<(u32, u32)>) -> Harness { sh.view_active = CellRef::parse("A31").unwrap(); sh.freeze = freeze; let mut session = Session::new(); - session.add_document(DocState::new(wb, None, "Book1".into())); + session.add_document(DocState::new(wb, None, "Book1".into(), gridcraft_engine::Trust::Local)); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); diff --git a/docs/architecture.md b/docs/architecture.md index 3fc6cb0b1..2a5bed44a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,13 +71,14 @@ Actions made of many commands (macros, a debugger that runs one step per call) u dialog (the dialog's confirming call is the step), and the buffer is independent of the journal (Automate › Record Actions still records through the journal). - **Document trust.** `DocState::trust` is `Local` for workbooks made in the session and - `FromFile` for workbooks opened from a file, and for workbooks that received a paste from one - that writes formulas or validation: Paste, Paste Special › All, All Except Borders, Formulas, - Formulas and Number Formats or Validation, and any linked paste other than column widths or - notes. Values, formats, column widths and notes carry nothing active. Active content in a - `FromFile` workbook asks before it runs. This - is provenance, which no decision changes; `DocState::scripts_trusted` is the separate - per-session consent for stored `automate.*` scripts (`automate.trustScripts`). + `FromFile` for workbooks opened from a file. `Trust` has no default: `DocState::new` takes it, + so every way of making a document says where its content came from. It is also `FromFile` for + workbooks that received a paste from one that writes formulas or validation: Paste, Paste + Special › All, All Except Borders, Formulas, Formulas and Number Formats or Validation, and any + linked paste other than column widths or notes. Values, formats, column widths and notes carry + nothing active. Active content in a `FromFile` workbook asks before it runs. This is provenance, + which no decision changes; `DocState::scripts_trusted` is the separate per-session consent for + stored `automate.*` scripts (`automate.trustScripts`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden workbook-scope names `_gc__0`, `_gc__1`, … of string literals within Excel's 255- and 8,192-character limits, so it survives an xlsx round trip. Characters XML can't carry (controls From b44a5139ab66bb8b92d0d790c629b29e456035a1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:19:07 -0400 Subject: [PATCH 26/42] Engine: the name commands refuse only names that hold GridCraft's data The guard refused any name of the stash's shape, so a visible name a person had defined, such as _gc_rate_0, could not be edited or deleted from Name Manager, which cannot pass force. It now refuses a name only when the workbook holds a hidden workbook-scope item under it; a visible name of the shape is the person's own, as the stash itself treats it. The test covers such a name. The query comment says what the copy skip relies on: a query runs nothing that changes the history. --- crates/engine/src/cmd/formulas.rs | 36 ++++++++++++++++++++++--------- crates/engine/src/lib.rs | 4 ++-- docs/architecture.md | 5 +++-- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index c5f8a8bf8..b3f79e81a 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula}; +use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Define Name…", ["Formulas", "Defined Names"], None, - "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", + "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", has_doc, define_name ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Delete Name", [], None, - "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (names GridCraft keeps its own data in, `_gc__` and `_sc_`, are refused unless force is true)", + "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", has_doc, delete_name ), @@ -277,7 +277,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("The name that you entered is not valid.".into())); } for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() { - refuse_stash_name(p, n)?; + refuse_stash_name(&s.doc()?.wb, p, n)?; } let d = s.doc()?; let scope = match str_param(p, "scope") { @@ -388,8 +388,15 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc /// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]): /// defining, renaming or deleting one would replace or remove part of an item (a stored macro, /// script or view) and leave the rest behind. `force: true` lets the call go ahead. -fn refuse_stash_name(p: &Json, name: &str) -> Result<()> { - if stash::item_key(name).is_some() && !bool_param(p, "force").unwrap_or(false) { +/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope +/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and +/// the name commands may change it. +fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool { + wb.names.iter().any(|n| n.hidden && n.scope.is_none() && n.name.eq_ignore_ascii_case(name) && stash::item_key(&n.name).is_some()) +} + +fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> { + if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) { return Err(EngineError::Other(format!( "'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway." ))); @@ -399,7 +406,7 @@ fn refuse_stash_name(p: &Json, name: &str) -> Result<()> { fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); - refuse_stash_name(p, &name)?; + refuse_stash_name(&s.doc()?.wb, p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -485,10 +492,10 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // reported. let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); - // A label of the shape GridCraft keeps its own data in would replace part of an item, or - // stand in the way of one (see `refuse_stash_name`). + // A label that names one of GridCraft's own hidden data names would replace part of an item + // (see `refuse_stash_name`). let force = bool_param(p, "force").unwrap_or(false); - let ours = |n: &str| !force && stash::item_key(n).is_some(); + let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n); let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n))); let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); @@ -802,6 +809,15 @@ mod tests { let after = names(&s); assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::>(), before); assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str())); + // A visible name of the shape is the person's own (the stash never reads it): the name + // commands change and remove it without `force`, as Name Manager cannot pass one. + let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone()); + s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own); + s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap(); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched"); // Ordinary names and forced calls go ahead. s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap(); s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1fd2f267b..b4ab60f3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -538,8 +538,8 @@ impl Session { let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); // A command that panics leaves the history as it found it: the steps commands nested in it // made or undid, and the redo history they cleared, come back. A length is not enough, - // because a nested `edit.undo` removes entries. A query runs no commands and leaves the - // history alone (`every_query_leaves_the_history_alone` runs each one), so it needs no + // because a nested `edit.undo` removes entries. A query runs nothing that changes the + // history (`every_query_leaves_the_history_alone` runs each one), so it needs no // copy; front ends run queries all the time. let query = !spec.journal && !spec.undoable; let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { diff --git a/docs/architecture.md b/docs/architecture.md index 2a5bed44a..bfbf2b8c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,8 +86,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u U+FDD0 and four hex digits, as is U+FDD0 itself, and read back as they were. `stash::items` and `stash::keys` list the items under a key prefix in one pass over the names, at most 10,000 of them (`stash::MAX_ITEMS`). The older single-name `_sc_` items are read as well. - `formulas.defineName` and `formulas.deleteName` refuse names of either shape, and - `formulas.createFromSelection` skips labels of either shape, unless `force: true` is passed. + `formulas.defineName` and `formulas.deleteName` refuse a name the workbook holds such an item + under (a hidden workbook-scope name of either shape; a visible one is the person's own), and + `formulas.createFromSelection` skips such labels, unless `force: true` is passed. ## Agent control From b09a5502e3a7a092561d13aaa262881b7f3d05c2 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:30:15 -0400 Subject: [PATCH 27/42] UI: the toolbar's Undo and Redo follow the commands' own availability The ribbon enabled them from the history lengths alone, so while an undo group was open on the document (an action spanning several calls) a click ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and cmd::can_redo, the undo list is empty while a group is open, and the two buttons carry a name and their enabled state for assistive technology. A UI test opens a group, clicks both and checks the history and their state. --- crates/ui-egui/src/ribbon.rs | 22 +++++-- crates/ui-egui/tests/it/main.rs | 1 + .../ui-egui/tests/it/undo_while_grouping.rs | 57 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/it/undo_while_grouping.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 45856565f..ab9cc311d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -74,15 +74,23 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { { app.run_or_alert("file.save", json!({})); } - let can_undo = app.session.active().is_some_and(|d| !d.undo.is_empty()); - let can_redo = app.session.active().is_some_and(|d| !d.redo.is_empty()); - if icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)).clicked() && can_undo { + // The commands' own availability: there must be a step, and no undo group may be open on + // the document (an action that spans several calls is still running). + let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok(); + let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); + // Named and marked enabled or not for assistive technology (and the UI tests). + let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); + undo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_undo, "Undo")); + if undo.clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); egui::Popup::menu(&undo_list).show(|ui| { - let labels: Vec = - app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default(); + let labels: Vec = if can_undo { + app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default() + } else { + Vec::new() + }; if labels.is_empty() { ui.label("Can't Undo"); } @@ -92,7 +100,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } } }); - if icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)).clicked() && can_redo { + let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); + redo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_redo, "Redo")); + if redo.clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index fc00fb520..b9725aed0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -85,6 +85,7 @@ mod touch_pan; mod trace_navigation; mod translated_form_fields; mod ui_clock; +mod undo_while_grouping; mod validation_alert; mod validation_input_message; mod wheel_zoom; diff --git a/crates/ui-egui/tests/it/undo_while_grouping.rs b/crates/ui-egui/tests/it/undo_while_grouping.rs new file mode 100644 index 000000000..fdc98ebf9 --- /dev/null +++ b/crates/ui-egui/tests/it/undo_while_grouping.rs @@ -0,0 +1,57 @@ +//! Undo and Redo on the Quick Access Toolbar follow the commands' own availability: while an undo +//! group is open on the document (an action that spans several calls is still running), both +//! buttons are off, and a click neither runs the command nor raises its refusal. + +use egui_kittest::kittest::{NodeT as _, Queryable as _}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn history(h: &Harness) -> (usize, usize) { + let d = h.state().session.active().unwrap(); + (d.undo.len(), d.redo.len()) +} + +#[test] +fn undo_and_redo_wait_while_an_undo_group_is_open() { + let mut h = harness(); + assert_eq!(history(&h), (1, 1)); + let token = h.state_mut().session.group_begin("Long action").unwrap(); + h.run_steps(2); + for button in ["Undo", "Redo"] { + assert!(h.get_by_label(button).accesskit_node().is_disabled(), "{button} is off while the group is open"); + h.get_by_label(button).click(); + h.run_steps(2); + assert!(h.state().message.is_none(), "{button}: no refusal is raised while the group is open"); + assert_eq!(history(&h), (1, 1), "{button}: the history is as it was"); + } + h.state_mut().session.group_end(token).unwrap(); + h.run_steps(2); + assert!(!h.get_by_label("Undo").accesskit_node().is_disabled() && !h.get_by_label("Redo").accesskit_node().is_disabled()); + // With the group ended, the buttons work again. + h.get_by_label("Undo").click(); + h.run_steps(2); + assert_eq!(history(&h), (0, 2)); + h.get_by_label("Redo").click(); + h.run_steps(2); + assert_eq!(history(&h), (1, 1)); +} From 4a294aefa9fb496ccd55ee6f93bd51f915044e88 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:41:03 -0400 Subject: [PATCH 28/42] Engine: the name commands use the stash's own rule for what holds its data The guard asked for a hidden name, but the stash reads a legacy _sc_ name whether it is hidden or not, so a visible one could be removed or replaced without force. stash::holds_item is the one rule (item_of), shared by the guard; the test covers a visible legacy name. --- crates/engine/src/cmd/formulas.rs | 8 ++++++-- crates/engine/src/cmd/stash.rs | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index b3f79e81a..7718279b2 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -392,7 +392,8 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc /// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and /// the name commands may change it. fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool { - wb.names.iter().any(|n| n.hidden && n.scope.is_none() && n.name.eq_ignore_ascii_case(name) && stash::item_key(&n.name).is_some()) + // The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not. + stash::holds_item(wb, name) } fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> { @@ -787,6 +788,8 @@ mod tests { let text = "m".repeat(9_000); stash::set(wb, "macro_1", &text).unwrap(); wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true }); + // A legacy item is read whether or not it is hidden, so a visible one is data too. + wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false }); let names = |s: &Session| s.doc().unwrap().wb.names.clone(); let before = names(&s); assert!(before.len() >= 3, "the item takes two chunks"); @@ -795,11 +798,12 @@ mod tests { json!({"name": "_SC_Watch", "refersTo": "=1"}), json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}), json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}), + json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}), ] { let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string(); assert!(err.contains("force"), "{params}: {err}"); } - for name in ["_gc_macro_1_1", "_sc_watch"] { + for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] { let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string(); assert!(err.contains("force"), "{name}: {err}"); } diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 630e84ab6..c0e8302ca 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -215,6 +215,13 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec { group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() } +/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope +/// `_gc__` name, or a workbook-scope legacy `_sc_` name, which is read whether or not +/// it is hidden. A visible `_gc_` name is the person's own. +pub fn holds_item(wb: &Workbook, name: &str) -> bool { + wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some()) +} + /// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. pub fn item_key(name: &str) -> Option { parse_name(name).map(|(k, _)| k.to_ascii_lowercase()) From 3b25aa0d16071b460cc7c006d96b57441b958f22 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:41:03 -0400 Subject: [PATCH 29/42] Docs: the panic guard's note says what happens to a group the command ended It keeps a step from the group's start to the restored workbook, so Undo still returns to the state before the group; the note said the history came back exactly. --- docs/architecture.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index bfbf2b8c1..4794a6ec9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,9 @@ including tests, 2026-10-10. enabled, run, journal, undoable }`; ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). `Session::execute` catches escaped panics and restores the workbook, its selection and its - undo and redo history as they were before the command (never-crash guard). + undo and redo history as they were before the command (never-crash guard); a group that was + open before the command and that the command ended or rolled back stays ended and keeps a + step from its start to the restored workbook, so Undo still returns to the state before it. 2. **Edits** change the model through copy-on-write bands; undo keeps the previous snapshot. Undo retains at most 100 steps and trims older steps when its estimated retained cell storage exceeds 128 MiB. The newest step remains undoable even if that step alone exceeds the budget. From 6ead1fb51936f7d368c09e8ce68806f51589b6c3 Mon Sep 17 00:00:00 2001 From: Pranav <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:06:44 -0400 Subject: [PATCH 30/42] Engine: building blocks for multi-command actions (undo groups, recording buffer, document trust, chunked hidden names) (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Engine: undo groups make several commands one undo step Session::grouped(label, f) runs f as one undo step on the active document. On success the group leaves exactly one undo entry holding the workbook from before the group; on error it restores the workbook, selection, undo and redo stacks as they were and returns the error. group_begin, group_end and group_rollback do the same across separate calls, for an action that runs one step per call. While a group is open on a document, undoable commands on it add no undo steps of their own, so a long group neither grows the history nor pushes older entries past the undo limit, and Undo and Redo are disabled there. Groups nest: the outermost one makes the step, and a failing inner group rolls back only its own changes. Closing a document drops its groups, and a command that panics drops the groups it opened. history.list reports whether a group is open. Insert Copied Cells folds its nested steps into one undo step by counting revisions; inside a group the nested steps push no entries, so it now leaves the history alone there instead of removing entries from before the group. * Engine: a recording buffer of top-level commands, apart from the journal A macro recorder needs more than a journal index: the journal is trimmed past 10,000 entries, it also holds the commands that other commands run (so playback would run them twice), and it keeps no selection, which relative references need. Session now tracks how deeply execute calls are nested. While recording, a buffer (record.rs) keeps each successful, journaled top-level call with the document uid, the active sheet (index and name) and the selection it ran in, then its id and parameters without secrets, as the journal stores them. Queries, failed commands and the command that starts recording are not kept. The buffer holds up to 100,000 steps and counts any it drops. Session::start_recording, stop_recording, is_recording and depth are the interface. Automate › Record Actions keeps recording through the journal as before. * Engine: document trust for workbooks opened from files Active content such as macros must not run only because a workbook was opened. DocState gets a trust state: Local for workbooks made in the session (new workbooks and samples) and FromFile for anything opened with file.open. Active content in a FromFile workbook asks before it runs. A second window on a workbook shares its trust. The clipboard carries the trust of its source document. Pasting formulas, formats (conditional formats hold formulas) or validation from a FromFile workbook into another workbook makes that workbook FromFile too; pasting values, column widths or notes carries nothing active and changes nothing. Sheet moves and copies stay inside one workbook, so its trust is unchanged. document.inspect reports the trust. * Engine: workbook data in chunked hidden names (cmd/stash.rs) GridCraft keeps workbook data in hidden defined names so it travels inside the xlsx file. The existing items write one name holding one string literal, which past Excel's 255-character literal and 8,192-character formula limits gives a file Excel does not accept. cmd/stash.rs stores an item `key` as hidden workbook-scope names _gc__0, _gc__1, ..., each a formula of string literals of at most 255 characters (UTF-16 units, as Excel counts them) joined with &, at most 8,192 characters per name. Reading concatenates the chunks in order and stops at a gap; it also accepts the older single _sc_ name. Names are matched case-insensitively and only at workbook scope. Keys are limited to letters, digits, _ and ., items to 4,194,304 characters, and junk around or inside the literals, duplicate or out-of-order chunks, non-JSON and over-deep JSON all read safely. keys(prefix) lists the stored items that start with a prefix. Custom views, scripts, the watch list and scenarios keep their current storage; nothing reads or writes the new names yet. docs/architecture.md describes the engine building blocks added in this series: undo groups, the recording buffer, document trust and this storage. * Engine: a linked paste of values from a file carries the file's trust A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes. * Engine: a stash item with only later chunks reads as missing and falls back to the legacy name With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists. * Engine: a command that runs its own undo groups makes one undo step for itself execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command. * Engine: a full undo history keeps one step per command that runs its own undo groups When the history held 100 steps, group_end pushed its step and dropped the oldest one at once, so execute's collapse compared against a length that no longer matched. A command that was one group kept the group's step and added its own, losing two old steps. After test.editThenGroup, the second Undo brought back the intermediate state after the outside edit. The history limit now applies after the outermost command has collapsed its steps, and when a group ends outside any command. A document with an open group is trimmed when the group ends. * Engine: pasting only formats from a file leaves the destination's trust alone Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats, formulas or validation, so it carries nothing that runs. It still marked the destination FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook made that workbook ask before running its own active content. Formats pastes now count as passive, like values, column widths and notes. The comment and docs/architecture.md no longer say that pasted formats carry trust. * Engine: a command keeps its group's name only for a group it ended itself The record of the last group step (document, history length, epoch) outlived the Undo of that step. A later command that opened and closed its own groups at the same history length, after rolling one back, passed the epoch check (the record's epoch merely differed from the one the command started with) and took the name of the nested edit it had made instead of its own. The step must now come from a group ended during the command (an epoch past the command's starting one), and a rollback drops a record of a step it truncated away. * Engine: a group rolled back inside a command keeps the redo history A command that changed the workbook cleared the redo history even when an open group owned its step. When the group was then rolled back inside the same command (the debugger's Stop with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared it again. Now only a command that makes a step of its own clears the redo history; a group clears it when it ends as a step, and a rollback restores it. * Engine: a visible name of the item shape is not workbook data The item store read, listed and removed every workbook-scope name of the `_gc__` shape, visible or not, so saving an item removed a person's own visible name that happened to look like one. Items are the hidden names the store writes; a legacy `_sc_` name still reads. A visible `_gc_` name is left alone by every operation, and writing an item that would need that name is refused before anything changes: a hidden name beside it with the same name is the one an xlsx file drops, so the item would be lost on save. * Engine: a command keeps its group's name only for a step that is exactly the group's The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone. The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record. * Engine: the recording buffer is bounded in bytes and keeps the start of a recording The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps. * Docs: the architecture revision row carries the date the change lands; document trust beside script trust The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178). * Engine: writing a stash item checks for a clashing visible name in one pass over the names The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged. * Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at. A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits. The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it. * Engine: the undo steps made inside a command are bounded while it runs The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once. Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop. * Engine: a grouped action that closes its document succeeds Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error. * Docs: undo groups at a command's boundaries, and the bound on steps made inside a command * Engine: a linked paste carries the file's trust whatever it pastes Paste Link writes a formula into every cell whatever `what` says, so a linked paste of formats from a file now makes the destination FromFile as a paste of formulas does. Column widths and notes write no cells, linked or not, and still carry nothing. Regression test for the three kinds. * Engine: a stash chunk number with leading zeros names someone else's name `_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first chunk and a visible one stopped `_gc_watch_0` from being written. Only the chunk number as `set` writes it counts now. Regression test. * Engine: a command that panics leaves the undo history as it found it The panic guard restored the workbook and selection but not the history, so a nested edit made before the panic left a no-op undo step and the redo history it had cleared stayed lost. The guard now truncates the steps made during the command and puts the redo history back. Regression test, and the architecture note says what the guard restores. * Engine: a headless call that fails for want of a file is not recorded file.open with no file succeeds by queuing a dialog, which execute_headless turns into an error for callers with no UI; the recording kept the step. Recorder::truncate forgets it and keeps the byte count right. Regression test. * Engine: a later stash chunk on its own is not a listed item stash::keys reported a key that get read as missing, because only a later chunk of it existed. keys now counts chunk 0 and legacy names only, as get does. Regression assertions in the hostile-names test. * Tests: a linked paste of column widths or notes copies its metadata and no cell The source now carries a note and a column width, and the test checks that the destination received them while its trust stays Local and no cell is written. * Engine: the panic guard restores entries a nested Undo removed Truncating the undo history to its length at the command's start cannot bring back an entry that a nested edit.undo popped. The guard now keeps the undo and redo history as they were and puts both back. The regression test gains the undo-then-panic case. * Engine: a group a panicking command ended keeps its step A group open before a command and ended or rolled back inside it lost its undo step when the command then panicked: the restored history held no entry for the group's changes, so Undo skipped the state before the group. The panic path now pushes the outermost such group's step from its start to the restored workbook. The group stays ended, because its token went with the command. Regression test for a group ended and one rolled back. * Docs: list every paste that carries a file's trust The architecture note named formulas and validation only; it now lists the Paste Special kinds and linked pastes that write formulas, and the kinds that carry nothing. * Engine: a grouped action that panics is rolled back before the panic goes on Session::grouped ran its action without a panic guard, so a caller outside a command that caught the panic found the group still open: Undo and Redo disabled, the partial edits kept, and the next grouped action nested in the abandoned group. The group is now rolled back and the panic resumed. Regression test. * Engine: the recorder counts a dropped step without serializing it Once a step has been dropped, or the step count is full, push serialized each later step only to count it; a file.open step holds a whole file. The checks that need no size come first. * Engine: a failed command rolls back the undo groups it opened A command that opened an undo group and then returned an error (a `?` between `group_begin` and `group_end`) left the group open for good: Undo and Redo refused with "finish or stop the running action first", later edits made no undo steps, and nothing could stop the group. `execute` now rolls back every group a failed command opened, as `group_rollback` does, restoring the workbook, selection, undo and redo history to where each group began. Only a command that succeeds keeps a group it opened, which is what a debugger's start needs. `Session::abort_groups(doc)` and the menu-less command `automate.stopAction` roll back the groups left open on a document, for a debugger the person abandons or a caller that dropped its token. The command is not undoable and works while a group is open, and the Undo refusal now names it. * Engine: the recorder skips a call that only opened a dialog A menu-style call with no parameters, such as `file.open`, `data.fromTextCsv` or `home.formatCells` with `{}`, only queues its dialog and succeeds. The recorder kept it as a step with empty parameters, and then kept the dialog's confirming call as well, so playing the recording back ran the command twice, or failed on the empty step when played back headless. Only `execute_headless` took the step back, and only for the open and save-as dialogs. `execute` now compares the queued UI requests before and after a top-level command and does not record one that queued a dialog. The special case in `execute_headless` goes (it keeps turning a missing file into an error), and with it `Recorder::truncate`, which had no other caller. * Engine: stash items keep characters XML can't carry The xlsx writer drops characters XML 1.0 can't carry: controls other than tab, line feed and carriage return, and U+FFFE and U+FFFF. A stash item holding one lost it on save without a warning, and so did a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a stored macro parameter changed silently. `chunk_formulas` now writes each such character, and the marker itself, as the noncharacter U+FDD0 followed by four hex digits, and `get` turns them back for `_gc_` items. A marker that isn't followed by four hex digits naming a character is read as it is. An escape counts as five units against the 255-unit literal limit and is never split across literals, and the guard on the stored size allows the longest item even when every character is escaped. Legacy `_sc_` items are read unchanged. * Engine: list stash items in one pass over the names Each `stash::get` looks at every defined name, so listing the items with `keys` and then reading each with `get` took time quadratic in the number of names. A file with 20,000 hidden `_gc_macro__0` names took about ten seconds in a debug build, and the macro list reads its items this way from data that comes from the file. `stash::items(wb, prefix)` now groups the names by item in one pass and returns each item's text by key, and `keys` lists from the same pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys in sorted order, so a file with any number of hidden names can't make a listing unbounded. A test counts the names each listing looks at. * Engine: the name commands leave GridCraft's data names alone `formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden chunk of a stored item with a visible name: the item vanished from `stash::get` and `stash::keys`, and its other chunks stayed behind as orphan hidden names. `formulas.deleteName` could remove chunks the same way, and `formulas.createFromSelection` with `replace` could replace one from a cell label. `stash::set` refused a clash with a visible name, but nothing guarded the other direction. Defining, renaming or deleting a name that `stash::item_key` recognises (`_gc__` and legacy `_sc_`) is now refused with an error that names `force: true`, which lets the call go ahead. Create from Selection lists such labels in `skipped` unless `force` is true. The params docs of the three commands say so. * Engine: queries take no copy of the undo history `execute` copied the document's undo and redo history on every call, so a panic could put it back: up to 100 entries per stack, each with a label and a selection. Queries paid for it too, although no query runs a command or changes the history, and front ends and agents run queries all the time. The copy is now skipped for queries (commands that neither journal nor make undo steps). A test runs every query, with empty and with typical parameters, and checks the history is unchanged, so a query that ever changes it fails there. Commands that can change the history, nested ones included, still take the copy: a nested command that panics is restored at its own level, and the outer command can carry on, so relying on the outermost copy would weaken that restore. * Engine: a document's maker says where its content came from `Trust` defaulted to `Local`, and `DocState::new` set it, so a document was trusted unless its maker remembered to mark it. Every current path that reads a file goes through `file.open`, which marks it `FromFile`, but a later way of building a document from bytes (recovery, templates, a new import) would have come out trusted by omission. `DocState::new` now takes the `Trust`, and `Trust` has no default. New workbooks and samples pass `Local`, `file.open` passes `FromFile`, a new window passes the trust of its workbook, and the tests that build documents from bytes pass `FromFile`. * Engine: the name commands refuse only names that hold GridCraft's data The guard refused any name of the stash's shape, so a visible name a person had defined, such as _gc_rate_0, could not be edited or deleted from Name Manager, which cannot pass force. It now refuses a name only when the workbook holds a hidden workbook-scope item under it; a visible name of the shape is the person's own, as the stash itself treats it. The test covers such a name. The query comment says what the copy skip relies on: a query runs nothing that changes the history. * UI: the toolbar's Undo and Redo follow the commands' own availability The ribbon enabled them from the history lengths alone, so while an undo group was open on the document (an action spanning several calls) a click ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and cmd::can_redo, the undo list is empty while a group is open, and the two buttons carry a name and their enabled state for assistive technology. A UI test opens a group, clicks both and checks the history and their state. * Engine: the name commands use the stash's own rule for what holds its data The guard asked for a hidden name, but the stash reads a legacy _sc_ name whether it is hidden or not, so a visible one could be removed or replaced without force. stash::holds_item is the one rule (item_of), shared by the guard; the test covers a visible legacy name. * Docs: the panic guard's note says what happens to a group the command ended It keeps a step from the group's start to the restored workbook, so Undo still returns to the state before the group; the note said the history came back exactly. --- crates/engine/src/cmd/edit.rs | 12 +- crates/engine/src/cmd/extra.rs | 18 +- crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/cmd/formulas.rs | 97 +- crates/engine/src/cmd/inspect.rs | 13 +- crates/engine/src/cmd/mod.rs | 25 + crates/engine/src/cmd/sheet.rs | 8 +- crates/engine/src/cmd/stash.rs | 606 ++++++++++ crates/engine/src/lib.rs | 448 ++++++- crates/engine/src/record.rs | 66 ++ crates/engine/src/sample.rs | 2 +- crates/engine/src/tests.rs | 6 + crates/engine/src/tests/seams.rs | 1052 +++++++++++++++++ crates/engine/tests/it/dynamicmetadata.rs | 2 +- .../engine/tests/it/long_operator_chains.rs | 2 +- crates/engine/tests/it/stored_list_shape.rs | 3 +- crates/ui-egui/src/ribbon.rs | 22 +- crates/ui-egui/tests/it/interaction.rs | 2 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/scroll_position.rs | 2 +- .../ui-egui/tests/it/undo_while_grouping.rs | 57 + docs/architecture.md | 51 +- 22 files changed, 2461 insertions(+), 39 deletions(-) create mode 100644 crates/engine/src/cmd/stash.rs create mode 100644 crates/engine/src/record.rs create mode 100644 crates/engine/src/tests/seams.rs create mode 100644 crates/ui-egui/tests/it/undo_while_grouping.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 352797eab..edb0e2d7c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -741,7 +741,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -1264,6 +1264,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; + // Formulas and validation from an untrusted file leave the destination untrusted; plain values, + // formats (cell styles and merged areas, no conditional formats), column widths and notes + // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, + // except for column widths and notes, which write no cells at all. + let passive = + matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); + if !same_doc && !passive { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } if cut_move { s.clipboard = None; } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index f248c59f2..009d77f2a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -212,6 +212,15 @@ pub fn specs() -> Vec { trust_scripts ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), + cmd!( + noundo "automate.stopAction", + "Stop Action", + [], + None, + "{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began", + has_doc, + stop_action + ), // Cell controls, themes, borders, styles cmd!( "insert.checkbox", @@ -800,7 +809,7 @@ fn split(s: &mut Session, _: &Json) -> Result { fn new_window(s: &mut Session, _: &Json) -> Result { let d = s.doc()?.clone(); - let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title())); + let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust); copy.selection = d.selection.clone(); copy.scripts_trusted = d.scripts_trusted; let i = s.add_document(copy); @@ -1087,6 +1096,13 @@ fn run_script(s: &mut Session, p: &Json) -> Result { Ok(json!({"ran": n})) } +/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and +/// Redo work there again. +fn stop_action(s: &mut Session, _: &Json) -> Result { + let uid = s.doc()?.uid; + Ok(json!({"stopped": s.abort_groups(uid)})) +} + fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a61c6298f..b3e7309e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result { Some(name) => { let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?; let title = crate::sample::title(name); - s.add_document(DocState::new(wb, None, title)) + s.add_document(DocState::new(wb, None, title, crate::Trust::Local)) } None => s.new_workbook(), }; @@ -83,7 +83,8 @@ fn open(s: &mut Session, p: &Json) -> Result { { s.close_document(0); } - let mut doc = DocState::new(wb, path, title); + // Someone else may have made the file: its active content asks before it runs. + let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile); if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { doc.content_kind = Some(crate::io::FileKind::Xlsx); } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..7718279b2 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula}; +use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -32,18 +32,26 @@ pub fn specs() -> Vec { "Define Name…", ["Formulas", "Defined Names"], None, - "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)", + "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", has_doc, define_name ), - cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name), + cmd!( + "formulas.deleteName", + "Delete Name", + [], + None, + "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", + has_doc, + delete_name + ), cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager), cmd!( "formulas.createFromSelection", "Create from Selection", ["Formulas", "Defined Names"], Some("Cmd+Shift+F3"), - "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}", + "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc__` and `_sc_`, are listed in `skipped` unless force is true)}", has_doc, create_from_selection ), @@ -268,6 +276,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result { if !valid_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } + for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() { + refuse_stash_name(&s.doc()?.wb, p, n)?; + } let d = s.doc()?; let scope = match str_param(p, "scope") { None | Some("Workbook") => None, @@ -374,8 +385,29 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc } } +/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]): +/// defining, renaming or deleting one would replace or remove part of an item (a stored macro, +/// script or view) and leave the rest behind. `force: true` lets the call go ahead. +/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope +/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and +/// the name commands may change it. +fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool { + // The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not. + stash::holds_item(wb, name) +} + +fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> { + if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) { + return Err(EngineError::Other(format!( + "'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway." + ))); + } + Ok(()) +} + fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); + refuse_stash_name(&s.doc()?.wb, p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -461,7 +493,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // reported. let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); - let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n)); + // A label that names one of GridCraft's own hidden data names would replace part of an item + // (see `refuse_stash_name`). + let force = bool_param(p, "force").unwrap_or(false); + let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n); + let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n))); let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); edit(s, |cx| { @@ -742,6 +778,57 @@ mod tests { assert_eq!(r["refersTo"], "=Sheet1!$A$1"); } + #[test] + fn the_name_commands_leave_gridcraft_data_alone() { + // Defining, renaming or deleting a name of a stored item's shape would replace or remove + // one of its chunks and leave the rest behind: refused unless `force` is true. + let mut s = Session::new(); + s.new_workbook(); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + let text = "m".repeat(9_000); + stash::set(wb, "macro_1", &text).unwrap(); + wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true }); + // A legacy item is read whether or not it is hidden, so a visible one is data too. + wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false }); + let names = |s: &Session| s.doc().unwrap().wb.names.clone(); + let before = names(&s); + assert!(before.len() >= 3, "the item takes two chunks"); + for params in [ + json!({"name": "_gc_macro_1_0", "refersTo": "=1"}), + json!({"name": "_SC_Watch", "refersTo": "=1"}), + json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}), + json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}), + json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}), + ] { + let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string(); + assert!(err.contains("force"), "{params}: {err}"); + } + for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] { + let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string(); + assert!(err.contains("force"), "{name}: {err}"); + } + s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap(); + let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap(); + assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"]))); + let after = names(&s); + assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::>(), before); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str())); + // A visible name of the shape is the person's own (the stash never reads it): the name + // commands change and remove it without `force`, as Name Manager cannot pass one. + let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone()); + s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own); + s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap(); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched"); + // Ordinary names and forced calls go ahead. + s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap(); + assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1")); + } + #[test] fn arabic_defined_names_count_characters_not_bytes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f373de489..f5b478ccf 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -7,11 +7,11 @@ use super::*; pub fn specs() -> Vec { vec![ - cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect), + cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect), cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read), cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get), cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats), - cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history), + cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history), cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands), ] } @@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "title": d.display_title(), "path": d.path, "dirty": d.is_dirty(), + "trust": d.trust, "activeSheet": wb.active_sheet, "sheets": sheets, "selection": d.selection.a1(), @@ -148,9 +149,11 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result { fn history(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; - Ok( - json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::>()}), - ) + Ok(json!({ + "undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::>(), + "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::>(), + "grouping": s.in_group(d.uid), + })) } fn list_commands(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1f15ddfb8..8a0bb1052 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub mod sheet; pub mod slicer; pub mod solver; pub mod spelling; +pub mod stash; pub mod view; pub mod whatif; @@ -96,12 +97,19 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> { } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; + not_grouping(s)?; if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } pub fn can_redo(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; + not_grouping(s)?; if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) } } +/// Undo and Redo wait while an undo group is open on the active document (an action that spans +/// several calls is still running). +fn not_grouping(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) } +} pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) } @@ -179,6 +187,23 @@ pub fn register_test_command(id: &'static str, run: Run) { test_commands().push(spec); } +/// Test-only: an undoable command, to exercise how `execute` records its step. +#[cfg(test)] +pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { + let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec { + id, + label: id, + menu: &[], + shortcut: None, + params: "", + enabled: |_| Ok(()), + run, + journal: false, + undoable: true, + })); + test_commands().push(spec); +} + fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> { #[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery match TEST_COMMANDS.lock() { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 555c2fb79..a453f250b 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -586,11 +586,15 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { } let revision = d.revision; let before = (d.wb.clone(), d.selection.clone()); + // Inside an undo group the nested steps add no undo entries of their own. + let nested_entries = !s.in_group(d.uid); let result = insert_copied_steps(s, &clip, sheet, at, shift, cut); let d = s.doc_mut()?; // The nested steps collapse into this command's single undo step. - for _ in 0..d.revision.saturating_sub(revision) { - d.undo.pop(); + if nested_entries { + for _ in 0..d.revision.saturating_sub(revision) { + d.undo.pop(); + } } match result { Ok((final_range, out)) => { diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs new file mode 100644 index 000000000..c0e8302ca --- /dev/null +++ b/crates/engine/src/cmd/stash.rs @@ -0,0 +1,606 @@ +//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx +//! file. +//! +//! An item `key` is stored as names `_gc__0`, `_gc__1`, …, each a formula of string +//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a +//! formula to 8,192, so a long value is split over as many names as it needs and the file stays +//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep +//! one name each, `_sc_`; reading accepts that form as well, so those items can move to this +//! format later, and a write of the same key replaces it. +//! +//! XML can't carry every character (controls other than tab, line feed and carriage return, and +//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the +//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits +//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode +//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on +//! ordinary text. Legacy `_sc_` items are read as they are. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use gridcraft_model::{DefinedName, Workbook}; +use serde_json::Value as Json; + +use crate::{EngineError, Result}; + +const PREFIX: &str = "_gc_"; +const LEGACY_PREFIX: &str = "_sc_"; +/// Characters (UTF-16 units, as Excel counts them) in one string literal. +const LITERAL_UNITS: usize = 255; +/// Characters in one name's formula. +const FORMULA_UNITS: usize = 8_192; +/// The longest key: `_gc__` stays well inside Excel's 255-character names. +const MAX_KEY: usize = 200; +/// The longest item: 4,194,304 characters. +pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; +/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character +/// takes seven (the three-byte marker and four digits). +const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7; +/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter. +pub const ESCAPE: char = '\u{FDD0}'; +/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A +/// file can carry any number of hidden names; the rest are not listed. +pub const MAX_ITEMS: usize = 10_000; + +/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its +/// legacy `_sc_` name. +#[derive(Default)] +struct Parts<'a> { + chunks: BTreeMap, + legacy: Option<&'a str>, +} + +impl<'a> Parts<'a> { + fn add(&mut self, chunk: Option, formula: &'a str) { + match chunk { + Some(i) => { + self.chunks.entry(i).or_insert(formula); + } + None => self.legacy = self.legacy.or(Some(formula)), + } + } + + /// Whether the names make an item: a later chunk on its own does not. + fn is_item(&self) -> bool { + self.chunks.contains_key(&0) || self.legacy.is_some() + } +} + +#[cfg(test)] +thread_local! { + /// Names [`group`] has looked at on this thread, so a test can bound the work of a listing. + static NAME_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names +/// that hold them, in one pass over the names. +fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap> { + let prefix = prefix.to_ascii_lowercase(); + let mut items: BTreeMap> = BTreeMap::new(); + for n in wb.names.iter() { + #[cfg(test)] + NAME_VISITS.with(|v| v.set(v.get().saturating_add(1))); + let Some((k, chunk)) = item_of(n) else { continue }; + let k = k.to_ascii_lowercase(); + if k.starts_with(&prefix) { + items.entry(k).or_default().add(chunk, &n.formula); + } + } + items +} + +/// The item text stored under `key`, or `None` when there is none (or it is over the size limit). +pub fn get(wb: &Workbook, key: &str) -> Option { + let mut parts = Parts::default(); + for n in wb.names.iter() { + if let Some((k, chunk)) = item_of(n) + && k.eq_ignore_ascii_case(key) + { + parts.add(chunk, &n.formula); + } + } + assemble(&parts) +} + +/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass +/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit. +pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap { + group(wb, prefix) + .into_iter() + .filter(|(_, parts)| parts.is_item()) + .take(MAX_ITEMS) + .filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))) + .collect() +} + +/// The text of the item held by `parts`, as [`get`] reads it. +fn assemble(parts: &Parts) -> Option { + let chunks = &parts.chunks; + let mut out = String::new(); + if !chunks.contains_key(&0) { + out = string_literals(parts.legacy?); + } else { + // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. + for (want, (i, f)) in chunks.iter().enumerate() { + if *i != want as u64 { + break; + } + out.push_str(&string_literals(f)); + if out.len() > MAX_STORED_BYTES { + return None; + } + } + out = unescape(&out); + } + (out.chars().count() <= MAX_ITEM_CHARS).then_some(out) +} + +/// Whether `c` is stored escaped: XML can't carry it, or it is the marker. +fn needs_escape(c: char) -> bool { + c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') +} + +/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four +/// hex digits naming a character (a hand-made name) stays as it is. +fn unescape(s: &str) -> String { + if !s.contains(ESCAPE) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find(ESCAPE) { + out.push_str(rest.get(..at).unwrap_or("")); + let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or(""); + let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok()); + match code.and_then(char::from_u32) { + Some(c) => { + out.push(c); + rest = after.get(4..).unwrap_or(""); + } + None => { + out.push(ESCAPE); + rest = after; + } + } + } + out.push_str(rest); + out +} + +/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it. +pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { + check_key(key)?; + if text.chars().count() > MAX_ITEM_CHARS { + return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024)))); + } + let formulas = chunk_formulas(text); + // A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside + // it with the same name would be the one an xlsx file drops. Refuse before changing anything, + // in one pass over the names (a file can carry many thousands). + let chunks = formulas.len() as u64; + let taken = |n: &&DefinedName| { + n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks)) + }; + if let Some(n) = wb.names.iter().find(taken) { + return Err(EngineError::Other(format!( + "The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.", + n.name + ))); + } + remove(wb, key); + for (i, formula) in formulas.into_iter().enumerate() { + wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); + } + Ok(()) +} + +/// Removes the item stored under `key`. +pub fn remove(wb: &mut Workbook, key: &str) { + wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key))); +} + +/// The item under `key` as JSON (`None` when missing or not JSON). +pub fn get_json(wb: &Workbook, key: &str) -> Option { + serde_json::from_str(&get(wb, key)?).ok() +} + +pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> { + set(wb, key, &v.to_string()) +} + +/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted +/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass +/// where a [`get`] per key would take one each. +pub fn keys(wb: &Workbook, prefix: &str) -> Vec { + group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() +} + +/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope +/// `_gc__` name, or a workbook-scope legacy `_sc_` name, which is read whether or not +/// it is hidden. A visible `_gc_` name is the person's own. +pub fn holds_item(wb: &Workbook, name: &str) -> bool { + wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some()) +} + +/// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. +pub fn item_key(name: &str) -> Option { + parse_name(name).map(|(k, _)| k.to_ascii_lowercase()) +} + +/// The item a name holds a part of: a hidden workbook-scope `_gc__` name, or a legacy +/// `_sc_` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is +/// neither read, nor listed, nor removed on write. +fn item_of(n: &DefinedName) -> Option<(&str, Option)> { + if n.scope.is_some() { + return None; + } + parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden) +} + +/// `_gc__` → (`key`, `Some(k)`); `_sc_` → (`key`, `None`). +fn parse_name(name: &str) -> Option<(&str, Option)> { + if let Some(rest) = strip_prefix_ci(name, PREFIX) { + let (key, k) = rest.rsplit_once('_')?; + // Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not + // chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`. + let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0')); + if key.is_empty() || !canonical { + return None; + } + return k.parse().ok().map(|i| (key, Some(i))); + } + strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None)) +} + +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten() +} + +fn check_key(key: &str) -> Result<()> { + let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.'); + if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) } +} + +/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula +/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]). +/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back. +pub fn chunk_formulas(text: &str) -> Vec { + let mut formulas = Vec::new(); + let (mut formula, mut units) = (String::new(), 0usize); + let (mut literal, mut lit_units) = (String::from("\""), 0usize); + let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| { + literal.push('"'); + let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty()); + if *units + cost > FORMULA_UNITS && !formula.is_empty() { + formulas.push(std::mem::take(formula)); + *units = 0; + } + if !formula.is_empty() { + formula.push('&'); + *units += 1; + } + *units += literal.encode_utf16().count(); + formula.push_str(literal); + *literal = String::from("\""); + *lit_units = 0; + }; + for ch in text.chars() { + // An escaped character is the marker and four digits, kept in one literal. + let escaped = needs_escape(ch); + let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() }; + if lit_units + cost > LITERAL_UNITS { + flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); + } + lit_units += cost; + if escaped { + literal.push(ESCAPE); + let _ = write!(literal, "{:04X}", u32::from(ch)); + } else { + if ch == '"' { + literal.push('"'); + } + literal.push(ch); + } + } + if lit_units > 0 { + flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); + } + if !formula.is_empty() { + formulas.push(formula); + } + formulas +} + +/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored). +pub fn string_literals(f: &str) -> String { + let mut out = String::new(); + let mut it = f.chars().peekable(); + let mut in_str = false; + while let Some(c) = it.next() { + if !in_str { + if c == '"' { + in_str = true; + } + continue; + } + if c == '"' { + if it.peek() == Some(&'"') { + it.next(); + out.push('"'); + } else { + in_str = false; + } + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name(n: &str, formula: &str) -> DefinedName { + DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true } + } + + fn excel_limits_hold(wb: &Workbook) { + for n in &wb.names { + assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len()); + // Every literal (quotes undoubled) is at most 255 characters. + for lit in n.formula.split("\"&\"") { + let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\""); + assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len()); + } + } + } + + #[test] + fn long_values_split_over_names_and_come_back() { + let mut wb = Workbook::new(); + // Quotes double when written; characters outside the BMP count twice. + let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect(); + set(&mut wb, "big", &text).unwrap(); + let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect(); + assert!(names.len() > 5, "{names:?}"); + assert_eq!(names[0], "_gc_big_0"); + assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none())); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str())); + assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive"); + // Shorter again: the extra chunks go. + set(&mut wb, "big", "short").unwrap(); + assert_eq!(wb.names.len(), 1); + assert_eq!(wb.names[0].formula, "\"short\""); + set(&mut wb, "big", "").unwrap(); + assert!(wb.names.is_empty()); + assert_eq!(get(&wb, "big"), None); + } + + #[test] + fn chunks_and_literals_respect_the_boundaries() { + for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] { + let text = "q".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(fs.iter().map(|f| string_literals(f)).collect::(), text, "{len}"); + assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}"); + } + // All quotes: each literal doubles in the formula. + let text = "\"".repeat(9_000); + let fs = chunk_formulas(&text); + assert_eq!(fs.iter().map(|f| string_literals(f)).collect::(), text); + assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS)); + } + + #[test] + fn the_old_single_name_still_reads_and_is_replaced_on_write() { + let mut wb = Workbook::new(); + wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\"")); + wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\"")); + assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]}))); + assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2]))); + set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap(); + let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]); + assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []}))); + } + + #[test] + fn hostile_names_read_safely() { + let mut wb = Workbook::new(); + // A gap: chunk 2 without chunk 1. + wb.names.push(name("_gc_gap_0", "\"a\"")); + wb.names.push(name("_gc_gap_2", "\"c\"")); + assert_eq!(get(&wb, "gap").as_deref(), Some("a")); + // Only a later chunk: no item, not listed, and the legacy name is still read. + wb.names.push(name("_gc_orphan_2", "\"c\"")); + assert_eq!(get(&wb, "orphan"), None); + assert_eq!(keys(&wb, "orphan"), Vec::::new()); + wb.names.push(name("_sc_orphan", "\"old\"")); + assert_eq!(get(&wb, "orphan").as_deref(), Some("old")); + assert_eq!(keys(&wb, "orphan"), ["orphan"]); + // Chunks out of order, a duplicate, junk around the literals, an unterminated literal. + wb.names.push(name("_gc_mix_1", "\"b\"&A1&")); + wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); + wb.names.push(name("_gc_mix_0", "\"ignored\"")); + wb.names.push(name("_gc_mix_2", "\"c")); + assert_eq!(get(&wb, "mix").as_deref(), Some("abc")); + // Names that only look like chunks. + for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] { + wb.names.push(name(odd, "\"zzz\"")); + } + assert_eq!(get(&wb, "x"), None); + assert_eq!(get(&wb, ""), None); + // Not JSON, or JSON nested deeper than the parser allows. + wb.names.push(name("_gc_notjson_0", "\"{\"")); + assert_eq!(get_json(&wb, "notjson"), None); + let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000)); + wb.names.push(name("_gc_deep_0", &deep)); + assert_eq!(get_json(&wb, "deep"), None); + // Sheet-scoped names are someone else's. + wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") }); + assert_eq!(get(&wb, "scoped"), None); + // Bad keys are refused. + for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] { + assert!(set(&mut wb, bad, "v").is_err(), "{bad}"); + } + assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err()); + } + + #[test] + fn oversized_items_are_not_read() { + let mut wb = Workbook::new(); + let chunk = format!("\"{}\"", "x".repeat(250)); + let big = vec![chunk.as_str(); 32].join("&"); + for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) { + wb.names.push(name(&format!("_gc_huge_{i}"), &big)); + } + assert_eq!(get(&wb, "huge"), None); + } + + #[test] + fn a_visible_name_of_the_item_shape_is_not_an_item() { + // Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and + // writing or removing the item leaves it alone. + let mut wb = Workbook::new(); + let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") }; + wb.names.push(own.clone()); + assert_eq!(get(&wb, "watch"), None); + assert!(keys(&wb, "").is_empty()); + // Writing the item would put a hidden name of the same name beside it, and an xlsx file + // keeps only one of them: refused, with the names as they were. + let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string(); + assert!(err.contains("_gc_watch_0"), "{err}"); + assert_eq!(wb.names, vec![own.clone()]); + assert_eq!(get(&wb, "watch"), None); + set(&mut wb, "other", "theirs").unwrap(); + assert_eq!(get(&wb, "other").as_deref(), Some("theirs")); + remove(&mut wb, "watch"); + remove(&mut wb, "other"); + assert_eq!(wb.names, vec![own]); + } + + #[test] + fn a_chunk_number_with_leading_zeros_is_another_name() { + // `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a + // visible one is not in the way of writing `_gc_watch_0`. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_watch_00", "\"theirs\"")); + assert_eq!(get(&wb, "watch"), None); + assert!(keys(&wb, "").is_empty()); + let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") }; + wb.names.push(own.clone()); + set(&mut wb, "watch", "ours").unwrap(); + assert_eq!(get(&wb, "watch").as_deref(), Some("ours")); + assert_eq!(wb.names.len(), 3, "both other names stay"); + remove(&mut wb, "watch"); + assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]); + } + + #[test] + fn keys_list_items_by_prefix() { + let mut wb = Workbook::new(); + set(&mut wb, "macro_2", "b").unwrap(); + set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap(); + set(&mut wb, "scripts", "{}").unwrap(); + assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]); + assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1")); + assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts")); + assert_eq!(item_key("Total"), None); + } + + #[test] + fn listing_many_items_takes_one_pass_and_is_capped() { + // 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each + // key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS. + let mut wb = Workbook::new(); + let total = 20_000; + for i in 0..total { + wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\""))); + } + set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap(); + wb.names.push(name("_sc_macro_legacy", "\"old\"")); + wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\"")); + wb.names.push(name("_gc_other_0", "\"not a macro\"")); + NAME_VISITS.with(|v| v.set(0)); + let all = items(&wb, "MACRO_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names"); + assert_eq!(all.len(), MAX_ITEMS); + let first: Vec<&String> = all.keys().take(2).collect(); + assert_eq!(first, ["macro_00000", "macro_00001"]); + assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999")); + assert!(!all.contains_key("macro_10000"), "past the cap"); + NAME_VISITS.with(|v| v.set(0)); + let listed = keys(&wb, "macro_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len()); + assert_eq!(listed, all.keys().cloned().collect::>(), "keys and items agree"); + + // Under the cap every item is there, read as `get` reads it. + wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1")); + let few = items(&wb, "macro_"); + let want: BTreeMap = + [("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect(); + assert_eq!(few, want); + for (k, text) in &few { + assert_eq!(get(&wb, k).as_ref(), Some(text)); + } + assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]); + assert_eq!(items(&wb, "").len(), 3, "the other item too"); + } + + #[test] + fn items_round_trip_through_xlsx() { + let mut wb = Workbook::new(); + let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect(); + set(&mut wb, "blob", &text).unwrap(); + wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\"")); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"]))); + + // Characters XML can't carry, and the escape marker itself, come back too, in text and + // in JSON (which leaves U+FFFE and U+FFFF unescaped). + let mut wb = Workbook::new(); + let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect(); + let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect(); + set(&mut wb, "odd", &text).unwrap(); + excel_limits_hold(&wb); + let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"}); + set_json(&mut wb, "json", &json).unwrap(); + // All escapes: each literal holds whole escapes, within the limits. + let controls = "\u{1}".repeat(9_000); + set(&mut wb, "controls", &controls).unwrap(); + excel_limits_hold(&wb); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "json"), Some(json)); + assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str())); + } + + #[test] + fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() { + // Excel's limits hold however many characters are escaped, and the longest item of + // characters that all need escaping still reads. + for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] { + let text = "\u{2}".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::()), text, "{len}"); + assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}"); + } + let mut wb = Workbook::new(); + let longest = "\u{0}".repeat(MAX_ITEM_CHARS); + set(&mut wb, "longest", &longest).unwrap(); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS)); + // A marker in a hand-made name that is not an escape stays as it is. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\"")); + assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}")); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 066a9e559..b4ab60f3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; +pub mod record; pub mod sample; pub mod selection; pub mod tables; @@ -66,6 +67,51 @@ pub struct HistoryEntry { pub retained_bytes: usize, } +/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`] +/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all). +#[derive(Debug, PartialEq, Eq)] +#[must_use = "an undo group stays open until it is ended or rolled back"] +pub struct GroupToken { + id: u64, + doc: u64, +} + +impl GroupToken { + /// The uid of the document the group belongs to. + pub fn doc(&self) -> u64 { + self.doc + } +} + +/// An open undo group: what runs on `doc` until it ends becomes one undo step. +#[derive(Clone, Debug)] +struct OpenGroup { + id: u64, + doc: u64, + label: String, + /// Undo depth when the group began; the group's own steps are cut back to it. + start: usize, + wb: Arc, + selection: Selection, + /// The redo stack when the group began (a rollback puts it back). + redo: Vec, +} + +/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the +/// workbook and selection it ended at, the undo length then, and whether it made a step. A +/// command that ended the group tells its own later changes from the group's step by it, and +/// keeps the group's name only when the group's step is exactly its own (the entry holds the +/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no +/// workbook alive between commands. +#[derive(Debug)] +struct GroupStep { + doc: u64, + after: Arc, + selection: Selection, + len: usize, + stepped: bool, +} + /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { @@ -84,6 +130,34 @@ pub struct Clipboard { /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid /// together into one block at `range`. Empty for a single-range copy. pub areas: Vec, + /// Trust of the source document: pasting content from a file someone else made carries it. + pub trust: Trust, +} + +/// Whether a document's active content (macros) may run without asking the person first. There +/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new +/// way of building one from bytes can't come out trusted by omission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Trust { + /// Made in this session: a new workbook or a sample. + Local, + /// Opened from a file, or holding content pasted from one: active content asks before it + /// runs. + FromFile, +} + +impl Trust { + /// Whether active content may run without asking. + pub fn allows_code(self) -> bool { + self == Trust::Local + } + + /// The trust of a document after it receives content from a document with trust `source`: + /// content from an untrusted file leaves the receiver untrusted. + pub fn receive(self, source: Trust) -> Trust { + if source == Trust::FromFile { Trust::FromFile } else { self } + } } /// In-cell edit mode as shown in the status bar. @@ -120,6 +194,11 @@ pub struct DocState { /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts /// trusted, since any script it gains later was written here. pub scripts_trusted: bool, + /// Where the workbook's content came from: whether its active content may run without + /// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the + /// workbook is opened from a file, carried by content pasted from such a workbook, and never + /// changed by a trust decision. + pub trust: Trust, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -160,17 +239,31 @@ fn push_history_with_budget( selection: Selection, byte_budget: usize, ) { + push_entry(history, label, wb, next, selection); + trim_to_budget(history, byte_budget); +} + +/// Adds a step without applying the limits: inside a command they wait for the outermost +/// command to collapse its steps (see [`Session::trim_history`]). +fn push_entry(history: &mut Vec, label: String, wb: Arc, next: &Workbook, selection: Selection) { let retained_bytes = history_retained_bytes(&wb, next); history.push(HistoryEntry { label, wb, selection, retained_bytes }); +} + +/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one +/// (even if that one edit exceeds the budget). +fn trim_to_budget(history: &mut Vec, byte_budget: usize) { let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes)); - // Always preserve the latest action, even if that one edit exceeds the budget. while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) { total = total.saturating_sub(history.remove(0).retained_bytes); } } impl DocState { - pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { + /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything + /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook + /// made in this session. + pub fn new(mut wb: Workbook, path: Option, title: String, trust: Trust) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); let scripts_trusted = !cmd::extra::has_scripts(&wb); @@ -191,6 +284,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, scripts_trusted, + trust, } } pub fn is_dirty(&self) -> bool { @@ -307,8 +401,13 @@ pub struct Session { pub last_find: Option, /// View › Show › Ruler is off (rulers in Page Layout view; an application option). pub hide_ruler: bool, - /// Journal position where action recording started. + /// Journal position where Automate › Record Actions started. pub recording: Option, + /// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level + /// commands with the place they ran in, kept apart from the journal. + pub recorder: Option, + /// How many `execute` calls are running (0 between top-level commands). + depth: u32, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. @@ -320,6 +419,17 @@ pub struct Session { /// Review › Notes › Show All Notes: every note is displayed. An application display setting /// (like Excel's comment indicator option), so it's not saved in the file. pub show_all_notes: bool, + /// Open undo groups, innermost last. + groups: Vec, + next_group: u64, + /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. + group_epoch: u64, + /// Where the last group ended, so `execute` can tell a command's own changes from its group's. + group_step: Option, + /// For each running command, innermost last: its document and that document's undo length + /// when it began. Steps before that length are not touched while the command runs (see + /// [`Session::bound_tail`]). + marks: Vec<(u64, usize)>, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. pub text_width: Option, @@ -359,7 +469,8 @@ impl Session { } pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { - self.docs.remove(i); + let d = self.docs.remove(i); + self.groups.retain(|g| g.doc != d.uid); if self.active >= self.docs.len() { self.active = self.docs.len().saturating_sub(1); } @@ -373,7 +484,7 @@ impl Session { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); } - self.add_document(DocState::new(wb, None, format!("Book{n}"))) + self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local)) } /// Journals a command, then trims the oldest entries past either cap. A recording in progress @@ -412,7 +523,8 @@ impl Session { command_specs().iter().map(|c| c.info(self)).collect() } - /// Runs a command by id. Undoable commands record a history step when the workbook changed. + /// Runs a command by id. Undoable commands record a history step when the workbook changed, + /// unless an undo group is open on the document (the group then makes the step). /// Panics inside commands are caught and reported; the workbook is restored. pub fn execute(&mut self, id: &str, params: Json) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; @@ -421,36 +533,352 @@ impl Session { } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; + let (first_group, group_epoch) = (self.next_group, self.group_epoch); + // Whether a group was open on the document before the command, and how long its history was. + let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); + // A command that panics leaves the history as it found it: the steps commands nested in it + // made or undid, and the redo history they cleared, come back. A length is not enough, + // because a nested `edit.undo` removes entries. A query runs nothing that changes the + // history (`every_query_leaves_the_history_alone` runs each one), so it needs no + // copy; front ends run queries all the time. + let query = !spec.journal && !spec.undoable; + let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { + #[cfg(test)] + tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1))); + self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone())) + }); + // The groups open on the document before the command (usually none), for the same reason. + let groups_before: Vec<(u64, String, Arc, Selection)> = match &before { + Some((uid, ..)) => { + self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect() + } + None => Vec::new(), + }; + // The recorder keeps top-level calls with the place they ran in. + let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { + Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), + None => (0, 0, String::new(), Selection::default()), + }); + let doc_uid = before.as_ref().map(|(uid, ..)| *uid); + let requests_before = self.ui_requests.len(); + let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len)); + self.marks.extend(marked); + self.depth = self.depth.saturating_add(1); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); + self.depth = self.depth.saturating_sub(1); + if marked.is_some() { + self.marks.pop(); + } let result = match result { Ok(r) => r, Err(p) => { let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); - // Restore the workbook as it was. + // Restore the workbook and its history as they were, and drop the undo groups the + // command opened. + self.groups.retain(|g| g.id < first_group); + let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid)); if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) { d.wb = wb.clone(); d.selection = sel.clone(); + if let Some((undo, redo)) = history_before { + d.undo = undo; + d.redo = redo; + } + // A group open before the command and ended or rolled back inside it stays + // ended (its token went with the command), and keeps a step from its start to + // the restored workbook, so Undo still returns to the state before the group. + if !group_open + && let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0) + && !Arc::ptr_eq(&wb, &d.wb) + { + push_entry(&mut d.undo, label, wb, &d.wb, selection); + d.redo.clear(); + } d.calc.rebuild(&d.wb); } + if self.depth == 0 { + self.trim_history(); + self.group_step = None; + } return Err(EngineError::Internal(id.to_string(), msg)); } }; + // A command that fails leaves none of its own undo groups open (a `?` between + // `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a + // command that succeeds keeps a group it opened (a debugger's start). + if result.is_err() { + self.rollback_groups_from(first_group); + } if spec.journal && result.is_ok() { + // A command that started recording is not part of the recording; one that stopped it + // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no + // file, `home.formatCells` with no style) is not recorded either: the dialog's own + // call is, so playing the recording back runs the command once, with its parameters. + let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog) + && let Some(rec) = self.recorder.as_mut() + { + rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); + } self.push_journal(id, cmd::journal_params(¶ms)); } + // Inside an undo group the group owns the step. A command that ended a group open before + // it leaves the group its step and makes one of its own for what it changed after the end. + // A command that opened and closed its own groups makes one step of its own, which + // replaces the steps those groups made. + let epoch_changed = self.group_epoch != group_epoch; + let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid)); + let was_open = start.is_some_and(|(was_open, _)| was_open); if spec.undoable && let Some((uid, wb, sel)) = before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&d.wb, &wb) { - push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel); + let label = spec.label.trim_end_matches('…').to_string(); + if in_group_now { + // A group this command opened and left open (a debugger's start) takes the + // command's start as its own, so what the command changed before opening it is + // part of the group's step and not a step of its own. + if !was_open + && let Some((_, len)) = start + && let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id) + { + g.start = len; + g.wb = wb; + g.selection = sel; + } + } else if epoch_changed && was_open { + // The group it ended made its step; changes after the end, whether direct or by + // nested commands, are the command's own step from where the group ended. + if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid) + && (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb)) + { + d.undo.truncate(step.len); + push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone()); + d.redo.clear(); + } + } else { + let mut label = label; + if epoch_changed && let Some((_, len)) = start { + // A command whose only step is its group's keeps that name ("Run Macro: Totals"). + // That is so only when the one entry the command added is a group's step from + // the command's own start to its own result: an entry a nested command made, + // a group step of an earlier command (undone since), or a group's step that + // the command edited past all name the step after the command instead. + if d.undo.len() == len + 1 + && let Some(last) = d.undo.last() + && Arc::ptr_eq(&last.wb, &wb) + && self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb)) + { + label = last.label.clone(); + } + d.undo.truncate(len); + } + push_entry(&mut d.undo, label, wb, &d.wb, sel); + d.redo.clear(); + } + // Inside a group the redo history waits for the group: `group_end` clears it when the + // group becomes a step, and `group_rollback` puts it back as it was. + d.revision += 1; + } + // The history limit applies once the outermost command has collapsed its steps, so the + // lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own. + if self.depth == 0 { + self.trim_history(); + self.group_step = None; + } else if let Some(uid) = doc_uid { + self.bound_tail(uid); + } + result + } + + /// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f` + /// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made + /// is rolled back and its error is returned. Groups nest; the outermost one makes the step. + pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let token = self.group_begin(label)?; + let doc = token.doc(); + // A panic in `f` rolls the group back before it goes on, so a caller that catches it + // (outside `execute`, which drops a command's groups itself) finds no group left open. + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) { + Ok(Ok(r)) => match self.group_end(token) { + Ok(_) => Ok(r), + // `f` closed the group's document (`file.close`, or `file.open` taking the place of + // an untouched new workbook), and the group went with it: nothing is left to undo. + Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r), + Err(e) => Err(e), + }, + Ok(Err(e)) => { + // The error from `f` is the one to report. A rollback fails only when the group + // has already ended, and then there is nothing left to undo. + let _ = self.group_rollback(token); + Err(e) + } + Err(panic) => { + let _ = self.group_rollback(token); + std::panic::resume_unwind(panic) + } + } + } + + /// Opens an undo group on the active document that can span several calls (for example a + /// debugger that runs one step per call). Until [`Session::group_end`] or + /// [`Session::group_rollback`], undoable commands on that document add no undo steps of their + /// own, and Undo and Redo are unavailable there. + pub fn group_begin(&mut self, label: &str) -> Result { + let d = self.doc()?; + let g = OpenGroup { + id: self.next_group, + doc: d.uid, + label: label.trim_end_matches('…').to_string(), + start: d.undo.len(), + wb: d.wb.clone(), + selection: d.selection.clone(), + redo: d.redo.clone(), + }; + let token = GroupToken { id: g.id, doc: g.doc }; + self.next_group = self.next_group.saturating_add(1); + self.group_epoch = self.group_epoch.wrapping_add(1); + self.groups.push(g); + Ok(token) + } + + /// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is + /// nested in another open group on the same document, which then owns the step. Groups opened + /// inside it and still open end with it. Returns whether the workbook changed. + pub fn group_end(&mut self, token: GroupToken) -> Result { + let g = self.take_group(&token)?; + let outer = self.in_group(g.doc); + let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) }; + // Steps pushed directly while the group was open fold into the group's step. + d.undo.truncate(g.start); + let changed = !Arc::ptr_eq(&d.wb, &g.wb); + if changed && !outer { + push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection); d.redo.clear(); d.revision += 1; } - result + if !outer { + self.group_step = + Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed }); + } + // Inside a command, the outermost command applies the history limit when it finishes. + if self.depth == 0 { + self.trim_history(); + } + Ok(changed) + } + + /// Stops the running action on the document with uid `doc`: every undo group open on it is + /// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo + /// and redo history as they were when the outermost one began. Their tokens are then spent + /// (ending or rolling one back reports that it has already ended). Returns whether a group + /// was open. `automate.stopAction` runs it on the active document. + pub fn abort_groups(&mut self, doc: u64) -> bool { + let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false }; + // Rolling back the outermost group ends the ones opened inside it. + self.group_rollback(GroupToken { id, doc }).is_ok() + } + + /// Rolls back every open undo group numbered `first` or later (the groups a failed command + /// opened), outermost first on each document. + fn rollback_groups_from(&mut self, first: u64) { + let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect(); + for (id, doc) in opened { + // A group inside one rolled back before it is already gone. + if self.groups.iter().any(|g| g.id == id) { + let _ = self.group_rollback(GroupToken { id, doc }); + } + } + } + + /// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per + /// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest. + /// Only steps after the latest mark go: a running command's start, an open group's start and + /// the end of the last group are positions the command or group collapses its steps by when + /// it finishes, so what is before them stays as it is until [`Session::trim_history`]. + fn bound_tail(&mut self, uid: u64) { + let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; + let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len); + let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start); + let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len); + let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len()); + let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes)); + while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) { + bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes); + } + } + + /// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with + /// an open group waits for the group to end, because the group remembers where its steps start. + fn trim_history(&mut self) { + let groups = &self.groups; + for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) { + trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT); + } + } + + /// Ends an undo group by putting back the workbook, selection, undo and redo history as they + /// were when it began. + pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> { + let g = self.take_group(&token)?; + let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) }; + if !Arc::ptr_eq(&d.wb, &g.wb) { + d.wb = g.wb; + d.calc.rebuild(&d.wb); + } + d.selection = g.selection; + d.undo.truncate(g.start); + d.redo = g.redo; + d.revision += 1; + // The record of the last group step stays when the document is back exactly where that + // step left it (this group began there); otherwise the rollback is where the group ended. + let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb)); + if !kept && !self.groups.iter().any(|o| o.doc == g.doc) { + self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false }); + } + Ok(()) + } + + /// Starts recording top-level commands into an empty buffer, replacing any recording in + /// progress. Queries, failed commands and commands run by other commands are not recorded. + pub fn start_recording(&mut self) { + self.recorder = Some(record::Recorder::default()); + } + + /// Stops recording and returns what was recorded (`None` when nothing was recording). + pub fn stop_recording(&mut self) -> Option { + self.recorder.take() + } + + /// Whether the recording buffer is collecting commands. + pub fn is_recording(&self) -> bool { + self.recorder.is_some() + } + + /// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a + /// command run by a command, and so on. + pub fn depth(&self) -> u32 { + self.depth + } + + /// Whether an undo group is open on the document with this uid. + pub fn in_group(&self, doc: u64) -> bool { + self.groups.iter().any(|g| g.doc == doc) + } + + /// Removes the token's group and any group on the same document opened after it. + fn take_group(&mut self, token: &GroupToken) -> Result { + let ended = || EngineError::Other("That undo group has already ended.".into()); + let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?; + let mut later = self.groups.split_off(at).into_iter(); + let g = later.next().ok_or_else(ended)?; + self.groups.extend(later.filter(|o| o.doc != g.doc)); + self.group_epoch = self.group_epoch.wrapping_add(1); + Ok(g) } pub fn run(&mut self, id: &str, params: Json) -> std::result::Result { @@ -468,6 +896,8 @@ impl Session { let r = self.execute(id, params); let requests = self.take_ui_requests(); let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); + // A call that only opened a dialog is not recorded (see `execute`), so the recording + // has no step to take back here. if r.is_ok() && asked_for_file { let what = match id { "file.open" => "`path` or `base64`", diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs new file mode 100644 index 000000000..7804be6c9 --- /dev/null +++ b/crates/engine/src/record.rs @@ -0,0 +1,66 @@ +//! The recording buffer: the commands a person (or an agent) ran while recording, each with the +//! document, sheet and selection it ran in. +//! +//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a +//! script) is one step, so playing the recording back does not run the inner commands twice. The +//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and +//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]). + +use serde::Serialize; +use serde_json::Value as Json; + +use crate::Selection; + +/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`]. +pub const RECORD_LIMIT: usize = 100_000; +/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full +/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so +/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. +pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024; + +/// One recorded command and where it ran. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordedStep { + /// Uid of the document that was active when the command ran (0 when none was open). + pub doc: u64, + /// That document's active sheet (index and name) just before the command ran. + pub sheet: usize, + pub sheet_name: String, + /// That document's selection just before the command ran. + pub selection: Selection, + /// The command id and its parameters, without secrets. + pub id: String, + pub params: Json, +} + +/// The steps recorded since recording started. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + pub steps: Vec, + /// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`]. + /// Once a step is dropped, every later one is too, so `steps` is always the beginning of + /// what ran, never a sequence with gaps. + pub dropped: usize, + /// The size of `steps` serialized as JSON. + bytes: usize, +} + +impl Recorder { + /// Adds a step, or counts it as dropped once the buffer is full. + pub fn push(&mut self, step: RecordedStep) { + // Once a step has been dropped, or the step count is full, the rest are counted without + // being serialized (a `file.open` step holds a whole file). + if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT { + self.dropped = self.dropped.saturating_add(1); + return; + } + let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { + self.bytes = self.bytes.saturating_add(size); + self.steps.push(step); + } else { + self.dropped = self.dropped.saturating_add(1); + } + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 129fe2171..90029bac3 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option { _ => return None, }; } - s.add_document(DocState::new(wb, None, title(name))); + s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local)); let ok = match name { "budget" => budget(&mut s), "sales" => sales(&mut s), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 756a4d090..1be90d56c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,6 +3,11 @@ use serde_json::json; use crate::Session; +thread_local! { + /// Copies of a document's undo and redo history `execute` has taken on this thread. + pub(crate) static HISTORY_COPIES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn s() -> Session { let mut s = Session::new(); s.new_workbook(); @@ -4599,3 +4604,4 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() { let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); assert!(!html.contains("") && !html.contains("table-layout") && !html.contains("