From 5392d95bf85d53eab9abedf78a8757e7d292d9ad Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 21:13:13 -0400 Subject: [PATCH 01/63] scripts: require workbook trust before running embedded scripts (#2178) A workbook opened with GridCraft scripts (`_sc_scripts`) starts untrusted: automate.runScript refuses them until the user trusts the workbook for the session (Macros dialog > Trust Scripts, i.e. automate.trustScripts) or the caller passes `trusted: true` for that one run. Workbooks without scripts, and scripts written in the session, are unaffected. Whatever the trust, a script whose steps reach outside the workbook (file.*, link.*, or any `path` param: Get Data, From Text/CSV, Insert Picture) is refused before any step runs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/extra.rs | 75 ++++++++++++++++++++++++++--- crates/engine/src/lib.rs | 7 +++ crates/engine/src/tests.rs | 87 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 15 +++++- 4 files changed, 176 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..b88ce5592 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -193,7 +193,24 @@ pub fn specs() -> Vec { new_script ), cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts), - cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script), + cmd!( + "automate.runScript", + "Run Script", + [], + None, + "{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)", + has_doc, + run_script + ), + cmd!( + noundo "automate.trustScripts", + "Trust Scripts", + ["Automate", "Scripting Tools"], + None, + "{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session", + has_doc, + trust_scripts + ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), // Cell controls, themes, borders, styles cmd!( @@ -782,6 +799,7 @@ 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())); copy.selection = d.selection.clone(); + copy.scripts_trusted = d.scripts_trusted; let i = s.add_document(copy); Ok(json!({"index": i})) } @@ -970,10 +988,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let wb = &s.doc()?.wb; + let d = s.doc()?; + let (wb, trusted) = (&d.wb, d.scripts_trusted); let all = stash_get(wb, SCRIPTS_NAME); - let mut list: Vec = - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + let mut list: Vec = all + .as_object() + .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) + .unwrap_or_default(); // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on // save but never executed here, so it has no step count. if let Some(bytes) = &wb.vba_project { @@ -982,12 +1003,54 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result { Ok(Json::Array(list)) } +/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). +pub(crate) fn has_scripts(wb: &Workbook) -> bool { + stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) +} + +/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the +/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, +/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) +fn denied_script_step(id: &str, params: &Json) -> Option { + if id.starts_with("file.") || id.starts_with("link.") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + if params.get("path").is_some() { + return Some(format!("scripts can't pass a file `path` to `{id}`")); + } + None +} + +fn trust_scripts(s: &mut Session, p: &Json) -> Result { + let trusted = bool_param(p, "trusted").unwrap_or(true); + s.doc_mut()?.scripts_trusted = trusted; + Ok(json!({"trusted": trusted})) +} + +/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is +/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that +/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. fn run_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); + let d = s.doc()?; + let all = stash_get(&d.wb, SCRIPTS_NAME); let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; + if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { + return Err(bad( + "automate.runScript", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)", + )); + } + let steps: Vec = steps.into_iter().take(100_000).collect(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if let Some(why) = denied_script_step(id, &st["params"]) { + return Err(bad("automate.runScript", why)); + } + } let mut n = 0; - for st in steps.iter().take(100_000) { + for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { continue; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb72..bf7dd633d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,6 +112,11 @@ pub struct DocState { /// The format `path`'s content was recognised as when its extension doesn't name one (an /// XLSX called `budget.dat`): Save writes it back in that format. pub content_kind: Option, + /// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook + /// that arrives already carrying scripts (opened from a file) starts untrusted until the + /// 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, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -121,6 +126,7 @@ impl DocState { pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); + let scripts_trusted = !cmd::extra::has_scripts(&wb); let wb = Arc::new(wb); let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default()); DocState { @@ -137,6 +143,7 @@ impl DocState { sheet_selections: vec![], unlocked_ranges: vec![], content_kind: None, + scripts_trusted, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..357b925c1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -940,6 +940,93 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { s.execute("automate.runScript", json!({"name": "Lock"})).unwrap(); } +/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178). +fn opened_with_script() -> Session { + let mut author = s(); + author + .execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]})) + .unwrap(); + let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap(); + let mut s = Session::new(); + s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + s +} + +#[test] +fn scripts_written_in_this_session_run_without_a_trust_step() { + let mut s = s(); + assert!(s.doc().unwrap().scripts_trusted); + s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]})) + .unwrap(); + assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); +} + +#[test] +fn untrusted_workbook_scripts_are_refused_until_trusted() { + let mut s = opened_with_script(); + s.ui_requests.clear(); + assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted"); + let listed = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(listed[0]["trusted"], false, "{listed}"); + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("trust"), "{err}"); + assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs"); + // Trust can't be smuggled in as a string, and nothing opens a dialog. + assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err()); + assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests); + + // An explicit `trusted: true` runs it this once without trusting the workbook. + s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); + assert!(!s.doc().unwrap().scripts_trusted); + s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + + // Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session. + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + s.execute("automate.runScript", json!({"name": "Fill"})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); + // A second window on the same workbook keeps the decision; withdrawing it disables them again. + s.execute("view.newWindow", json!({})).unwrap(); + assert!(s.doc().unwrap().scripts_trusted); + s.execute("automate.trustScripts", json!({"trusted": false})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); +} + +#[test] +fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() { + let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id())); + let leak = dir.join("leak.xlsx"); + let leak = leak.to_string_lossy().to_string(); + let mut s = opened_with_script(); + s.ui_requests.clear(); + s.execute("automate.trustScripts", json!({})).unwrap(); + let denied = [ + json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}), + json!({"command": "file.saveAs", "params": {"path": leak}}), + json!({"command": "file.save", "params": {}}), + json!({"command": "file.exportPdf", "params": {"path": leak}}), + json!({"command": "file.open", "params": {"path": leak}}), + json!({"command": "data.getData", "params": {"path": leak}}), + json!({"command": "data.fromTextCsv", "params": {"path": leak}}), + json!({"command": "insert.picture", "params": {"path": leak}}), + ]; + for step in denied { + // The harmless first step proves the whole script is refused before anything runs. + let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}}); + s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap(); + let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true})); + assert!(r.is_err(), "{step} must be refused"); + assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs"); + } + assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests); + assert!(!std::path::Path::new(&leak).exists()); + // Data a script supplies itself is still fine. + s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Csv"})).unwrap(); +} + /// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. fn protected(extra: serde_json::Value) -> Session { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..10320476c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1077,7 +1077,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } - "macros" => macros(ui, &mut d), + "macros" => macros(app, ui, &mut d), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1496,7 +1496,7 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { /// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the /// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is /// preserved and not executed. -fn macros(ui: &mut egui::Ui, d: &mut Dialog) { +fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); ui.add_space(6.0); let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); @@ -1504,6 +1504,17 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { ui.weak("This workbook has no scripts or macros."); return; } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for it in &items { let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); From b9801f3f810927d4dd597c0d38b5ba13729221eb Mon Sep 17 00:00:00 2001 From: Viorel Toader Date: Thu, 22 Oct 2026 07:55:04 +0300 Subject: [PATCH 02/63] Romanian: translate Options|Editing; 22 strings main added since (sheet tab menu, account, chart and error-checking labels) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk --- crates/ui-egui/src/i18n/ro.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 699e30ff3..9e0d92516 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1005,7 +1005,31 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Print job submitted: {receipt}" => "Lucrarea de imprimare a fost trimisă: {receipt}", "Print failed: {error}" => "Imprimarea a eșuat: {error}", "Print failed: the printing worker stopped." => "Imprimarea a eșuat: procesul de imprimare s-a oprit.", - "Options|Editing" => "Editing", + "Options|Editing" => "Editare", + "Duplicate" => "Duplicare", + "Rename" => "Redenumire", + "Product Information" => "Informații despre produs", + "Tab Color" => "Culoare filă", + "Select All Sheets" => "Selectare toate foile", + "Fit selection" => "Încadrare selecție", + "All sheets" => "Toate foile", + "Version" => "Versiune", + "Unhide…" => "Reafișare…", + "Platform" => "Platformă", + "Unprotect Sheet…" => "Deprotejare foaie…", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Salvare automată: salvează după fiecare modificare (registrele salvate într-un fișier)" + } + "Licence" => "Licență", + "Account" => "Cont", + "No Color" => "Fără culoare", + "Move or Copy…" => "Mutare sau copiere…", + "Number Stored as Text" => "Număr stocat ca text", + "Select Data…" => "Selectare date…", + "Convert to Number" => "Conversie în număr", + "Format Chart Area…" => "Formatare zonă diagramă…", + "Error checking options" => "Opțiuni de verificare a erorilor", + "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", _ => return None, }) } From e177bf1c44581da8bf29ebf7e261b8a988449c05 Mon Sep 17 00:00:00 2001 From: Tyrel Haveman <1434399+tyrel@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:41:28 -0700 Subject: [PATCH 03/63] calc: hardening: overlong digits, NaN indexes, REGEXREPLACE output (#2163, #2162) - Text coercion: digits too many for a finite number are not a number, and a fraction must come out finite, so "1 <400 digits>/<400 digits>" (inf/inf) no longer coerces to NaN (test overlong_digits_stay_text). - VLOOKUP/HLOOKUP and the D-functions' field index refuse NaN, which passed their "< 1" checks and became index 0, then underflowed (test nan_index_is_a_value_error; hostile_inputs_never_panic now also passes NaN and infinity to every function). - REGEXREPLACE replaces match by match and gives up once the result can only be too long, instead of building every replacement first: the 32,767 x 32,767 worst case went from 1.05 GB peak to 7 MB (test regexreplace_bounds_its_result). #2165 (MAP's size check) is already fixed on main by the broadcast cap (836b2698, #2182), whose test covers MAP. Also adds my contributors/people.toml entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP --- ROADMAP.md | 4 +++- contributors/people.toml | 3 +++ crates/core/src/parse.rs | 19 +++++++++++++++++-- crates/functions/src/database.rs | 5 +++-- crates/functions/src/lib.rs | 31 +++++++++++++++++++++++++++++++ crates/functions/src/lookup.rs | 5 +++-- crates/functions/src/text.rs | 16 +++++++++++++++- 7 files changed, 75 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e04e9123..1faf680ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | | 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch | @@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | | 2026-10-19 | minor | Progress log: long flat operator chains (#916) | diff --git a/contributors/people.toml b/contributors/people.toml index 62635c8ae..88e496fc7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -93,3 +93,6 @@ sync_github_name = false [people.bilalmughal] sync_github_name = true + +[people.tyrel] +real_name = "Tyrel Haveman" diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b58586aef..3f2b97a8a 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { } /// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects -/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept. +/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a +/// finite number (#2163). fn plain_number(s: &str, frac: bool) -> Option { let (int, dec) = match s.split_once('.') { Some((i, d)) if frac => (i, Some(d)), @@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option { if !digits(int) || dec.is_some_and(|d| !digits(d)) { return None; } - s.parse().ok() + s.parse().ok().filter(|x: &f64| x.is_finite()) } /// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`. @@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option { return None; } let v = w + a / b; + if !v.is_finite() { + return None; + } Some(if neg { -v } else { v }) } @@ -366,6 +370,17 @@ mod tests { parse_input(s, DateSystem::D1900) } + /// #2163: digits too many for a finite number don't make one (∞/∞ was NaN). + #[test] + fn overlong_digits_stay_text() { + let big = "9".repeat(400); + for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] { + assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}"); + assert_eq!(parse_number_text(&s), None, "{s}"); + } + assert_eq!(parse_number_text("1 1/2"), Some(1.5)); + } + #[test] fn non_digit_time_and_fraction_parts_stay_text() { for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] { diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 67132e98f..455b8a1d6 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { match v { Value::Number(n) => { let i = n.trunc(); - if i < 1.0 || i > db.cols as f64 { + // NaN too: it would pass both comparisons and become index 0 (#2163). + if i.is_nan() || i < 1.0 || i > db.cols as f64 { return Err(CellError::Value); } - Ok(Some(i as usize - 1)) + Ok(Some((i as usize).saturating_sub(1))) } Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value), Value::Bool(_) => Err(CellError::Value), diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index d7ccc8726..f870f3fe2 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -264,6 +264,34 @@ mod tests { } } + /// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`). + #[test] + fn nan_index_is_a_value_error() { + use crate::util::testutil::*; + use gridcraft_core::CellError; + let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]])); + let nan = || av(Value::Number(f64::NAN)); + assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value)); + assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value)); + assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value)); + assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0)); + } + + /// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces + /// as before (every match, `$n` groups). + #[test] + fn regexreplace_bounds_its_result() { + use crate::util::testutil::*; + use gridcraft_core::CellError; + let many = "a".repeat(32_767); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value)); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767))); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c")); + assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026")); + assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-")); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#")); + } + #[test] fn hostile_inputs_never_panic() { use crate::util::testutil::*; @@ -279,6 +307,9 @@ mod tests { n(-1e308), n(1e10), n(2.5), + // Numbers no formula should make, but code can (#2163). + av(Value::Number(f64::NAN)), + av(Value::Number(f64::INFINITY)), t(""), t("abc"), t("*?~"), diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index f9c0b6f44..acb23f2ea 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -134,10 +134,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R { return Err(*e); } let idx = num(args, 2)?; - if idx < 1.0 { + // NaN too: it would pass `idx < 1.0` and become index 0 (#2163). + if idx.is_nan() || idx < 1.0 { return Err(CellError::Value); } - let idx = idx.trunc() as usize - 1; + let idx = (idx.trunc() as usize).saturating_sub(1); let approx = opt_bool(args, 3, true)?; let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) }; if idx >= len { diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 71a6ccb4b..096e32990 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -769,7 +769,21 @@ fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let occ = opt_int(a, 3, 0)?; let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?; if occ == 0 { - return check_len(re.replace_all(&s, rep.as_str()).into_owned()); + // Match by match, giving up once the text can only be too long (a character is at most + // four bytes) rather than building every replacement first (#2162). + let mut out = String::new(); + let mut last = 0; + for cap in re.captures_iter(&s) { + let Some(m) = cap.get(0) else { continue }; + out.push_str(s.get(last..m.start()).unwrap_or_default()); + cap.expand(&rep, &mut out); + last = m.end(); + if out.len() > MAX_TEXT * 4 { + return Err(CellError::Value); + } + } + out.push_str(s.get(last..).unwrap_or_default()); + return check_len(out); } let caps: Vec = re.captures_iter(&s).collect(); let k = occ.unsigned_abs() as usize; From 742a6e8535dde599073ddffb9e55a3f2b58cd83e Mon Sep 17 00:00:00 2001 From: Tyrel Haveman <1434399+tyrel@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:45:50 -0700 Subject: [PATCH 04/63] functions: REGEXREPLACE bounds each replacement as it expands it (#2162) From the local CodeRabbit review: the result was bounded per match, but one match's expansion was built whole first, so a replacement repeating $0 10,000 times over a 32,767-character match allocated about 330 MB before the check. The replacement is now split into pieces as regex's Captures::expand reads it (template_expands_like_regex checks they agree) and appended piece by piece within the bound: 327 MB peak became 7 MB (regexreplace_bounds_its_result). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP --- crates/functions/src/lib.rs | 3 ++ crates/functions/src/text.rs | 100 ++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index f870f3fe2..e4f7a1f24 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -290,6 +290,9 @@ mod tests { assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026")); assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-")); assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#")); + // One match whose replacement repeats it 10,000 times: refused before it is built. + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value)); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value)); } #[test] diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 096e32990..95eb6784d 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -763,24 +763,87 @@ fn convert_replacement(r: &str) -> String { out } +/// A piece of a replacement: literal text or a capture group (by number or name). +enum Piece { + Text(String), + Index(usize), + Name(String), +} + +/// A replacement split into pieces as `regex::Captures::expand` reads it: `$$` is `$`, `$name` takes +/// the longest run of `[_0-9A-Za-z]`, `${name}` ends at `}` (`${}` is empty), a name of digits is a +/// group number, and a `$` that starts no reference is itself. +fn template(rep: &str) -> Vec { + let mut out = Vec::new(); + let mut lit = String::new(); + let mut rest = rep; + while let Some(i) = rest.find('$') { + lit.push_str(&rest[..i]); + rest = &rest[i + 1..]; + if let Some(r) = rest.strip_prefix('$') { + lit.push('$'); + rest = r; + continue; + } + // A braced name is a reference even when empty (it expands to nothing). + let braced = rest.strip_prefix('{').and_then(|b| b.find('}').map(|j| (&b[..j], &b[j + 1..]))); + let (name, after) = braced.unwrap_or_else(|| { + let j = rest.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())).unwrap_or(rest.len()); + (&rest[..j], &rest[j..]) + }); + if braced.is_none() && name.is_empty() { + lit.push('$'); + continue; + } + if !lit.is_empty() { + out.push(Piece::Text(std::mem::take(&mut lit))); + } + out.push(match name.parse::() { + Ok(n) => Piece::Index(n), + Err(_) => Piece::Name(name.to_string()), + }); + rest = after; + } + lit.push_str(rest); + if !lit.is_empty() { + out.push(Piece::Text(lit)); + } + out +} + +/// Append a match's replacement to `out`, piece by piece; `None` once `out` would pass `limit` +/// bytes, before anything that long is built (#2162). +fn expand_capped(cap: ®ex::Captures, pieces: &[Piece], out: &mut String, limit: usize) -> Option<()> { + for p in pieces { + let s = match p { + Piece::Text(t) => t.as_str(), + Piece::Index(i) => cap.get(*i).map_or("", |m| m.as_str()), + Piece::Name(n) => cap.name(n).map_or("", |m| m.as_str()), + }; + if out.len().saturating_add(s.len()) > limit { + return None; + } + out.push_str(s); + } + Some(()) +} + fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = text(a, 0)?; - let rep = convert_replacement(&text(a, 2)?); + let rep = template(&convert_replacement(&text(a, 2)?)); let occ = opt_int(a, 3, 0)?; let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?; + // Built match by match within a bound past which the text can only be too long (a character + // is at most four bytes), instead of building every replacement first (#2162). + let limit = MAX_TEXT * 4; + let mut out = String::new(); if occ == 0 { - // Match by match, giving up once the text can only be too long (a character is at most - // four bytes) rather than building every replacement first (#2162). - let mut out = String::new(); let mut last = 0; for cap in re.captures_iter(&s) { let Some(m) = cap.get(0) else { continue }; out.push_str(s.get(last..m.start()).unwrap_or_default()); - cap.expand(&rep, &mut out); + expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?; last = m.end(); - if out.len() > MAX_TEXT * 4 { - return Err(CellError::Value); - } } out.push_str(s.get(last..).unwrap_or_default()); return check_len(out); @@ -790,10 +853,9 @@ fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) }; let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) }; let Some(m) = cap.get(0) else { return text_val(s) }; - let mut out = String::new(); - out.push_str(&s[..m.start()]); - cap.expand(&rep, &mut out); - out.push_str(&s[m.end()..]); + out.push_str(s.get(..m.start()).unwrap_or_default()); + expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?; + out.push_str(s.get(m.end()..).unwrap_or_default()); check_len(out) } @@ -971,6 +1033,20 @@ pub(crate) fn specs() -> Vec { mod tests { use gridcraft_core::{CellError, Value}; + /// The pieces expand exactly as `regex::Captures::expand` does. + #[test] + fn template_expands_like_regex() { + let re = regex::Regex::new(r"(?\d+)-(\d+)").unwrap(); + let cap = re.captures("x 2026-10 y").unwrap(); + for rep in ["$1", "${1}", "$1a", "${1}a", "$y", "${y}!", "$$", "$$1", "a$", "$}", "${", "${}", "${y", "$0|$2|$9", "$-1", "€$1€", ""] { + let mut want = String::new(); + cap.expand(rep, &mut want); + let mut got = String::new(); + assert!(super::expand_capped(&cap, &super::template(rep), &mut got, usize::MAX).is_some()); + assert_eq!(got, want, "{rep:?}"); + } + } + use crate::util::testutil::*; #[test] From c39d2ad197e00fe4bc55572e075bf1702b1cab40 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:47:21 +0200 Subject: [PATCH 05/63] Keep '!' in structured-reference columns intact on XLSX save Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/printer.rs | 24 +++++++++++++++++++++++- crates/xlsx/src/fmla.rs | 12 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 055b0f145..e69351168 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String { write_struct_ref(s, false) } +/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets +/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`). +fn sheet_split(n: &str) -> Option<(&str, &str)> { + let mut depth = 0usize; + let mut escaped = false; + let mut at = None; + for (i, c) in n.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\'' if depth > 0 => escaped = true, + '[' => depth = depth.saturating_add(1), + ']' => depth = depth.saturating_sub(1), + '!' if depth == 0 => at = Some(i), + _ => {} + } + } + at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?))) +} + fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { @@ -178,7 +200,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => match n.rsplit_once('!') { + Expr::Name(n) => match sheet_split(n) { // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. Some((sheet, name)) => { s.push_str("e_sheet(sheet)); diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index bef43a5aa..c5e1f686c 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -183,6 +183,18 @@ mod tests { } } + #[test] + fn struct_ref_column_with_bang() { + // A `!` inside the brackets is part of the column name, not a sheet separator. + assert_eq!(to_file(&Formula::new("SUM(Table1[Done!])")), "SUM(Table1[Done!])"); + assert_eq!(text_to_file("=Table1[[#This Row],[a!b]]"), "Table1[[#This Row],[a!b]]"); + // `Table1[#REF!]` was found by the command-sequence fuzzer (seed 40). + for text in ["SUM(Table1[Done!])", "Table1[#REF!]", "Table1[@[x!]]", "Sheet2!A1+Table1[[#All],[a!b]]"] { + let f = Formula::new(text); + assert_eq!(from_file(&to_file(&f)), f.text); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); From 8307d3cb0526c7af66645cf9d7c79f01e77be632 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:25:54 +0000 Subject: [PATCH 06/63] Treat empty instance_num in TEXTBEFORE/TEXTAFTER as 1 Found while testing crates/functions/src/text.rs:553 Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 13 +++++++++++++ crates/functions/src/text.rs | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e5e287d38..622ed6cc9 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1768,3 +1768,16 @@ fn a_dynamic_array_of_one_value_still_anchors_a_spill() { t.set("E11", "=ROWS(D1#)"); assert_eq!(t.get("E11"), Value::Error(CellError::Ref)); } + +#[test] +fn text_before_after_empty_instance_defaults_to_one() { + let mut t = T::new(); + t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)"); + assert_eq!(t.get("A1"), Value::Text("Little ".into())); + t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")"); + assert_eq!(t.get("A2"), Value::Text("b".into())); + t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")"); + assert_eq!(t.get("A3"), Value::Text("nf".into())); + t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)"); + assert_eq!(t.get("A4"), Value::Error(CellError::Value)); +} diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 71a6ccb4b..baa86693a 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -5,8 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text}; use crate::criteria::wildcard_find; use crate::util::{ - A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, - text_val, + A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, int, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, + text, text_val, }; use crate::{Arg, Ctx, FnSpec, VAR}; @@ -550,7 +550,7 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end fn text_before_after(a: &[Arg], before: bool) -> R { let s = chars(&text(a, 0)?); let delims = delimiters(&arg(a, 1)?.value)?; - let inst = opt_int(a, 2, 1)?; + let inst = if has(a, 2) { int(a, 2)? } else { 1 }; let mode = opt_int(a, 3, 0)?; let match_end = opt_bool(a, 4, false)?; if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) { From 7ed494691cbd272c5dac69f768317fc699fcaa81 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:57:56 +0700 Subject: [PATCH 07/63] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2c224559c..d9dc5eb36 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ GridCraft icon: a spreadsheet page on a green tile +

+

GridCraft

From 56207f2d3cc55f5f0574415998ebcbaf61c90216 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 17:50:09 +0500 Subject: [PATCH 08/63] run --script: a line's unknown key stops the run (#2256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script line whose parameters sat under a key the runner does not read ran the command with no parameters at all, and the run still exited 0: {"command":"home.insertSheet","args":{"name":"Report"}} {"command":"home.insertSheet","name":"Report2"} made Sheet2 and Sheet3 instead of Report and Report2, and said nothing. A typo in a script produced a valid, wrong workbook. A line is read for `command` (or `id`) and `params`; any other key is a usage error now, naming the keys and the line, and the run stops before the workbook is written: gridcraft-cli: s.jsonl:2: unknown key `args`: a line is {"command": "id", "params": {…}} The usage text says what a line looks like, which is the other half of the report — the format was not written down anywhere, so the reporter found it by trying `args`, a flat object and `id` in turn. Tested at both levels: `parse_command` for the keys, their sorted list when there are several, and the forms that are read (`id`, `params`, `id={json}`, comments); and a run of a four-line script that stops at line 2 without writing the file, then the same script corrected, whose sheets come out named. Without the check the mistaken line parses again. An unknown key *inside* `params` is still ignored — the rest of #2256 — and needs the command's parameter list, which the engine does not expose yet. Noted on the issue. --- apps/gridcraft-cli/src/main.rs | 33 ++++++++++++++++++++++++++++ apps/gridcraft-cli/tests/cli.rs | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 0b763e378..972c023fa 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -43,6 +43,8 @@ const USAGE: &str = "usage: cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference) gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] + a script line is id={json} or {\"command\": \"id\", \"params\": {…}}; + blank lines and lines starting with # or // are skipped gridcraft-cli commands [--json] [--search X] gridcraft-cli functions [--json] [--search X] [--category C] gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] @@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result, String> { } if t.starts_with('{') { let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?; + // A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and + // `{"command": …, "name": "Report"}` ran the command with no parameters at all and the + // script still exited 0, so a typo made a valid, wrong workbook (#2256). + if let Some(o) = v.as_object() { + let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect(); + if !unknown.is_empty() { + unknown.sort_unstable(); + let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `")); + return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}")); + } + } let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?; return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({}))))); } @@ -567,6 +580,26 @@ mod tests { assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({})))); assert_eq!(parse_command(" # comment").unwrap(), None); assert!(parse_command("x={bad").is_err()); + // A key this does not read is a mistake: the command used to run with no parameters and + // the script still exited 0, so a typo made a valid, wrong workbook (#2256). + for (line, named) in [ + (r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"), + (r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"), + (r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"), + ] { + let e = parse_command(line).expect_err("refused"); + assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}"); + assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}"); + } + // Several at once are listed in one message, sorted. + let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused"); + assert!(e.contains("unknown keys `args`, `name`"), "{e}"); + // `id` is still the accepted alternative to `command`, and `params` still carries them. + assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({})))); + assert_eq!( + parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(), + Some(("home.insertSheet".into(), json!({"name": "R"}))) + ); } #[test] diff --git a/apps/gridcraft-cli/tests/cli.rs b/apps/gridcraft-cli/tests/cli.rs index 3105819ec..5295368e3 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/cli.rs @@ -206,3 +206,41 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() { assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit")))); let _ = std::fs::remove_dir_all(dir); } + +/// A script line whose parameters are under a key `run` does not read used to run the command +/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2, +/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256). +#[test] +fn a_script_line_with_an_unknown_key_stops_the_run() { + let dir = tmpdir("script-keys"); + let script = dir.join("s.jsonl"); + let out = dir.join("t.xlsx"); + let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap(); + + write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "the run must stop: {err}"); + assert!(err.contains("s.jsonl:2"), "it names the line: {err}"); + assert!(err.contains("unknown key `args`"), "and the key: {err}"); + assert!(!out.exists(), "and writes no workbook: {err}"); + + // The forms that are read still work, in one script: `params`, `id=json`, and a comment. + write(&[ + r#"{"command":"file.new"}"#, + r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#, + "# a comment", + r#"home.insertSheet={"name":"Report2"}"#, + ]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let info = ok(&["info", out.to_str().unwrap()]); + assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}"); + assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}"); + + // The usage text says what a line looks like, which is how this was found. + let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout); + assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}"); + + let _ = std::fs::remove_dir_all(&dir); +} From 843c9b84049738eaab5cf2fc71d22a31ea86100b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 09:50:06 +0200 Subject: [PATCH 09/63] ui: a too-wide number in a repeat-fill format shows #### A number too wide for its column is drawn as ####. With a repeat fill (`*` in the format, as in the Accounting format) the fill step then rebuilt the text from the formatted value, so it overflowed and, being right-aligned, lost its leading digits: 2,509.00 CHF in a narrow column was drawn as "509.00 CHF". The fill now leaves #### alone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 7 ++- crates/ui-egui/tests/accounting_fill.rs | 77 +++++++++++++++++++++++++ docs/ui-parity.md | 3 +- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/accounting_fill.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..87aaa3c73 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1268,13 +1268,16 @@ pub(crate) fn paint_quadrant( let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); let mut w = galley.size().x; // Numbers and dates never overflow: they show #### when too narrow. - if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { + let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; + if hashed { let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; } - // Repeat fill (`*` in number formats) / Fill alignment. + // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps + // it: rebuilding it from the text would overflow and clip its leading digits. if let Some((ch, pos)) = fill_char + && !hashed && pos <= first_line.len() { let base_w = w; diff --git a/crates/ui-egui/tests/accounting_fill.rs b/crates/ui-egui/tests/accounting_fill.rs new file mode 100644 index 000000000..492f581cd --- /dev/null +++ b/crates/ui-egui/tests/accounting_fill.rs @@ -0,0 +1,77 @@ +//! Repeat fill (`*` in a number format, as in the Accounting format): a number that fits is drawn +//! across its cell, and one too wide for its column shows #### instead of losing its leading digits. + +use egui::Shape; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const ACCOUNTING: &str = r#"_-* #,##0.00\ "CHF"_-;\-* #,##0.00\ "CHF"_-;_-* "-"??\ "CHF"_-;_-@_-"#; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B2", "values": [[344.75, 344.75], [2509, 2509]]})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1:B2", "style": {"num_fmt": ACCOUNTING}})).unwrap(); + // Column A is too narrow for 2,509.00 CHF; column B fits both. + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap(); + s.execute("selection.set", json!({"range": "H20"})).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 +} + +/// Painted text shapes whose text satisfies `f`: (text, left x, width). +fn texts(h: &Harness, f: impl Fn(&str) -> bool) -> Vec<(String, f32, f32)> { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if f(&t.galley.job.text) => Some((t.galley.job.text.clone(), t.pos.x, t.galley.size().x)), + _ => None, + }) + .collect() +} + +#[test] +fn too_wide_fill_number_shows_hashes_not_a_clipped_value() { + let h = harness(); + 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); + let a2 = geo.cell_rect(sheet, CellRef::parse("A2").unwrap()); + let b2 = geo.cell_rect(sheet, CellRef::parse("B2").unwrap()); + // A2 is drawn as #### inside its cell. + let hashes = texts(&h, |t| !t.is_empty() && t.chars().all(|c| c == '#')); + assert!(hashes.iter().any(|(_, x, w)| *x >= a2.left() && x + w <= a2.right() + 0.5), "A2 shows ####: {hashes:?}"); + // The only 2,509.00 drawn is B2's, whole and inside column B. + let values = texts(&h, |t| t.contains("509.00")); + assert_eq!(values.len(), 1, "{values:?}"); + let (text, x, w) = &values[0]; + assert!(text.contains("2,509.00 CHF"), "{text:?}"); + assert!(*x >= b2.left() && x + w <= b2.right() + 0.5, "B2's value stays in its cell: {values:?}"); +} + +#[test] +fn fill_spreads_a_fitting_number_across_its_cell() { + let h = harness(); + 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); + let b1 = geo.cell_rect(sheet, CellRef::parse("B1").unwrap()); + let drawn = texts(&h, |t| t.contains("344.75 CHF")); + let (_, x, w) = drawn.iter().find(|(_, x, _)| *x >= b1.left()).expect("B1 is drawn"); + // The fill pushes the value to the right edge (less the trailing `_-` space). + assert!(b1.right() - (x + w) < 6.0, "B1 ends at its right edge: {drawn:?} in {b1:?}"); + assert!(*x - b1.left() < 6.0, "the fill starts at the left edge: {drawn:?} in {b1:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..601e19d9d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | From 99f92f4a28e0c02d661050198499f5daeb6efece Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 18:05:53 +0500 Subject: [PATCH 10/63] range.setValues: `parse: false` stores a string as the text it is (#2255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every string in `values` went through typed input, which reads it with en-US rules, and nothing said so: "05.04.2026" (dd.mm.yyyy) → the date 4 May 2026 "02/11/2026" (dd/mm/yyyy) → the date 11 February 2026 "2.500" (thousands) → the number 2.5 "4.25 %" (text) → 0.0425, formatted 0.00% A workbook built from Turkish-format exports therefore held different values than the script put in it, and the only way to notice was to read the saved file back cell by cell. `parse: false` stores every string as the text it is. A formula is still a formula — `=` means a formula in a cell, and the ticket asks for that — and numbers, booleans and null are unaffected either way. A leading apostrophe is part of the text rather than the marker that makes it text, since nothing is being marked. The default is unchanged: `parse` is absent in every existing call, and those calls behave exactly as before. A `parse` that is neither true nor false is a usage error rather than a quiet default. The test pins both sides — the swapped date, the decimal point and the percentage under today's default; each of the six strings stored verbatim with `parse: false`, with `=1+1` still calculating 2; numbers and booleans either way; and the refused `parse` values. Without the change the first string comes back as a date. --- crates/engine/src/cmd/edit.rs | 62 ++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e53740712..67920264d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Set Values", [], None, - "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}", has_doc, range_set_values ), @@ -299,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let start = target_range(s, p)?.start; let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; + // A string is run through typed input by default, which reads it with en-US rules: a caller + // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence + // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas + // are unaffected either way. + let parse = match p.get("parse") { + None | Some(Json::Null) => true, + Some(Json::Bool(b)) => *b, + Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), + }; if rows.len() > MAX_ROWS as usize { return Err(bad("range.setValues", "too many rows")); } @@ -321,6 +330,14 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { Json::Bool(b) => { Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) } + // Text as given: no date or number reading, and a leading `'` is part of the + // text rather than the marker that makes it text. A formula is still a + // formula, which is what the ticket asks for and what `=` means in a cell. + Json::String(text) if !parse && !text.starts_with('=') => Some(Cell { + value: Value::Text(text.as_str().into()), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + }), _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; @@ -1769,6 +1786,49 @@ mod tests { assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); } + /// Every string in `values` went through typed input, read with en-US rules, in silence: a + /// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and + /// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. + #[test] + fn set_values_can_store_strings_as_the_text_they_are() { + let mut s = Session::new(); + s.new_workbook(); + let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; + let row: Vec = strings.iter().map(|v| json!(v)).collect(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + + // The default is unchanged: the dates swap, the thousands separator is a decimal point. + s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); + assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); + assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); + + // With `parse: false` each string is the text it was given, and a formula is still a + // formula. + s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); + for (i, want) in strings.iter().enumerate() { + let cell = format!("{}3", (b'A' + i as u8) as char); + let got = get(&mut s, &cell); + if *want == "=1+1" { + assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); + assert_eq!(got["formula"], "=1+1", "{got}"); + } else { + assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); + assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); + } + } + // Numbers and booleans are untouched either way. + s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); + assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); + assert_eq!(get(&mut s, "B5")["value"], json!(true)); + assert_eq!(get(&mut s, "C5")["text"], ""); + // `parse` is a flag, and anything else is a usage error rather than a quiet default. + s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); + assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + } + #[test] fn legacy_array_entry() { let mut s = Session::new(); From 840ba5c3a5107b642ad243800d53ef095702434b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 15:54:06 +0200 Subject: [PATCH 11/63] view: Show Outline Symbols (Ctrl+8), read and written A sheet can hide its outline's level buttons and brackets while keeping the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't modelled, so such sheets showed a wide outline band Excel doesn't. - model: Sheet::show_outline_symbols (default on, also for older JSON). - xlsx: read and written on the sheet view. - engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable. - ui: no outline bands while it's off. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 2 ++ crates/engine/src/tests.rs | 15 +++++++++++++++ crates/model/src/sheet.rs | 9 +++++++++ crates/ui-egui/src/grid.rs | 28 ++++++++++++++++++++++++---- crates/xlsx/src/sheet_read.rs | 1 + crates/xlsx/src/sheet_write.rs | 3 +++ crates/xlsx/src/tests/malformed.rs | 13 +++++++++++++ docs/ui-parity.md | 3 ++- 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index f0b2f77d3..b42aec9a6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -35,6 +35,7 @@ pub fn specs() -> Vec { cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")), cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")), cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), + cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), @@ -166,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { "gridlines" => &mut sh.show_gridlines, "headings" => &mut sh.show_headings, "zeros" => &mut sh.show_zeros, + "outlineSymbols" => &mut sh.show_outline_symbols, "rtl" => &mut sh.right_to_left, "printGridlines" => &mut sh.print.gridlines, _ => &mut sh.print.headings, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d6a99be91..81441c827 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2595,6 +2595,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() { assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook"); } +/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting. +#[test] +fn show_outline_symbols_toggles() { + let mut s = s(); + let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols; + assert!(on(&s)); + assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false})); + assert!(!on(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(on(&s)); + s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap(); + reopen(&mut s); + assert!(!on(&s)); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 1901d8048..f7ef8f3be 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -76,6 +76,10 @@ pub struct Sheet { pub show_headings: bool, pub show_formulas: bool, pub show_zeros: bool, + /// The outline's level buttons and brackets (Excel's Show Outline Symbols, Ctrl+8); the + /// outline itself stays. + #[serde(default = "yes")] + pub show_outline_symbols: bool, pub right_to_left: bool, pub zoom: u16, pub cond_formats: Vec, @@ -113,6 +117,10 @@ pub struct Sheet { pub preserved: Option>, } +fn yes() -> bool { + true +} + /// Default width of a column and height of a row (pixels at 96 dpi) for the default font. pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; @@ -140,6 +148,7 @@ impl Sheet { tab_color: None, visibility: Visibility::Visible, show_gridlines: true, + show_outline_symbols: true, show_headings: true, show_formulas: false, show_zeros: true, diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..78e558cce 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -235,13 +235,15 @@ impl Geo { let frozen_h = (ri.start(fr) as f32) * z; let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows)); let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols)); - let mut band_w = crate::outline::band(row_outline.levels, zoom); - let mut band_h = crate::outline::band(col_outline.levels, zoom); + // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). + let symbols = sh.show_outline_symbols; + let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; + let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; // Without headings the level buttons still need a corner to sit in. - if row_outline.levels > 0 && header_h == 0.0 { + if symbols && row_outline.levels > 0 && header_h == 0.0 { band_h = band_h.max(crate::outline::lane(zoom) + 3.0); } - if col_outline.levels > 0 && header_w == 0.0 { + if symbols && col_outline.levels > 0 && header_w == 0.0 { band_w = band_w.max(crate::outline::lane(zoom) + 3.0); } let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); @@ -4216,6 +4218,24 @@ mod pane_tests { assert!(range_in_pane(sel, (2, 20), (2, 10))); } + #[test] + fn hidden_outline_symbols_take_no_room() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let mut sh = Sheet::new("S"); + sh.rows.entry(3).or_default().outline = 1; + sh.cols.entry(2).or_default().outline = 2; + let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert!(shown.band_w > 0.0 && shown.band_h > 0.0); + sh.show_outline_symbols = false; + let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); + assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); + // Without headings too. + sh.show_headings = false; + let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + } + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. fn frozen_geo(sh: &mut Sheet) -> Geo { sh.freeze = Some((2, 2)); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 83955ee1f..5af3aeab3 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -274,6 +274,7 @@ fn read_views(e: &El, sheet: &mut Sheet) { sheet.show_headings = v.flag("showRowColHeaders", true); sheet.show_formulas = v.flag("showFormulas", false); sheet.show_zeros = v.flag("showZeros", true); + sheet.show_outline_symbols = v.flag("showOutlineSymbols", true); sheet.right_to_left = v.flag("rightToLeft", false); sheet.view = v.attr("view").and_then(gridcraft_model::SheetViewMode::parse).unwrap_or_default(); if let Some(z) = v.attr_u32("zoomScale") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index daac03daa..e0e863ec3 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -172,6 +172,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if !sheet.show_zeros { s.push_str(" showZeros=\"0\""); } + if !sheet.show_outline_symbols { + s.push_str(" showOutlineSymbols=\"0\""); + } if sheet.right_to_left { s.push_str(" rightToLeft=\"1\""); } diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index e746b01de..91b5d4b9c 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -330,3 +330,16 @@ fn ordinary_overlapping_column_ranges_keep_last_declaration() { assert!(last.hidden); assert_eq!(wb.styles.get(last.style.unwrap()).num_fmt.as_str(), "0.00"); } + +/// Show Outline Symbols off (`showOutlineSymbols="0"`) is read and written back. +#[test] +fn outline_symbols_flag_round_trips() { + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert!(!wb.sheet(0).unwrap().show_outline_symbols); + let (back, _) = read_xlsx(&crate::write_xlsx(&wb).unwrap()).unwrap(); + assert!(!back.sheet(0).unwrap().show_outline_symbols); + // Absent means shown. + let (wb, _) = read_xlsx(&minimal("", &[], "", "")).unwrap(); + assert!(wb.sheet(0).unwrap().show_outline_symbols); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..404875b5d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Show Outline Symbols, Ctrl+8; Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | From 5bcbaacce7a9de54c4bd2960f25998a2ecc6a8bb Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:02:58 +0200 Subject: [PATCH 12/63] Snapshot: catalog installed fonts before rendering The offscreen snapshot never started the background font scan the app runs from its first frame, so a workbook's installed fonts (Helvetica Neue, say) were drawn in the Calibri substitute. Catalog them up front and run a few more frames so they bind, as in the app. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/snapshot.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index dcee71173..9eb3cc69c 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -79,6 +79,9 @@ fn main() { } i += 1; } + // Catalog the installed fonts first (the app scans in the background from its first frame), so + // the workbook's fonts are drawn in their own faces, not a substitute. + let _ = gridcraft_ui_egui::fonts::catalog(); let mut session = Session::new(); if let Some(p) = &input { session.execute("file.open", json!({"path": p})).expect("open"); @@ -127,7 +130,9 @@ fn main() { app }, ); - harness.run_steps(8); + // The workbook's installed fonts bind once the font scan is done; frames past the first few + // apply them, so the snapshot shows them as the app does. + harness.run_steps(14); let img = harness.render().expect("render"); img.save(&out).expect("save png"); eprintln!("wrote {out} ({}x{})", img.width(), img.height()); From e6ffca60bb2356e87d64f94e6c59b2af626e64c5 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:09:29 +0200 Subject: [PATCH 13/63] view: hidden outline symbols answer no clicks; parity rows updated With headings hidden too, a level button sat across the empty column band at the grid's top edge, so a click there still ran it. Outline hit testing now stops when both bands are empty. The parity doc no longer lists Ctrl+8 as missing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 10 ++++++++++ crates/ui-egui/src/outline.rs | 4 ++++ docs/ui-parity.md | 6 +++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 78e558cce..78116b4ec 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -4234,6 +4234,16 @@ mod pane_tests { sh.show_headings = false; let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + // No button answers a click on the empty bands' edges (the grid's left and top), where the + // level buttons would sit with no headings to hold them. + for geo in [&hidden, &bare] { + for i in 0..400 { + let t = i as f32 * 0.5; + for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { + assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); + } + } + } } /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 61c40811c..19fd88497 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -224,6 +224,10 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { /// The button under `p` (logical coordinates), if any. pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option { + // Hidden symbols (or no outline) leave empty bands: a click on their edge is a cell click. + if geo.band_w <= 0.0 && geo.band_h <= 0.0 { + return None; + } let (rows, cols) = bands(geo); if !rows.contains(p) && !cols.contains(p) { return None; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 404875b5d..d35ed50b7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -60,7 +60,7 @@ this logic, with one undo step. | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). | -| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | +| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | @@ -135,9 +135,9 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+8, Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | outline symbols, Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | -| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | From 74f29a743f51292c9c418af12cc27bd4caaccbaa Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:18:45 +0200 Subject: [PATCH 14/63] docs: Ctrl+Shift+A (argument names) is no longer listed as missing Co-Authored-By: Claude Opus 5.5 --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d35ed50b7..0fd350aee 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -135,7 +135,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | From b5b3969377b125cd782768ace8cfbaee06f57bc9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 22 Oct 2026 12:43:48 -0400 Subject: [PATCH 15/63] scripts: trust gate review fixes (#2178) - automate.trustScripts: only a missing `trusted` defaults to true; a non-boolean value is an error and leaves the workbook untrusted. - The refusal message points at Trust Scripts in View > Macros (Alt+F8) and at automate.trustScripts. - Catalog row for automate.trustScripts; parity checklist regenerated. - Script steps run inside an io::ScriptSandbox (thread-local) so read_file/write_file refuse whatever the parameter is called, and a step that raises OpenUrl is dropped and stops the script. The id/param deny list stays as the up-front refusal. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/extra.rs | 28 ++++++++++++--- crates/engine/src/io.rs | 62 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 38 +++++++++++++++++++++ docs/parity-checklist.md | 7 ++-- 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6b67e1b4..007cbf4bc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("automate.newScript", "Automate", "Scripting Tools", "New Script"), ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), + ("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"), // Table Design ("table.rename", "Table Design", "Properties", "Table Name"), ("table.resize", "Table Design", "Properties", "Resize Table"), diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b2d8c352d..f248c59f2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1017,7 +1017,10 @@ pub(crate) fn has_scripts(wb: &Workbook) -> bool { /// Why a script step is refused, whatever the workbook's trust: scripts stay inside the /// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, /// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run -/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. fn denied_script_step(id: &str, params: &Json) -> Option { if id.starts_with("file.") || id.starts_with("link.") { return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); @@ -1029,7 +1032,12 @@ fn denied_script_step(id: &str, params: &Json) -> Option { } fn trust_scripts(s: &mut Session, p: &Json) -> Result { - let trusted = bool_param(p, "trusted").unwrap_or(true); + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; s.doc_mut()?.scripts_trusted = trusted; Ok(json!({"trusted": trusted})) } @@ -1045,7 +1053,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { return Err(bad( "automate.runScript", - "scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", )); } let steps: Vec = steps.into_iter().take(100_000).collect(); @@ -1055,13 +1063,25 @@ fn run_script(s: &mut Session, p: &Json) -> Result { return Err(bad("automate.runScript", why)); } } + let _sandbox = crate::io::ScriptSandbox::enter(); let mut n = 0; for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { continue; } - s.execute(id, st["params"].clone())?; + let before = s.ui_requests.len(); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + ran?; n += 1; } Ok(json!({"ran": n})) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c0bf800b..b18e8577b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -556,8 +556,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { Ok(out) } +thread_local! { + /// How many [`ScriptSandbox`]es are alive on this thread. + static SCRIPT_SANDBOX: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a +/// stored script's steps run inside one (#2178), so no command reaches the file system from a +/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal. +pub(crate) struct ScriptSandbox(()); + +impl ScriptSandbox { + pub(crate) fn enter() -> Self { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1))); + ScriptSandbox(()) + } +} + +impl Drop for ScriptSandbox { + fn drop(&mut self) { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1))); + } +} + +/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the +/// thread is being torn down). +fn script_sandbox_check(path: &str) -> Result<()> { + if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) { + return Err(EngineError::Other(format!("{path}: scripts can't read or write files"))); + } + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; if meta.len() > 2 * 1024 * 1024 * 1024 { return Err(EngineError::Other("files larger than 2 GB aren't supported".into())); @@ -567,11 +600,13 @@ pub fn read_file(path: &str) -> Result> { #[cfg(target_arch = "wasm32")] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; // Write to a temporary file, then rename: a crash never leaves a half-written workbook. let tmp = format!("{path}.sctmp"); std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| { @@ -584,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(target_arch = "wasm32")] pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } @@ -902,6 +938,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// While a script runs (#2178), file reads and writes are refused whatever command or + /// parameter asked for them; nesting and dropping the sandbox restore access. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 567148d41..dcab1d204 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1147,6 +1147,44 @@ fn untrusted_workbook_scripts_are_refused_until_trusted() { assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); } +#[test] +fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() { + let mut s = opened_with_script(); + // A `trusted` that isn't a boolean is an error and leaves the workbook untrusted. + for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] { + let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string(); + assert!(err.contains("trusted"), "{wrong}: {err}"); + assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook"); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + } + // The refusal names where the Trust Scripts button actually is. + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}"); + assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false); + assert!(!s.doc().unwrap().scripts_trusted); + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + assert!(s.doc().unwrap().scripts_trusted); +} + +#[test] +fn scripts_leave_file_access_working_after_they_run() { + // The sandbox a script runs in is lifted afterwards, also when a step fails. + let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, b"a,b\n1,2\n").unwrap(); + let csv = csv.to_string_lossy().to_string(); + let mut s = s(); + let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}}); + s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err()); + s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Ok"})).unwrap(); + s.execute("data.fromTextCsv", json!({"path": csv})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() { let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id())); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169..ee8bcfb75 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 291 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -17,7 +17,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | -| Automate | 3 | 3 | 100% | +| Automate | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | @@ -324,13 +324,14 @@ Implemented: - `view.switchWindows` Switch Windows (Window) - `view.macros` Macros (Macros) -## Automate (3 / 3, 100%) +## Automate (4 / 4, 100%) Implemented: - `automate.newScript` New Script (Scripting Tools) - `automate.allScripts` All Scripts (Scripting Tools) - `automate.recordActions` Record Actions (Scripting Tools) +- `automate.trustScripts` Trust Scripts (Scripting Tools) ## Table Design (13 / 13, 100%) From 9cbb674c550e1bdca3a2be9a008e8a455b00f3f8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:34:06 +0200 Subject: [PATCH 16/63] Print numbers near f64::MAX in a form that parses back Found while testing crates/formula/src/printer.rs:134 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/printer.rs | 11 ++++++++++- crates/formula/tests/printer_roundtrip.rs | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 crates/formula/tests/printer_roundtrip.rs diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 055b0f145..ba659c188 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -132,7 +132,16 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { } fn number(n: f64) -> String { - number_to_text(n) + let text = number_to_text(n); + // 15 significant digits can round past f64::MAX, which no longer parses. + if text.parse::().is_ok_and(f64::is_finite) { + return text; + } + let exact = format!("{:E}", n); + match exact.split_once('E') { + Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"), + _ => exact, + } } /// Prints an expression in A1 notation. diff --git a/crates/formula/tests/printer_roundtrip.rs b/crates/formula/tests/printer_roundtrip.rs new file mode 100644 index 000000000..206e5efff --- /dev/null +++ b/crates/formula/tests/printer_roundtrip.rs @@ -0,0 +1,15 @@ +use gridcraft_formula::{Expr, parse, print}; + +#[test] +fn number_near_f64_max_round_trips() { + for src in ["=1.7976931348623157E308", "=-1.7976931348623157E308"] { + let e = parse(src).unwrap(); + let printed = print(&e); + let back = parse(&printed).unwrap_or_else(|err| panic!("{printed}: {err:?}")); + assert_eq!(print(&back), printed); + match (&e, &back) { + (Expr::Number(a), Expr::Number(b)) => assert_eq!(a, b), + _ => assert_eq!(e, back), + } + } +} From a8e2ed31b110bf60e70ba7c860ab798fbd28ffe9 Mon Sep 17 00:00:00 2001 From: coolvitto <160416811+coolvitto@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:05:42 +0900 Subject: [PATCH 17/63] Add extra Japanese translations for UI elements Added extra labels for ribbon menus and pop-ups in Japanese. --- crates/ui-egui/src/i18n/ja.rs | 164 +++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 254ff68f9..09461b4bd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -980,10 +980,160 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Unprotect Sheet…", "シート保護の解除…"), ]; +/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the +/// Portuguese catalog has no entry for them either). Kept apart so the two catalogs above keep the +/// same key set; it never overlaps them. +pub(super) const EXTRA: &[(&str, &str)] = &[ + ("Cut (⌘X)", "切り取り (⌘X)"), + ("Copy (⌘C)", "コピー (⌘C)"), + ("Bold (⌘B)", "太字 (⌘B)"), + ("Italic (⌘I)", "斜体 (⌘I)"), + ("Underline (⌘U)", "下線 (⌘U)"), + ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), + ("Filter (⌘⇧F)", "フィルター (⌘⇧F)"), + ("Flash Fill (⌘E)", "フラッシュ フィル (⌘E)"), + ("Insert Link (⌘K)", "リンクの挿入 (⌘K)"), + ("Paste (⌘V)", "貼り付け (⌘V)"), + ("Redo (⌘Y)", "やり直し (⌘Y)"), + ("Save (⌘S)", "上書き保存 (⌘S)"), + ("Search commands (⌘⇧Space)", "コマンドの検索 (⌘⇧Space)"), + ("Table (⌘T)", "テーブル (⌘T)"), + ("Undo (⌘Z)", "元に戻す (⌘Z)"), + ("New Workbook", "新しいブック"), + ("Save As…", "名前を付けて保存…"), + ("Print…", "印刷…"), + ("Export as PDF…", "PDF としてエクスポート…"), + ("Save a Copy as Excel Workbook (.xlsx)…", "Excel ブック (.xlsx) としてコピーを保存…"), + ("Export as CSV…", "CSV としてエクスポート…"), + ("Export as Web Page (.html)…", "Web ページ (.html) としてエクスポート…"), + ("Bottom Border", "下罫線"), + ("Top Border", "上罫線"), + ("Left Border", "左罫線"), + ("Right Border", "右罫線"), + ("No Border", "枠なし"), + ("All Borders", "格子"), + ("Outside Borders", "外枠"), + ("Thick Outside Borders", "太い外枠"), + ("Bottom Double Border", "下二重罫線"), + ("Thick Bottom Border", "下太罫線"), + ("Top and Bottom Border", "上下罫線"), + ("Top and Thick Bottom Border", "上罫線 + 下太罫線"), + ("Top and Double Bottom Border", "上罫線 + 下二重罫線"), + ("Inside Borders", "内側罫線"), + ("More Borders…", "その他の罫線…"), + ("More Functions…", "その他の関数…"), + ("No Fill", "塗りつぶしなし"), + ("Theme Colors", "テーマの色"), + ("Standard Colors", "標準の色"), + ("Highlight Cells Rules", "セルの強調表示ルール"), + ("Greater Than…", "指定の値より大きい…"), + ("Less Than…", "指定の値より小さい…"), + ("Between…", "指定の範囲内…"), + ("Equal To…", "指定の値に等しい…"), + ("Text that Contains…", "文字列…"), + ("A Date Occurring…", "日付…"), + ("Duplicate Values…", "重複する値…"), + ("Top/Bottom Rules", "上位/下位ルール"), + ("Blue", "青"), + ("Green", "緑"), + ("Red", "赤"), + ("Orange", "オレンジ"), + ("Light Blue", "水色"), + ("Purple", "紫"), + ("Green - Yellow - Red", "緑 - 黄 - 赤"), + ("Red - Yellow - Green", "赤 - 黄 - 緑"), + ("Green - White - Red", "緑 - 白 - 赤"), + ("Blue - White - Red", "青 - 白 - 赤"), + ("White - Green", "白 - 緑"), + ("White - Red", "白 - 赤"), + ("3 Arrows", "3 つの矢印"), + ("3 Traffic Lights", "3 つの信号"), + ("3 Symbols", "3 つの記号"), + ("4 Ratings", "4 段階評価"), + ("5 Arrows", "5 つの矢印"), + ("5 Quarters", "5 つの四分円"), + ("New Rule…", "新しいルール…"), + ("Clear Rules", "ルールのクリア"), + ("Manage Rules…", "ルールの管理…"), + ("Good, Bad and Neutral", "良い、悪い、どちらでもない"), + ("Bad", "悪い"), + ("Good", "良い"), + ("Neutral", "どちらでもない"), + ("Data and Model", "データとモデル"), + ("Calculation", "計算"), + ("Check Cell", "チェック セル"), + ("Explanatory Text", "説明文"), + ("Input", "入力"), + ("Linked Cell", "リンク セル"), + ("Note", "メモ"), + ("Output", "出力"), + ("Warning Text", "警告文"), + ("Titles and Headings", "タイトルと見出し"), + ("Heading 1", "見出し 1"), + ("Heading 2", "見出し 2"), + ("Heading 3", "見出し 3"), + ("Heading 4", "見出し 4"), + ("Total", "集計"), + ("Themed Cell Styles", "テーマのセルのスタイル"), + ("Number Format", "表示形式"), + ("Comma [0]", "桁区切り [0]"), + ("Currency [0]", "通貨表示形式 [0]"), + ("Percent", "パーセント"), + ("Place in Cell", "セル内に配置"), + ("Place over Cells", "セルの上に配置"), + ("Rectangle", "四角形"), + ("Rounded Rectangle", "角丸四角形"), + ("Oval", "楕円"), + ("Triangle", "三角形"), + ("Arrow", "矢印"), + ("Column", "縦棒"), + ("Win/Loss", "勝敗"), + ("Type to filter…", "入力して絞り込み…"), + ("Sort Ascending", "昇順"), + ("Sort Descending", "降順"), + ("Search", "検索"), + ("Apply", "適用"), + ("20% - Accent1", "20% - アクセント 1"), + ("20% - Accent2", "20% - アクセント 2"), + ("20% - Accent3", "20% - アクセント 3"), + ("20% - Accent4", "20% - アクセント 4"), + ("20% - Accent5", "20% - アクセント 5"), + ("20% - Accent6", "20% - アクセント 6"), + ("40% - Accent1", "40% - アクセント 1"), + ("40% - Accent2", "40% - アクセント 2"), + ("40% - Accent3", "40% - アクセント 3"), + ("40% - Accent4", "40% - アクセント 4"), + ("40% - Accent5", "40% - アクセント 5"), + ("40% - Accent6", "40% - アクセント 6"), + ("60% - Accent1", "60% - アクセント 1"), + ("60% - Accent2", "60% - アクセント 2"), + ("60% - Accent3", "60% - アクセント 3"), + ("60% - Accent4", "60% - アクセント 4"), + ("60% - Accent5", "60% - アクセント 5"), + ("60% - Accent6", "60% - アクセント 6"), + ("Accent1", "アクセント 1"), + ("Accent2", "アクセント 2"), + ("Accent3", "アクセント 3"), + ("Accent4", "アクセント 4"), + ("Accent5", "アクセント 5"), + ("Accent6", "アクセント 6"), + ("Recent Colors", "最近使用した色"), + ("More Colors…", "その他の色…"), + ("Hex", "16進"), + ("Page Break Preview", "改ページ プレビュー"), + ("Bubble", "バブル"), + ("Box & Whisker", "箱ひげ図"), + ("Treemap", "ツリーマップ"), + ("Sunburst", "サンバースト"), + ("Stock", "株価"), + ("Bar", "横棒"), + ("Drag to fill cells. Drag from inside a cell to select a range.", "ドラッグしてセルを塗りつぶします。セルの内側からドラッグすると範囲を選択します。"), +]; + /// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can /// fall back to English. Dialog strings come first, the shared core second. pub(super) fn translate(text: &str) -> Option<&'static str> { - DIALOGS.iter().chain(CORE).find(|(english, _)| *english == text).map(|(_, translated)| *translated) + DIALOGS.iter().chain(CORE).chain(EXTRA).find(|(english, _)| *english == text).map(|(_, translated)| *translated) } #[cfg(test)] @@ -1088,6 +1238,18 @@ mod tests { assert_eq!(catalog_keys().count(), 954); } + #[test] + fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { + let catalog: HashSet<&str> = catalog_keys().collect(); + let mut seen = HashSet::new(); + for (english, japanese) in EXTRA { + assert!(!japanese.trim().is_empty(), "empty translation for {english:?}"); + assert!(!catalog.contains(english), "{english:?} is already in CORE or DIALOGS"); + assert!(seen.insert(*english), "duplicate EXTRA key: {english:?}"); + assert_eq!(translate(english), Some(*japanese)); + } + } + #[test] fn covers_every_key_the_portuguese_catalog_translates() { let portuguese = portuguese_keys(); From 09f78cb3a8dd116d524b3850608312c8e062be89 Mon Sep 17 00:00:00 2001 From: "D. Fender" <26173007+postfen@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:12:09 -0600 Subject: [PATCH 18/63] Tables: Format as Table inside a table restyles it Home > Format as Table on a cell or range inside an existing table failed with "A table can't overlap another table." Excel applies the picked style to that table instead. The command now returns that table and its range, as it does for a new table. A range crossing a table's edge is still rejected. Without a range, the active area of the selection decides, as it does for Insert > Table. The native Format menu's Format as Table now runs home.formatAsTable instead of insert.table. --- apps/gridcraft/src/native_menu.rs | 2 +- crates/engine/src/cmd/insert.rs | 19 +++++- .../engine/tests/format_as_table_restyle.rs | 61 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/format_as_table_restyle.rs diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0..7dc549cb7 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Unhide Sheet…", "sheet.unhide", None), ("-", "", None), ("Conditional Formatting…", "dialog:manageRules", None), - ("Format as Table", "insert.table", None), + ("Format as Table", "home.formatAsTable", None), ], ), ( diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index aae5a9faa..2388d1f25 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -26,9 +26,9 @@ pub fn specs() -> Vec { "Format as Table", ["Home", "Styles"], None, - "{range?, style: \"TableStyleMedium2\", header?}", + "{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)", has_doc, - insert_table + format_as_table ), cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag( s, p, "totals" @@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { }) } +/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one. +fn format_as_table(s: &mut Session, p: &Json) -> Result { + // Without a range, the active area decides (as in insert_table), not the first one. + let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() }; + let sheet = target_sheet(s, p)?; + let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range)); + match inside { + Some((table, range)) => { + table_style(s, &with(p, "table", json!(table)))?; + Ok(json!({"table": table, "range": range.a1()})) + } + None => insert_table(s, p), + } +} + fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; if let Some(n) = str_param(p, "table") { diff --git a/crates/engine/tests/format_as_table_restyle.rs b/crates/engine/tests/format_as_table_restyle.rs new file mode 100644 index 000000000..0419027c6 --- /dev/null +++ b/crates/engine/tests/format_as_table_restyle.rs @@ -0,0 +1,61 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn table() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap(); + s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap(); + s +} + +fn styles(s: &Session) -> Vec { + s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect() +} + +#[test] +fn format_as_table_inside_a_table_restyles_it() { + let mut s = table(); + s.execute("selection.set", json!({"range":"B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} + +#[test] +fn format_as_table_on_a_range_inside_a_table_restyles_it() { + let mut s = table(); + s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleDark3"]); +} + +#[test] +fn format_as_table_across_a_table_edge_is_rejected() { + let mut s = table(); + let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err(); + assert!(e.to_string().contains("overlap"), "{e}"); + assert_eq!(styles(&s), ["TableStyleMedium2"]); +} + +#[test] +fn format_as_table_inside_a_table_returns_that_table() { + let mut s = table(); + let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap(); + assert_eq!(r, json!({"table":"T", "range":"A1:B3"})); +} + +#[test] +fn format_as_table_uses_the_active_area_of_a_multi_area_selection() { + let mut s = table(); + s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} + +#[test] +fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() { + let mut s = table(); + s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap(); + s.execute("selection.set", json!({"range":"B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} From 73af8c6a6dab27e9d19666ac33e16631a7570132 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:38:06 -0400 Subject: [PATCH 19/63] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e041aafbc..abc472008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.10.0" +version = "0.11.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.10.0" +version = "0.11.0" [[package]] name = "gridcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.10.0" +version = "0.11.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e8a6dc67e..11e52c683 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 10fb7d566eb8f1afe215117349881e1b6be78ca0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 16:16:17 -0400 Subject: [PATCH 20/63] macOS packaging: retry stapler when Apple's CloudKit drops the connection stapler staple/validate fetch the notarization ticket from CloudKit, which sometimes fails with "The network connection was lost" (Error 68). That failed this release's macOS job twice in a row. Both steps are idempotent, so retry up to 5 times with backoff, like hdiutil_retry. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b128c3c7f..d8b4b097b 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,20 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The +# network connection was lost", Error 68). Stapling and validating are idempotent, so retry. +stapler_retry() { + local attempt + for attempt in 1 2 3 4 5; do + if xcrun stapler "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; } + warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + sleep $((attempt * 15)) + done +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -120,8 +134,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [ "$NOTARIZE" = 1 ]; then ditto -c -k --keepParent "$APP" "$WORK/GridCraft-notarize.zip" notarize "$WORK/GridCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -146,8 +160,8 @@ sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi From ac8da7d49cbeb4be6aa1ad5869f3d29fe2da8cd9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 16:16:52 -0400 Subject: [PATCH 21/63] Release 0.11.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abc472008..c7e3560b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.11.0" +version = "0.11.1" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.11.0" +version = "0.11.1" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.11.0" +version = "0.11.1" [[package]] name = "gridcraft-ui-egui" -version = "0.11.0" +version = "0.11.1" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.11.0" +version = "0.11.1" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.11.0" +version = "0.11.1" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 11e52c683..bf8eb381e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From d046ba6aa73cd1b5ce2c7e3f1c8e89e0e5dcba68 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:44:34 +0200 Subject: [PATCH 22/63] Hyperlinks: Ctrl+click on a link follows it instead of extending the selection (#1198) A Ctrl+click went to the add-to-selection branch, so the link under the pointer was never followed. It now goes through follow_link like a plain click (asks before opening web addresses, jumps to references). --- crates/ui-egui/src/grid.rs | 10 ++- crates/ui-egui/tests/hyperlink_ctrl_click.rs | 70 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/hyperlink_ctrl_click.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..386f151a3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2364,7 +2364,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - select_ranges(app, RangeRef::cell(c), true, c); + // Ctrl+click 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 + && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 + { + follow_link(app, &h.target); + } else { + select_ranges(app, RangeRef::cell(c), true, c); + } } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { diff --git a/crates/ui-egui/tests/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/hyperlink_ctrl_click.rs new file mode 100644 index 000000000..34e110225 --- /dev/null +++ b/crates/ui-egui/tests/hyperlink_ctrl_click.rs @@ -0,0 +1,70 @@ +//! Ctrl+click on a hyperlink cell follows the link (#1198), like a plain click does. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "B2", "input": "web"})).unwrap(); + session.execute("insert.link", json!({"cell": "B2", "target": "https://example.org/a"})).unwrap(); + session.execute("cell.set", json!({"cell": "B4", "input": "jump"})).unwrap(); + session.execute("insert.link", json!({"cell": "B4", "target": "Sheet1!F9"})).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(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn left_of_cell(h: &Harness, 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); + let r = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()); + egui::pos2(r.left() + r.width() * 0.3, r.center().y) +} + +fn click(h: &mut Harness, cell: &str, modifiers: Modifiers) { + let pos = left_of_cell(h, cell); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }, + ]); + h.run_steps(3); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +#[test] +fn ctrl_click_on_a_web_link_asks_to_open_it() { + let mut h = harness(); + click(&mut h, "B2", Modifiers::COMMAND); + let d = h.state().dialog.as_ref().expect("Ctrl+click on a web link opens the confirmation"); + assert_eq!(d.name, "openLink"); +} + +#[test] +fn ctrl_click_on_an_internal_link_goes_there() { + let mut h = harness(); + click(&mut h, "B4", Modifiers::COMMAND); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); +} + +#[test] +fn plain_click_still_follows() { + let mut h = harness(); + click(&mut h, "B4", Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); +} From 6febf5442f747e32a4b332c967340ab7e23fcba4 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:25:21 +0200 Subject: [PATCH 23/63] Fill handle: Auto Fill Options button after a drag offers Copy Cells, Fill Series and the formatting modes (#1403) A left-drag of the fill handle filled at once and offered no choice. A button now appears below the filled range; its menu undoes that fill and reruns edit.autoFill in the chosen mode. It goes away when the workbook changes or the selection moves. --- crates/ui-egui/src/grid.rs | 105 +++++++++-- crates/ui-egui/tests/fill_options_button.rs | 182 ++++++++++++++++++++ 2 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/fill_options_button.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..e8250dde4 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -102,6 +102,19 @@ pub enum Drag { }, } +/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo +/// another way. It only applies while nothing else changed the workbook (`revision`) and the +/// filled cells are still selected. +#[derive(Clone, Debug, PartialEq)] +pub struct FillTag { + pub src: RangeRef, + pub target: RangeRef, + pub uid: u64, + pub sheet: usize, + pub revision: u64, + pub undo_len: usize, +} + #[derive(Default)] pub struct GridState { pub drag: Drag, @@ -120,6 +133,8 @@ pub struct GridState { pub context_menu_frame: u64, /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, + /// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells. + pub fill_tag: Option, /// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id. pub object_menu: Option<(Pos2, &'static str, u32)>, pub marching_phase: f32, @@ -790,6 +805,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + fill_options_tag(app, ui, &geo, sh); object_menu(app, ui); error_check_tag(app, ui, &geo, sh); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; @@ -2533,8 +2549,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. let mode = if mods.command { "alternate" } else { "series" }; - if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), } } } @@ -3317,6 +3334,81 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. +const FILL_MODES: [(&str, &str); 4] = + [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; + +/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. +fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() }); +} + +/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill +/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only +/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// The button goes away as soon as anything else changes the workbook or the selection moves. +fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + let Some(tag) = app.grid.fill_tag.clone() else { return }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + 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) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + Stroke::NONE, + )); + let mut chosen = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(190.0); + for (label, mode) in FILL_MODES { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + chosen = Some(mode); + ui.close(); + } + } + }); + if let Some(mode) = chosen { + let _ = app.run("edit.undo", json!({})); + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + /// The menu a right-drag of the fill handle opens on release, as in Excel. fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((src, target, at)) = app.grid.fill_menu else { return }; @@ -3324,14 +3416,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items = [ - ("Copy Cells", "copy"), - ("Fill Series", "extend"), - ("Fill Formatting Only", "formats"), - ("Fill Without Formatting", "values"), - ("-", ""), - ("Series…", "ui:series"), - ]; + let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; let lang = crate::i18n::current(ui.ctx()); for (label, mode) in items { if label == "-" { diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs new file mode 100644 index 000000000..35c314707 --- /dev/null +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -0,0 +1,182 @@ +//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells, +//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403). + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + setup(&mut session); + 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 +} + +/// B1:B2 holds 1, 2 and is selected. +fn one_two(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +fn corner(h: &Harness, 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()).right_bottom() - vec2(1.0, 1.0) +} + +fn center(h: &Harness, 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 press(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn move_to(h: &mut Harness, from: Pos2, to: Pos2) { + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } +} + +fn release(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`. +fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) { + let (start, end) = (corner(h, from), center(h, to)); + press(h, start, button); + move_to(h, start, end); + release(h, end, button); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn values(h: &Harness, cells: &[&str]) -> Vec { + cells.iter().map(|c| value(h, c)).collect() +} + +fn bold(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold +} + +fn italic(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn left_drag_shows_the_auto_fill_options_button() { + let mut h = harness(one_two); + assert!(h.query_by_label("Auto Fill Options").is_none()); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B6"]), [n(3.0), n(6.0)]); + assert!(h.query_by_label("Auto Fill Options").is_some()); +} + +#[test] +fn copy_cells_replaces_the_series_the_drag_made() { + let mut h = harness(one_two); + let steps_before = h.state().session.active().unwrap().undo.len(); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Copy Cells").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]); + assert_eq!(selection(&h), "B1:B6"); + assert_eq!(h.state().session.active().unwrap().undo.len(), steps_before + 1, "one fill step, not two"); +} + +#[test] +fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B4", PointerButton::Primary); + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(5.0), n(5.0), n(5.0)]); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Series").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); +} + +fn formatted(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +#[test] +fn fill_without_formatting_keeps_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Without Formatting").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]); + assert!(!bold(&h, "B3") && !bold(&h, "B4")); + assert!(italic(&h, "B4")); +} + +#[test] +fn the_button_goes_away_once_the_selection_moves() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + let away = center(&h, "E9"); + h.input_mut().events.push(Event::PointerMoved(away)); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + + assert!(h.query_by_label("Auto Fill Options").is_none()); +} + +#[test] +fn right_drag_makes_no_button() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.query_by_label("Auto Fill Options").is_none()); +} From d19f65601427d4aacf522d95e57169b6b89c088a Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:17:38 +0200 Subject: [PATCH 24/63] Fill handle: test the Auto Fill Options button's workbook-changed rule, report an undo error, document it (#1403) --- crates/ui-egui/src/grid.rs | 6 +++++- crates/ui-egui/tests/fill_options_button.rs | 12 ++++++++++++ docs/ui-parity.md | 5 +++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index e8250dde4..ab5874111 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3398,7 +3398,11 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet } }); if let Some(mode) = chosen { - let _ = app.run("edit.undo", json!({})); + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { Ok(_) => set_fill_tag(app, tag.src, tag.target), Err(e) => { diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs index 35c314707..8d5a21a18 100644 --- a/crates/ui-egui/tests/fill_options_button.rs +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -180,3 +180,15 @@ fn right_drag_makes_no_button() { drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); assert!(h.query_by_label("Auto Fill Options").is_none()); } + +#[test] +fn the_button_goes_away_once_the_workbook_changes() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + h.state_mut().session.execute("cell.set", json!({"cell": "D1", "input": "x"})).unwrap(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:B6", "the selection did not move"); + assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..f1c7d4d37 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Auto Fill Options button after a left-drag of the fill handle (#1403); Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -50,7 +50,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | From 60776ff4de75da9fc05633aa7fbea0a301cd1b26 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:23:37 +0200 Subject: [PATCH 25/63] Hyperlinks: test that Ctrl+click beside the link text still extends the selection (#1198) --- crates/ui-egui/tests/hyperlink_ctrl_click.rs | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/hyperlink_ctrl_click.rs index 34e110225..2e557e12e 100644 --- a/crates/ui-egui/tests/hyperlink_ctrl_click.rs +++ b/crates/ui-egui/tests/hyperlink_ctrl_click.rs @@ -26,16 +26,20 @@ fn harness() -> Harness { h } -fn left_of_cell(h: &Harness, cell: &str) -> Pos2 { +fn at_fraction(h: &Harness, cell: &str, fraction: f32) -> 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); let r = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()); - egui::pos2(r.left() + r.width() * 0.3, r.center().y) + egui::pos2(r.left() + r.width() * fraction, r.center().y) } fn click(h: &mut Harness, cell: &str, modifiers: Modifiers) { - let pos = left_of_cell(h, cell); + click_at(h, cell, 0.3, modifiers); +} + +fn click_at(h: &mut Harness, cell: &str, fraction: f32, modifiers: Modifiers) { + let pos = at_fraction(h, cell, fraction); h.input_mut().events.push(Event::ModifiersChanged(modifiers)); h.input_mut().events.extend([ Event::PointerMoved(pos), @@ -68,3 +72,14 @@ fn plain_click_still_follows() { click(&mut h, "B4", Modifiers::NONE); assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); } + +#[test] +fn ctrl_click_beside_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.9, 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"], "the hyperlink cell joins the selection"); + assert!(h.state().dialog.is_none(), "no link was followed"); +} From 7c1ec1df7dbd27cacccc9b8333cb361a939f3d42 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:37:49 +0200 Subject: [PATCH 26/63] Fill handle: test the Auto Fill Options button on a right-to-left sheet, merge the duplicate fill handle row (#1403) --- crates/ui-egui/tests/fill_options_button.rs | 21 +++++++++++++++++++-- docs/ui-parity.md | 3 +-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs index 8d5a21a18..2cb209fa9 100644 --- a/crates/ui-egui/tests/fill_options_button.rs +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -35,14 +35,14 @@ fn corner(h: &Harness, 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()).right_bottom() - vec2(1.0, 1.0) + geo.mirror_pos(geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0)) } fn center(h: &Harness, 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() + geo.screen_cell_rect(sheet, CellRef::parse(cell).unwrap()).center() } fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { @@ -192,3 +192,20 @@ fn the_button_goes_away_once_the_workbook_changes() { assert_eq!(selection(&h), "B1:B6", "the selection did not move"); assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it"); } + +#[test] +fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() { + let mut h = harness(|s| { + one_two(s); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + let button = h.get_by_label("Auto Fill Options").rect(); + 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); + assert!(geo.cells.contains_rect(button), "{button:?} outside {:?}", geo.cells); + 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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f1c7d4d37..cf0ab377d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,8 +50,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | From 8eda57754596533dc473436478e81b37a31f5737 Mon Sep 17 00:00:00 2001 From: RedBearAK <64876997+RedBearAK@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:27:56 -0800 Subject: [PATCH 27/63] Page Up into frozen rows scrolls the rows below back to the top With row 1 frozen, Page Up could land on A1, which is always on screen, while the scrolled rows stayed a page down (row 10, Page Down, Page Up, Page Up ends on row 1 with rows 2-9 hidden). The same held for Ctrl+Up into frozen rows and Ctrl+Left / Alt+Page Up into frozen columns. Arrow and paging keys now record the cell they moved from (`GridState::reveal_from`). When such a move goes from the scrolled rows (columns) into the frozen ones, the scrolled area goes back to its top (left). Other reveals (clicks, Find, Go To, Name Box, Undo, the control channel, formula pointing) leave it unset and keep the view where the user scrolled it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 14 +++ crates/ui-egui/src/keymap.rs | 2 + crates/ui-egui/tests/selection_scroll.rs | 148 +++++++++++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..d42ef180b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -109,6 +109,10 @@ pub struct GridState { /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). pub reveal_moving_end: bool, + /// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when + /// extending), set only by those keys. A move from the scrolled rows or columns into the + /// frozen ones scrolls them back to their top or left. + pub reveal_from: Option, pub last_title: Option, pub drag_select: bool, pub rect: Option, @@ -681,6 +685,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if app.grid.ensure_visible { app.grid.ensure_visible = false; let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); @@ -694,6 +699,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.bottom() > area.bottom() - 2.0 { view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; } if target.col >= geo.fc { if cr.left() < area.left() { @@ -701,6 +711,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.right() > area.right() - 2.0 { view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; } view.scroll = view.scroll.max(egui::Vec2::ZERO); geo = make_geo(app, view.scroll); @@ -2967,9 +2979,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let cmd = m.command; let shift = m.shift; let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; }; match key { Key::ArrowUp => mv(app, -1, 0), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index fea791b69..5840036a1 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -223,9 +223,11 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0); let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64; let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; + let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = m.shift; + app.grid.reveal_from = from; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs index 719f1a0f2..9a78f9b9f 100644 --- a/crates/ui-egui/tests/selection_scroll.rs +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -142,3 +142,151 @@ fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); } + +fn scroll(h: &Harness) -> egui::Vec2 { + h.state().view().scroll +} + +#[test] +fn page_down_and_back_up_from_row_10_shows_rows_2_to_9_again() { + // The reported steps: row 1 frozen, row 10 selected, Page Down, Page Up, Page Up. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A10"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::PageDown, Modifiers::NONE); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A10").unwrap()); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2") && visible(&h, "A9")); +} + +#[test] +fn page_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + // Row 1 frozen: Page Up from far down ends on A1, and rows 2 onwards show under it again + // instead of the view staying a page down. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::NONE); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0, "the scrolled rows are back at the top"); + assert!(visible(&h, "A2")); + // Ctrl+Up from the bottom lands on A1 the same way. + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert!(visible(&h, "A2")); +} + +#[test] +fn ctrl_left_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + key(&mut h, Key::ArrowLeft, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn moving_within_a_frozen_row_leaves_the_scrolled_rows_where_they_are() { + // Selecting in row 1 while the rows below are scrolled down (without a reveal, as a click + // does), then moving along the frozen row, must not jump the view back to the top. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + assert!(down > 0.0); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down, "the view stays scrolled down"); +} + +#[test] +fn alt_page_up_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::ALT); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn extending_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + assert!(visible(&h, "A2")); +} + +#[test] +fn other_reveals_onto_a_frozen_cell_leave_the_scrolled_rows_where_they_are() { + // Only arrow and paging keys bring the scrolled rows back; selecting a header cell from the + // control channel (as Find, Go To or Undo would) keeps the place the user scrolled to. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"cell": "B1"}})); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down); +} + +#[test] +fn pointing_along_a_frozen_row_keeps_the_edited_cell_in_view() { + // Editing a formula far down and pointing at header cells with the arrows must not scroll + // the cell being edited away. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + let ed = h.state_mut().editor.as_mut().expect("editing"); + ed.caret = ed.text.chars().count(); + ed.point_cell = Some(CellRef::parse("B1").unwrap()); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.point_cell), Some(CellRef::parse("C1").unwrap()), "the arrow moved the point"); + assert_eq!(scroll(&h).y, down); + assert!(visible(&h, "A200"), "the edited cell stays in view"); +} + +#[test] +fn down_from_a_frozen_header_row_goes_to_row_2_at_the_top_from_far_down() { + // Excel's quick way back to the top: select a cell in the frozen header row while far down, + // press Down, and the view jumps to row 2 under the header. Selecting the header cell alone + // doesn't scroll. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A100000"})).unwrap(); + h.state_mut().grid.ensure_visible = true; + h.run_steps(2); + assert!(!visible(&h, "A2") && visible(&h, "A100000")); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + assert!(visible(&h, "A100000")); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A2").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2")); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..0b4ee83f2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | From f1e29fa82868fe1a099fc90740a3849f6c8b8ff3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 10:14:10 -0400 Subject: [PATCH 28/63] Review fixes for #2267 and #2292 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit). - Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 19 ++++++++++++++----- crates/ui-egui/src/i18n/ja.rs | 5 ++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 67920264d..1998f0b2c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -333,11 +333,14 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { // Text as given: no date or number reading, and a leading `'` is part of the // text rather than the marker that makes it text. A formula is still a // formula, which is what the ticket asks for and what `=` means in a cell. - Json::String(text) if !parse && !text.starts_with('=') => Some(Cell { - value: Value::Text(text.as_str().into()), - formula: None, - style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), - }), + Json::String(text) if !parse && !text.starts_with('=') => { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) + } _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; @@ -1827,6 +1830,12 @@ mod tests { assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + // A line break still turns on Wrap Text. + s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); + assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); } #[test] diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 09461b4bd..482852d8d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1127,7 +1127,10 @@ pub(super) const EXTRA: &[(&str, &str)] = &[ ("Sunburst", "サンバースト"), ("Stock", "株価"), ("Bar", "横棒"), - ("Drag to fill cells. Drag from inside a cell to select a range.", "ドラッグしてセルを塗りつぶします。セルの内側からドラッグすると範囲を選択します。"), + ( + "Drag to fill cells. Drag from inside a cell to select a range.", + "ドラッグしてセルをオートフィルします。セルの内側からドラッグすると範囲を選択します。", + ), ]; /// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can From 168acc58898d0f358964703f2826f57ca440418d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:17:38 -0400 Subject: [PATCH 29/63] packaging: shell, PowerShell and web page fixes (SonarCloud S7688, S7679, S7682, S8677, S7926) (#2323) - Shell: [[ ]] for conditional tests, positional parameters copied into named locals, explicit return at the end of the env.sh helpers. - PowerShell: the desktop-shortcut smoke test writes its progress with Write-Output instead of Write-Host. - Web: the viewport no longer sets user-scalable=no, so people can zoom the page. --- apps/gridcraft-web/index.html | 2 +- packaging/env.sh | 19 ++++++++------ packaging/freebsd/package.sh | 4 +-- packaging/icons.sh | 5 +++- packaging/linux/flatpak-bundle.sh | 8 +++--- packaging/linux/package.sh | 21 +++++++++------- packaging/macos/import-cert.sh | 6 ++--- packaging/macos/package.sh | 28 ++++++++++----------- packaging/web/package.sh | 6 ++--- packaging/windows/test-desktop-shortcut.ps1 | 6 ++--- 10 files changed, 58 insertions(+), 47 deletions(-) diff --git a/apps/gridcraft-web/index.html b/apps/gridcraft-web/index.html index 5fdd1ea54..321aa302e 100644 --- a/apps/gridcraft-web/index.html +++ b/apps/gridcraft-web/index.html @@ -2,7 +2,7 @@ - + GridCraft diff --git a/packaging/env.sh b/packaging/env.sh index bacb167e1..6f36506b6 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,10 +19,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${GRIDCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${GRIDCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${GRIDCRAFT_BUILD_SHA:-}" ]]; then GRIDCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export GRIDCRAFT_BUILD_SHA @@ -41,29 +42,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" + return } # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so # the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" f - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi + if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi done } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e4ee9da7b..ab98e9800 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -16,7 +16,7 @@ APP_ID=ai.storyteller.gridcraft LINUX="$ROOT/packaging/linux" SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; -h | --help) sed -n '2,11p' "$0"; exit 0 ;; @@ -34,7 +34,7 @@ BASENAME="gridcraft-$VERSION-freebsd-$ARCH" echo "==> GridCraft $VERSION for FreeBSD $ARCH" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) fi BIN="$CARGO_TARGET_DIR/release" diff --git a/packaging/icons.sh b/packaging/icons.sh index 2adcb9a9f..c6f325e3d 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -21,7 +21,10 @@ MAC="$TMP/mac.svg" sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC" grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/gridcraft" ] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/gridcraft" ]] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=gridcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 105ac744f..f8c53d6b3 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -21,7 +21,7 @@ APP_ID=ai.storyteller.gridcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -42,8 +42,8 @@ BASENAME="gridcraft-$VERSION-linux-$ARCH" echo "==> GridCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) @@ -75,7 +75,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local format="$1" + case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -108,9 +111,9 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then + if [[ ! -x "$TOOL" ]]; then curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi @@ -133,16 +136,16 @@ if has appimage; then # package) appimagetool also writes the .zsync, into its working directory, hence the cd. (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/gridcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/gridcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 41d9538bf..4611ffe05 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index d8b4b097b..b45164ee0 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/gridcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/gridcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> GridCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli "${args[@]}") @@ -74,22 +74,22 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } # stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The # network connection was lost", Error 68). Stapling and validating are idempotent, so retry. stapler_retry() { - local attempt + local action="$1" attempt for attempt in 1 2 3 4 5; do if xcrun stapler "$@"; then return 0 fi - [ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; } - warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + [[ "$attempt" = 5 ]] && { echo "stapler $action failed 5 times" >&2; return 1; } + warn "stapler $action failed (attempt $attempt of 5); retrying in $((attempt * 15))s" sleep $((attempt * 15)) done } @@ -102,8 +102,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -131,7 +131,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/GridCraft-notarize.zip" notarize "$WORK/GridCraft-notarize.zip" stapler_retry staple "$APP" @@ -158,7 +158,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" stapler_retry staple "$DMG" stapler_retry validate "$DMG" @@ -176,7 +176,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/gridcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/gridcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 8b1bca63b..7c5be6134 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,15 +12,15 @@ set -euo pipefail HERE="$ROOT/packaging/web" SITE="$ROOT/dist/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } - [ -f "$ROOT/apps/gridcraft-web/index.html" ] || { echo "error: apps/gridcraft-web/index.html missing (the web app isn't set up yet)" >&2; exit 1; } + [[ -f "$ROOT/apps/gridcraft-web/index.html" ]] || { echo "error: apps/gridcraft-web/index.html missing (the web app isn't set up yet)" >&2; exit 1; } # --dist/--public-url here, so the output doesn't depend on Trunk.toml (which should agree: # public_url = "./"). (cd "$ROOT/apps/gridcraft-web" && trunk build --release --dist "$SITE" --public-url ./) fi -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/packaging/windows/test-desktop-shortcut.ps1 b/packaging/windows/test-desktop-shortcut.ps1 index 9a5b4cb04..f3c25b2c8 100644 --- a/packaging/windows/test-desktop-shortcut.ps1 +++ b/packaging/windows/test-desktop-shortcut.ps1 @@ -72,10 +72,10 @@ function Invoke-Msi([string] $Name, [string[]] $Arguments) { throw "$Name exceeded two minutes; see $log" } if ($process.ExitCode -ne 0) { - if (Test-Path $log) { Get-Content $log -Tail 60 | Write-Host } + if (Test-Path $log) { Get-Content $log -Tail 60 } throw "$Name exited $($process.ExitCode); see $log" } - Write-Host "ok $Name" + Write-Output "ok $Name" } finally { $process.Dispose() } @@ -146,7 +146,7 @@ function Uninstall-AsSystem($Package) { $info = Get-ScheduledTaskInfo -TaskName $taskName if ($info.LastRunTime -ge $started.AddSeconds(-1) -and $task.State -ne 'Running' -and $info.LastTaskResult -ne 267009) { Assert-That ($info.LastTaskResult -eq 0) "Cross-account uninstall failed with $($info.LastTaskResult); see $log" - Write-Host 'ok uninstall-as-system' + Write-Output 'ok uninstall-as-system' return } } while ((Get-Date) -lt $deadline) From bdf8b8cc755ae6dfc2266d23b3ede43329114339 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:48:02 -0400 Subject: [PATCH 30/63] Use chrono instead of hand-rolled UTC conversion (#2121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days / days_from_civil copies and the is_leap / days_in_month calendar helpers with chrono as a workspace dependency (default-features = false, std only, so wasm is unaffected). The spreadsheet serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial 60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday numbering and the 1900/1904 systems keep their explicit special cases in serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs the plain proleptic-Gregorian day-number <-> date conversion and the leap-year / month-length helpers those build on; a small fallback keeps the old answer for dates outside chrono's NaiveDate range. The duplicate local is_leap in the financial functions now uses the core one. Output is byte-identical and pinned by the existing date, parse, financial and logging tests. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on out-of-range clocks or years; keep calendar results outside chrono's range - logging::timestamp: chrono's From unwraps, so a clock past year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp and read such a clock as the epoch, like the old saturating code. Years past 9999 are written with {:04} (not %Y, which prefixes a '+'). - date::is_leap: restore the Gregorian leap-year rule for years outside chrono's NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic. - date::days_in_month: compute the December boundary with checked_add so days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths through days_from_civil's fallback so out-of-range years keep their real lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0. - civil_from_days: add missing docstring. - AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback` copies for dates outside chrono's ±262143-year range, plus a hand-rolled Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`. AGENTS.md carved a "narrow fallback" exception for them, which left calendar arithmetic outside chrono after all. Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono converts the day/year within one cycle and whole cycles are added back via i128 (so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce the year with `rem_euclid(400)` (leap years and month lengths repeat every 400 years), which also removes the December `i32::MAX` overflow special-case. chrono is now the only calendar logic, and every output is unchanged: verified the cycle result equals the old formula over wide ranges and at the i32/i64 extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 = fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/ `datetime_from_serial` only ever hand the converters in-range years, so their serials are byte-for-byte the pre-chrono ones (pinned by a new test). New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both sides of the epoch, round-tripping, with values from the old formula; the existing leap/month-length-beyond-range test still passes unchanged. Removed the AGENTS.md "narrow fallback" exception. Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check; clippy --all-targets -D warnings. * time: preserve out-of-range years in the log timestamp instead of the epoch `DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a clock set far ahead (an input the old hand-rolled civil-from-days converted) collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the in-range remainder with chrono, and carry the year back as i128. Only the year leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the epoch as before. chrono stays the only calendar logic (no new features). apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a platform-safe `UNIX_EPOCH.checked_add`: u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."), 10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...". * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 1 + Cargo.lock | 11 +++ Cargo.toml | 2 + apps/gridcraft/Cargo.toml | 1 + apps/gridcraft/src/logging.rs | 55 +++++++++----- crates/core/Cargo.toml | 1 + crates/core/src/date.rs | 135 ++++++++++++++++++++++++++++------ 7 files changed, 163 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 963fc6d74..ab73047ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`). diff --git a/Cargo.lock b/Cargo.lock index c7e3560b6..7f5203188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,6 +619,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.5.2" @@ -1499,6 +1508,7 @@ name = "gridcraft" version = "0.11.1" dependencies = [ "arboard", + "chrono", "craft-print", "eframe", "egui", @@ -1555,6 +1565,7 @@ dependencies = [ name = "gridcraft-core" version = "0.11.1" dependencies = [ + "chrono", "js-sys", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index bf8eb381e..9994d44e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index cd8311b34..94cf3fe9b 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } +chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index e3b56a8cb..a0f62d164 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -393,6 +399,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1decf6257..e7d369552 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] serde = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index b61892f6c..b42c068a0 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -28,15 +28,22 @@ pub struct DateTime { pub weekday: u32, } -/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01. +/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year. +/// +/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400 +/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added +/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce` +/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave. pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y as i64 - 1 } else { y as i64 }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let m = m as i64; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 + let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097; + i64::try_from(days).ok() + }) + .unwrap_or(0) } /// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. @@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 { } } +/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of +/// [`days_from_civil`]). +/// +/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle +/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the +/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range. +/// 1970-01-01 is `num_days_from_ce` 719_163. pub fn civil_from_days(z: i64) -> (i32, u32, u32) { - let z = z + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - ((if m <= 2 { y + 1 } else { y }) as i32, m, d) + use chrono::Datelike; + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day()) + } + // Unreachable: `rest + 719_163` is always a valid CE day. + None => (1970, 1, 1), + } } const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30) const UNIX_1904_01_01: i64 = -24107; +/// Whether `y` is a Gregorian leap year, for every `i32` year. pub fn is_leap(y: i32) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 + // A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so + // the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono. + let y = i64::from(y).rem_euclid(400) as i32; + chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some() } +/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every +/// `i32` year. pub fn days_in_month(y: i32, m: u32) -> u32 { - match m { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if is_leap(y) => 29, - 2 => 28, + // The length of a month is the day before the first of the next month. Month lengths repeat + // every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and + // always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`. + if !(1..=12).contains(&m) { + return 0; + } + let y = i64::from(y).rem_euclid(400) as i32; + let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1); + match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) { + (Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0), _ => 0, } } @@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th mod tests { use super::*; + #[test] + fn leap_and_month_length_outside_chrono_range() { + // In range, the ordinary Gregorian rule holds. + assert!(is_leap(2000)); + assert!(!is_leap(1900)); + assert!(is_leap(2024)); + assert!(!is_leap(2023)); + // Outside chrono's NaiveDate range the Gregorian rule is still applied. + assert!(is_leap(400_000)); // divisible by 400 + assert!(!is_leap(300_100)); // century not divisible by 400 + assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century + assert!(!is_leap(i32::MAX)); // 2147483647 is odd + // days_in_month keeps calendar lengths out of range and 0 for invalid months. + assert_eq!(days_in_month(400_000, 2), 29); + assert_eq!(days_in_month(300_100, 2), 28); + assert_eq!(days_in_month(400_000, 1), 31); + assert_eq!(days_in_month(400_000, 4), 30); + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2023, 0), 0); + assert_eq!(days_in_month(2023, 13), 0); + // December at i32::MAX does not overflow and still reports 31 days. + assert_eq!(days_in_month(i32::MAX, 12), 31); + assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year + } + + #[test] + fn civil_conversions_exact_beyond_chrono_range() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of + // the epoch; values round-trip through the proleptic Gregorian converters. + for (y, m, d, z) in [ + (262_144i32, 1u32, 1u32, 95_026_602i64), + (300_000, 1, 1, 108_853_222), + (400_000, 2, 29, 145_377_531), + (-262_145, 3, 1, -96_465_964), + (-300_000, 6, 15, -110_292_112), + ] { + assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}"); + assert_eq!(civil_from_days(z), (y, m, d), "{z}"); + } + // The extremes must not overflow or panic (i128 intermediates). + let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN)); + } + + #[test] + fn serials_unchanged_through_cycle_conversion() { + // The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see + // in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones. + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day + assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0)); + assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0)); + assert_eq!(days_in_month_in(s, 1900, 2), 29); + let s4 = DateSystem::D1904; + assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01 + assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year + assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system + assert_eq!(days_in_month_in(s4, 1900, 2), 28); + } + #[test] fn serial_from_unix_time() { assert_eq!(serial_from_unix_ms(0.0), 25569.0); From a7608ada6dc91f9caf53f9eb7c2cdb3231ab1e75 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:59:46 -0400 Subject: [PATCH 31/63] CI: one test binary per crate, nextest, line tables only (#2372) * CI: dev builds keep line tables only * CI: one integration-test binary per crate * CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps. * CI: docs name cargo xtask test * CI: agent guides name the moved fuzz test * CI: tests added on main join their crate's test binary --- AGENTS.md | 2 +- CLAUDE.md | 2 +- Cargo.toml | 2 + apps/gridcraft-cli/tests/{ => it}/cli.rs | 2 - apps/gridcraft-cli/tests/it/main.rs | 5 + .../tests/{ => it}/text_to_columns_quotes.rs | 0 .../tests/{ => it}/autofit_filter_buttons.rs | 0 .../tests/{ => it}/cf_formula_validate.rs | 0 .../tests/{ => it}/chart_workbook_names.rs | 0 .../tests/{ => it}/command_sequence_fuzz.rs | 4 +- .../engine/tests/{ => it}/cross_sheet_cut.rs | 0 .../tests/{ => it}/csv_export_budget.rs | 0 .../tests/{ => it}/custom_filter_blanks.rs | 0 .../tests/{ => it}/custom_view_freeze.rs | 0 .../engine/tests/{ => it}/dynamicmetadata.rs | 0 .../tests/{ => it}/extreme_selection_moves.rs | 0 .../tests/{ => it}/format_as_table_restyle.rs | 0 .../tests/{ => it}/hidden_sheet_navigation.rs | 0 crates/engine/tests/{ => it}/ink_roundtrip.rs | 0 .../tests/{ => it}/line_count_overflow.rs | 0 .../long_operator_chains.rs} | 0 crates/engine/tests/it/main.rs | 29 ++++++ .../engine/tests/{ => it}/offset_overflow.rs | 0 crates/engine/tests/{ => it}/percent_entry.rs | 0 .../tests/{ => it}/pivot_unknown_func.rs | 0 .../engine/tests/{ => it}/pivotchart_link.rs | 0 .../tests/{ => it}/range_fill_formats.rs | 0 crates/engine/tests/{ => it}/row_autofit.rs | 0 .../tests/{ => it}/sheet_name_quoting.rs | 0 .../{ => it}/sparse_clear_invalidation.rs | 0 .../tests/{ => it}/stored_list_shape.rs | 0 .../tests/{ => it}/table_resize_overlap.rs | 0 .../{ => it}/table_total_row_last_row.rs | 0 .../engine/tests/{ => it}/wrap_row_height.rs | 0 crates/formula/tests/it/main.rs | 4 + .../tests/{ => it}/printer_roundtrip.rs | 0 .../formula/tests/{ => it}/spanish_input.rs | 0 .../ui-egui/tests/{ => it}/accounting_fill.rs | 0 .../ui-egui/tests/{ => it}/advanced_filter.rs | 0 .../ui-egui/tests/{ => it}/argument_names.rs | 0 .../tests/{ => it}/autosave_persist.rs | 0 crates/ui-egui/tests/{ => it}/borders.rs | 0 .../tests/{ => it}/cell_editor_caret.rs | 0 .../ui-egui/tests/{ => it}/cell_pictures.rs | 0 crates/ui-egui/tests/{ => it}/chart_resize.rs | 0 crates/ui-egui/tests/{ => it}/clipboard.rs | 0 .../ui-egui/tests/{ => it}/comments_pane.rs | 0 .../tests/{ => it}/commit_after_pointing.rs | 0 .../tests/{ => it}/committed_text_fields.rs | 0 .../tests/{ => it}/context_menu_stays_open.rs | 0 .../ui-egui/tests/{ => it}/control_timeout.rs | 0 .../tests/{ => it}/dialog_button_row.rs | 0 .../ui-egui/tests/{ => it}/dialog_confirm.rs | 0 crates/ui-egui/tests/{ => it}/dialog_focus.rs | 0 crates/ui-egui/tests/{ => it}/dialog_move.rs | 0 crates/ui-egui/tests/{ => it}/drag_cursor.rs | 0 crates/ui-egui/tests/{ => it}/file_menu.rs | 0 .../{ => it}/fill_handle_double_click.rs | 0 .../tests/{ => it}/fill_handle_right_drag.rs | 0 .../tests/{ => it}/fill_options_button.rs | 0 .../tests/{ => it}/filter_list_scroll.rs | 0 .../tests/{ => it}/filter_menu_enter.rs | 0 .../tests/{ => it}/find_dialog_focus.rs | 0 .../tests/{ => it}/floating_pictures.rs | 0 .../ui-egui/tests/{ => it}/font_size_box.rs | 0 .../ui-egui/tests/{ => it}/format_painter.rs | 0 .../ui-egui/tests/{ => it}/formula_locale.rs | 0 .../tests/{ => it}/formula_point_headers.rs | 0 .../ui-egui/tests/{ => it}/formula_popup.rs | 0 .../ui-egui/tests/{ => it}/freeze_handles.rs | 0 .../ui-egui/tests/{ => it}/header_autofit.rs | 0 .../tests/{ => it}/header_column_drag.rs | 0 crates/ui-egui/tests/{ => it}/host_chrome.rs | 0 .../tests/{ => it}/hyperlink_ctrl_click.rs | 0 .../tests/{ => it}/in_cell_editor_size.rs | 0 .../tests/{ => it}/in_cell_editor_wrap.rs | 0 .../tests/{ => it}/insert_copied_cells.rs | 0 crates/ui-egui/tests/{ => it}/interaction.rs | 0 .../ui-egui/tests/{ => it}/interface_scale.rs | 0 .../tests/{ => it}/keyboard_shortcuts.rs | 0 .../tests/{ => it}/keytips_generated.rs | 0 crates/ui-egui/tests/it/main.rs | 91 +++++++++++++++++++ crates/ui-egui/tests/{ => it}/menu_hover.rs | 0 crates/ui-egui/tests/{ => it}/merged_cells.rs | 0 crates/ui-egui/tests/{ => it}/more_colors.rs | 0 crates/ui-egui/tests/{ => it}/native_print.rs | 0 .../tests/{ => it}/nowrap_line_breaks.rs | 0 .../tests/{ => it}/number_stored_as_text.rs | 0 .../ui-egui/tests/{ => it}/open_encrypted.rs | 0 crates/ui-egui/tests/{ => it}/outline.rs | 0 .../tests/{ => it}/page_layout_view.rs | 0 .../tests/{ => it}/page_setup_margins.rs | 0 .../tests/{ => it}/paste_special_dialog.rs | 0 crates/ui-egui/tests/{ => it}/pivot_pane.rs | 0 .../ui-egui/tests/{ => it}/popup_dismissal.rs | 0 .../tests/{ => it}/protect_password.rs | 0 .../ui-egui/tests/{ => it}/right_to_left.rs | 0 .../ui-egui/tests/{ => it}/scroll_position.rs | 0 .../tests/{ => it}/scrollbar_column_fill.rs | 0 crates/ui-egui/tests/{ => it}/selection.rs | 0 .../ui-egui/tests/{ => it}/selection_fill.rs | 0 .../ui-egui/tests/{ => it}/selection_glide.rs | 0 .../tests/{ => it}/selection_scroll.rs | 0 .../ui-egui/tests/{ => it}/sheet_navigator.rs | 0 .../tests/{ => it}/sheet_tab_rename.rs | 0 crates/ui-egui/tests/{ => it}/sheet_tabs.rs | 0 crates/ui-egui/tests/{ => it}/show_objects.rs | 0 .../ui-egui/tests/{ => it}/shrink_to_fit.rs | 0 .../tests/{ => it}/sort_dialog_columns.rs | 0 .../ui-egui/tests/{ => it}/symbol_glyphs.rs | 0 crates/ui-egui/tests/{ => it}/system_theme.rs | 0 .../ui-egui/tests/{ => it}/table_headings.rs | 0 .../tests/{ => it}/text_box_editing.rs | 0 .../tests/{ => it}/text_overflow_scroll.rs | 0 crates/ui-egui/tests/{ => it}/touch_pan.rs | 0 .../tests/{ => it}/trace_navigation.rs | 0 .../tests/{ => it}/translated_form_fields.rs | 0 crates/ui-egui/tests/{ => it}/ui_clock.rs | 0 .../tests/{ => it}/validation_alert.rs | 0 .../{ => it}/validation_input_message.rs | 0 crates/ui-egui/tests/{ => it}/wheel_zoom.rs | 0 .../tests/{ => it}/whole_line_shortcuts.rs | 0 crates/ui-egui/tests/{ => it}/widgets.rs | 0 crates/ui-egui/tests/{ => it}/zoom_dialog.rs | 0 docs/roadmap.md | 6 +- docs/target-app-parity.md | 2 +- xtask/src/keytips.rs | 4 +- xtask/src/main.rs | 60 ++++++++---- 128 files changed, 186 insertions(+), 27 deletions(-) rename apps/gridcraft-cli/tests/{ => it}/cli.rs (99%) create mode 100644 apps/gridcraft-cli/tests/it/main.rs rename apps/gridcraft-cli/tests/{ => it}/text_to_columns_quotes.rs (100%) rename crates/engine/tests/{ => it}/autofit_filter_buttons.rs (100%) rename crates/engine/tests/{ => it}/cf_formula_validate.rs (100%) rename crates/engine/tests/{ => it}/chart_workbook_names.rs (100%) rename crates/engine/tests/{ => it}/command_sequence_fuzz.rs (99%) rename crates/engine/tests/{ => it}/cross_sheet_cut.rs (100%) rename crates/engine/tests/{ => it}/csv_export_budget.rs (100%) rename crates/engine/tests/{ => it}/custom_filter_blanks.rs (100%) rename crates/engine/tests/{ => it}/custom_view_freeze.rs (100%) rename crates/engine/tests/{ => it}/dynamicmetadata.rs (100%) rename crates/engine/tests/{ => it}/extreme_selection_moves.rs (100%) rename crates/engine/tests/{ => it}/format_as_table_restyle.rs (100%) rename crates/engine/tests/{ => it}/hidden_sheet_navigation.rs (100%) rename crates/engine/tests/{ => it}/ink_roundtrip.rs (100%) rename crates/engine/tests/{ => it}/line_count_overflow.rs (100%) rename crates/engine/tests/{long-operator-chains.rs => it/long_operator_chains.rs} (100%) create mode 100644 crates/engine/tests/it/main.rs rename crates/engine/tests/{ => it}/offset_overflow.rs (100%) rename crates/engine/tests/{ => it}/percent_entry.rs (100%) rename crates/engine/tests/{ => it}/pivot_unknown_func.rs (100%) rename crates/engine/tests/{ => it}/pivotchart_link.rs (100%) rename crates/engine/tests/{ => it}/range_fill_formats.rs (100%) rename crates/engine/tests/{ => it}/row_autofit.rs (100%) rename crates/engine/tests/{ => it}/sheet_name_quoting.rs (100%) rename crates/engine/tests/{ => it}/sparse_clear_invalidation.rs (100%) rename crates/engine/tests/{ => it}/stored_list_shape.rs (100%) rename crates/engine/tests/{ => it}/table_resize_overlap.rs (100%) rename crates/engine/tests/{ => it}/table_total_row_last_row.rs (100%) rename crates/engine/tests/{ => it}/wrap_row_height.rs (100%) create mode 100644 crates/formula/tests/it/main.rs rename crates/formula/tests/{ => it}/printer_roundtrip.rs (100%) rename crates/formula/tests/{ => it}/spanish_input.rs (100%) rename crates/ui-egui/tests/{ => it}/accounting_fill.rs (100%) rename crates/ui-egui/tests/{ => it}/advanced_filter.rs (100%) rename crates/ui-egui/tests/{ => it}/argument_names.rs (100%) rename crates/ui-egui/tests/{ => it}/autosave_persist.rs (100%) rename crates/ui-egui/tests/{ => it}/borders.rs (100%) rename crates/ui-egui/tests/{ => it}/cell_editor_caret.rs (100%) rename crates/ui-egui/tests/{ => it}/cell_pictures.rs (100%) rename crates/ui-egui/tests/{ => it}/chart_resize.rs (100%) rename crates/ui-egui/tests/{ => it}/clipboard.rs (100%) rename crates/ui-egui/tests/{ => it}/comments_pane.rs (100%) rename crates/ui-egui/tests/{ => it}/commit_after_pointing.rs (100%) rename crates/ui-egui/tests/{ => it}/committed_text_fields.rs (100%) rename crates/ui-egui/tests/{ => it}/context_menu_stays_open.rs (100%) rename crates/ui-egui/tests/{ => it}/control_timeout.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_button_row.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_confirm.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_move.rs (100%) rename crates/ui-egui/tests/{ => it}/drag_cursor.rs (100%) rename crates/ui-egui/tests/{ => it}/file_menu.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_handle_double_click.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_handle_right_drag.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_options_button.rs (100%) rename crates/ui-egui/tests/{ => it}/filter_list_scroll.rs (100%) rename crates/ui-egui/tests/{ => it}/filter_menu_enter.rs (100%) rename crates/ui-egui/tests/{ => it}/find_dialog_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/floating_pictures.rs (100%) rename crates/ui-egui/tests/{ => it}/font_size_box.rs (100%) rename crates/ui-egui/tests/{ => it}/format_painter.rs (100%) rename crates/ui-egui/tests/{ => it}/formula_locale.rs (100%) rename crates/ui-egui/tests/{ => it}/formula_point_headers.rs (100%) rename crates/ui-egui/tests/{ => it}/formula_popup.rs (100%) rename crates/ui-egui/tests/{ => it}/freeze_handles.rs (100%) rename crates/ui-egui/tests/{ => it}/header_autofit.rs (100%) rename crates/ui-egui/tests/{ => it}/header_column_drag.rs (100%) rename crates/ui-egui/tests/{ => it}/host_chrome.rs (100%) rename crates/ui-egui/tests/{ => it}/hyperlink_ctrl_click.rs (100%) rename crates/ui-egui/tests/{ => it}/in_cell_editor_size.rs (100%) rename crates/ui-egui/tests/{ => it}/in_cell_editor_wrap.rs (100%) rename crates/ui-egui/tests/{ => it}/insert_copied_cells.rs (100%) rename crates/ui-egui/tests/{ => it}/interaction.rs (100%) rename crates/ui-egui/tests/{ => it}/interface_scale.rs (100%) rename crates/ui-egui/tests/{ => it}/keyboard_shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/keytips_generated.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ => it}/menu_hover.rs (100%) rename crates/ui-egui/tests/{ => it}/merged_cells.rs (100%) rename crates/ui-egui/tests/{ => it}/more_colors.rs (100%) rename crates/ui-egui/tests/{ => it}/native_print.rs (100%) rename crates/ui-egui/tests/{ => it}/nowrap_line_breaks.rs (100%) rename crates/ui-egui/tests/{ => it}/number_stored_as_text.rs (100%) rename crates/ui-egui/tests/{ => it}/open_encrypted.rs (100%) rename crates/ui-egui/tests/{ => it}/outline.rs (100%) rename crates/ui-egui/tests/{ => it}/page_layout_view.rs (100%) rename crates/ui-egui/tests/{ => it}/page_setup_margins.rs (100%) rename crates/ui-egui/tests/{ => it}/paste_special_dialog.rs (100%) rename crates/ui-egui/tests/{ => it}/pivot_pane.rs (100%) rename crates/ui-egui/tests/{ => it}/popup_dismissal.rs (100%) rename crates/ui-egui/tests/{ => it}/protect_password.rs (100%) rename crates/ui-egui/tests/{ => it}/right_to_left.rs (100%) rename crates/ui-egui/tests/{ => it}/scroll_position.rs (100%) rename crates/ui-egui/tests/{ => it}/scrollbar_column_fill.rs (100%) rename crates/ui-egui/tests/{ => it}/selection.rs (100%) rename crates/ui-egui/tests/{ => it}/selection_fill.rs (100%) rename crates/ui-egui/tests/{ => it}/selection_glide.rs (100%) rename crates/ui-egui/tests/{ => it}/selection_scroll.rs (100%) rename crates/ui-egui/tests/{ => it}/sheet_navigator.rs (100%) rename crates/ui-egui/tests/{ => it}/sheet_tab_rename.rs (100%) rename crates/ui-egui/tests/{ => it}/sheet_tabs.rs (100%) rename crates/ui-egui/tests/{ => it}/show_objects.rs (100%) rename crates/ui-egui/tests/{ => it}/shrink_to_fit.rs (100%) rename crates/ui-egui/tests/{ => it}/sort_dialog_columns.rs (100%) rename crates/ui-egui/tests/{ => it}/symbol_glyphs.rs (100%) rename crates/ui-egui/tests/{ => it}/system_theme.rs (100%) rename crates/ui-egui/tests/{ => it}/table_headings.rs (100%) rename crates/ui-egui/tests/{ => it}/text_box_editing.rs (100%) rename crates/ui-egui/tests/{ => it}/text_overflow_scroll.rs (100%) rename crates/ui-egui/tests/{ => it}/touch_pan.rs (100%) rename crates/ui-egui/tests/{ => it}/trace_navigation.rs (100%) rename crates/ui-egui/tests/{ => it}/translated_form_fields.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_clock.rs (100%) rename crates/ui-egui/tests/{ => it}/validation_alert.rs (100%) rename crates/ui-egui/tests/{ => it}/validation_input_message.rs (100%) rename crates/ui-egui/tests/{ => it}/wheel_zoom.rs (100%) rename crates/ui-egui/tests/{ => it}/whole_line_shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/widgets.rs (100%) rename crates/ui-egui/tests/{ => it}/zoom_dialog.rs (100%) diff --git a/AGENTS.md b/AGENTS.md index ab73047ef..7fd1629cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. -- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/CLAUDE.md b/CLAUDE.md index 280fe8bcb..1d1a77d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. -- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/Cargo.toml b/Cargo.toml index 9994d44e3..4d3fd4b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,9 +68,11 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 diff --git a/apps/gridcraft-cli/tests/cli.rs b/apps/gridcraft-cli/tests/it/cli.rs similarity index 99% rename from apps/gridcraft-cli/tests/cli.rs rename to apps/gridcraft-cli/tests/it/cli.rs index 5295368e3..dde018ca8 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/it/cli.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::io::Write; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..17f8db87f --- /dev/null +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod cli; +mod text_to_columns_quotes; diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs similarity index 100% rename from apps/gridcraft-cli/tests/text_to_columns_quotes.rs rename to apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs diff --git a/crates/engine/tests/autofit_filter_buttons.rs b/crates/engine/tests/it/autofit_filter_buttons.rs similarity index 100% rename from crates/engine/tests/autofit_filter_buttons.rs rename to crates/engine/tests/it/autofit_filter_buttons.rs diff --git a/crates/engine/tests/cf_formula_validate.rs b/crates/engine/tests/it/cf_formula_validate.rs similarity index 100% rename from crates/engine/tests/cf_formula_validate.rs rename to crates/engine/tests/it/cf_formula_validate.rs diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/it/chart_workbook_names.rs similarity index 100% rename from crates/engine/tests/chart_workbook_names.rs rename to crates/engine/tests/it/chart_workbook_names.rs diff --git a/crates/engine/tests/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs similarity index 99% rename from crates/engine/tests/command_sequence_fuzz.rs rename to crates/engine/tests/it/command_sequence_fuzz.rs index f8cb603cb..012757f69 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -23,7 +23,7 @@ //! //! Knobs (environment variables): //! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with -//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`. +//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`. //! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line). //! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that //! still fails the same way, printed as `execute` calls ready for a regression test. @@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure) None => format!("end-of-run checks after {} steps", sequence.len()), }; let mut out = format!( - "command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n", + "command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n", f.kind, f.what ); let end = (f.step + 1).min(sequence.len()); diff --git a/crates/engine/tests/cross_sheet_cut.rs b/crates/engine/tests/it/cross_sheet_cut.rs similarity index 100% rename from crates/engine/tests/cross_sheet_cut.rs rename to crates/engine/tests/it/cross_sheet_cut.rs diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/it/csv_export_budget.rs similarity index 100% rename from crates/engine/tests/csv_export_budget.rs rename to crates/engine/tests/it/csv_export_budget.rs diff --git a/crates/engine/tests/custom_filter_blanks.rs b/crates/engine/tests/it/custom_filter_blanks.rs similarity index 100% rename from crates/engine/tests/custom_filter_blanks.rs rename to crates/engine/tests/it/custom_filter_blanks.rs diff --git a/crates/engine/tests/custom_view_freeze.rs b/crates/engine/tests/it/custom_view_freeze.rs similarity index 100% rename from crates/engine/tests/custom_view_freeze.rs rename to crates/engine/tests/it/custom_view_freeze.rs diff --git a/crates/engine/tests/dynamicmetadata.rs b/crates/engine/tests/it/dynamicmetadata.rs similarity index 100% rename from crates/engine/tests/dynamicmetadata.rs rename to crates/engine/tests/it/dynamicmetadata.rs diff --git a/crates/engine/tests/extreme_selection_moves.rs b/crates/engine/tests/it/extreme_selection_moves.rs similarity index 100% rename from crates/engine/tests/extreme_selection_moves.rs rename to crates/engine/tests/it/extreme_selection_moves.rs diff --git a/crates/engine/tests/format_as_table_restyle.rs b/crates/engine/tests/it/format_as_table_restyle.rs similarity index 100% rename from crates/engine/tests/format_as_table_restyle.rs rename to crates/engine/tests/it/format_as_table_restyle.rs diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/it/hidden_sheet_navigation.rs similarity index 100% rename from crates/engine/tests/hidden_sheet_navigation.rs rename to crates/engine/tests/it/hidden_sheet_navigation.rs diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/it/ink_roundtrip.rs similarity index 100% rename from crates/engine/tests/ink_roundtrip.rs rename to crates/engine/tests/it/ink_roundtrip.rs diff --git a/crates/engine/tests/line_count_overflow.rs b/crates/engine/tests/it/line_count_overflow.rs similarity index 100% rename from crates/engine/tests/line_count_overflow.rs rename to crates/engine/tests/it/line_count_overflow.rs diff --git a/crates/engine/tests/long-operator-chains.rs b/crates/engine/tests/it/long_operator_chains.rs similarity index 100% rename from crates/engine/tests/long-operator-chains.rs rename to crates/engine/tests/it/long_operator_chains.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..385ec76ca --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,29 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +mod autofit_filter_buttons; +mod cf_formula_validate; +mod chart_workbook_names; +mod command_sequence_fuzz; +mod cross_sheet_cut; +mod csv_export_budget; +mod custom_filter_blanks; +mod custom_view_freeze; +mod dynamicmetadata; +mod extreme_selection_moves; +mod format_as_table_restyle; +mod hidden_sheet_navigation; +mod ink_roundtrip; +mod line_count_overflow; +mod long_operator_chains; +mod offset_overflow; +mod percent_entry; +mod pivot_unknown_func; +mod pivotchart_link; +mod range_fill_formats; +mod row_autofit; +mod sheet_name_quoting; +mod sparse_clear_invalidation; +mod stored_list_shape; +mod table_resize_overlap; +mod table_total_row_last_row; +mod wrap_row_height; diff --git a/crates/engine/tests/offset_overflow.rs b/crates/engine/tests/it/offset_overflow.rs similarity index 100% rename from crates/engine/tests/offset_overflow.rs rename to crates/engine/tests/it/offset_overflow.rs diff --git a/crates/engine/tests/percent_entry.rs b/crates/engine/tests/it/percent_entry.rs similarity index 100% rename from crates/engine/tests/percent_entry.rs rename to crates/engine/tests/it/percent_entry.rs diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/it/pivot_unknown_func.rs similarity index 100% rename from crates/engine/tests/pivot_unknown_func.rs rename to crates/engine/tests/it/pivot_unknown_func.rs diff --git a/crates/engine/tests/pivotchart_link.rs b/crates/engine/tests/it/pivotchart_link.rs similarity index 100% rename from crates/engine/tests/pivotchart_link.rs rename to crates/engine/tests/it/pivotchart_link.rs diff --git a/crates/engine/tests/range_fill_formats.rs b/crates/engine/tests/it/range_fill_formats.rs similarity index 100% rename from crates/engine/tests/range_fill_formats.rs rename to crates/engine/tests/it/range_fill_formats.rs diff --git a/crates/engine/tests/row_autofit.rs b/crates/engine/tests/it/row_autofit.rs similarity index 100% rename from crates/engine/tests/row_autofit.rs rename to crates/engine/tests/it/row_autofit.rs diff --git a/crates/engine/tests/sheet_name_quoting.rs b/crates/engine/tests/it/sheet_name_quoting.rs similarity index 100% rename from crates/engine/tests/sheet_name_quoting.rs rename to crates/engine/tests/it/sheet_name_quoting.rs diff --git a/crates/engine/tests/sparse_clear_invalidation.rs b/crates/engine/tests/it/sparse_clear_invalidation.rs similarity index 100% rename from crates/engine/tests/sparse_clear_invalidation.rs rename to crates/engine/tests/it/sparse_clear_invalidation.rs diff --git a/crates/engine/tests/stored_list_shape.rs b/crates/engine/tests/it/stored_list_shape.rs similarity index 100% rename from crates/engine/tests/stored_list_shape.rs rename to crates/engine/tests/it/stored_list_shape.rs diff --git a/crates/engine/tests/table_resize_overlap.rs b/crates/engine/tests/it/table_resize_overlap.rs similarity index 100% rename from crates/engine/tests/table_resize_overlap.rs rename to crates/engine/tests/it/table_resize_overlap.rs diff --git a/crates/engine/tests/table_total_row_last_row.rs b/crates/engine/tests/it/table_total_row_last_row.rs similarity index 100% rename from crates/engine/tests/table_total_row_last_row.rs rename to crates/engine/tests/it/table_total_row_last_row.rs diff --git a/crates/engine/tests/wrap_row_height.rs b/crates/engine/tests/it/wrap_row_height.rs similarity index 100% rename from crates/engine/tests/wrap_row_height.rs rename to crates/engine/tests/it/wrap_row_height.rs diff --git a/crates/formula/tests/it/main.rs b/crates/formula/tests/it/main.rs new file mode 100644 index 000000000..01bff98c1 --- /dev/null +++ b/crates/formula/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +mod printer_roundtrip; +mod spanish_input; diff --git a/crates/formula/tests/printer_roundtrip.rs b/crates/formula/tests/it/printer_roundtrip.rs similarity index 100% rename from crates/formula/tests/printer_roundtrip.rs rename to crates/formula/tests/it/printer_roundtrip.rs diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/it/spanish_input.rs similarity index 100% rename from crates/formula/tests/spanish_input.rs rename to crates/formula/tests/it/spanish_input.rs diff --git a/crates/ui-egui/tests/accounting_fill.rs b/crates/ui-egui/tests/it/accounting_fill.rs similarity index 100% rename from crates/ui-egui/tests/accounting_fill.rs rename to crates/ui-egui/tests/it/accounting_fill.rs diff --git a/crates/ui-egui/tests/advanced_filter.rs b/crates/ui-egui/tests/it/advanced_filter.rs similarity index 100% rename from crates/ui-egui/tests/advanced_filter.rs rename to crates/ui-egui/tests/it/advanced_filter.rs diff --git a/crates/ui-egui/tests/argument_names.rs b/crates/ui-egui/tests/it/argument_names.rs similarity index 100% rename from crates/ui-egui/tests/argument_names.rs rename to crates/ui-egui/tests/it/argument_names.rs diff --git a/crates/ui-egui/tests/autosave_persist.rs b/crates/ui-egui/tests/it/autosave_persist.rs similarity index 100% rename from crates/ui-egui/tests/autosave_persist.rs rename to crates/ui-egui/tests/it/autosave_persist.rs diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/it/borders.rs similarity index 100% rename from crates/ui-egui/tests/borders.rs rename to crates/ui-egui/tests/it/borders.rs diff --git a/crates/ui-egui/tests/cell_editor_caret.rs b/crates/ui-egui/tests/it/cell_editor_caret.rs similarity index 100% rename from crates/ui-egui/tests/cell_editor_caret.rs rename to crates/ui-egui/tests/it/cell_editor_caret.rs diff --git a/crates/ui-egui/tests/cell_pictures.rs b/crates/ui-egui/tests/it/cell_pictures.rs similarity index 100% rename from crates/ui-egui/tests/cell_pictures.rs rename to crates/ui-egui/tests/it/cell_pictures.rs diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs similarity index 100% rename from crates/ui-egui/tests/chart_resize.rs rename to crates/ui-egui/tests/it/chart_resize.rs diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs similarity index 100% rename from crates/ui-egui/tests/clipboard.rs rename to crates/ui-egui/tests/it/clipboard.rs diff --git a/crates/ui-egui/tests/comments_pane.rs b/crates/ui-egui/tests/it/comments_pane.rs similarity index 100% rename from crates/ui-egui/tests/comments_pane.rs rename to crates/ui-egui/tests/it/comments_pane.rs diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/it/commit_after_pointing.rs similarity index 100% rename from crates/ui-egui/tests/commit_after_pointing.rs rename to crates/ui-egui/tests/it/commit_after_pointing.rs diff --git a/crates/ui-egui/tests/committed_text_fields.rs b/crates/ui-egui/tests/it/committed_text_fields.rs similarity index 100% rename from crates/ui-egui/tests/committed_text_fields.rs rename to crates/ui-egui/tests/it/committed_text_fields.rs diff --git a/crates/ui-egui/tests/context_menu_stays_open.rs b/crates/ui-egui/tests/it/context_menu_stays_open.rs similarity index 100% rename from crates/ui-egui/tests/context_menu_stays_open.rs rename to crates/ui-egui/tests/it/context_menu_stays_open.rs diff --git a/crates/ui-egui/tests/control_timeout.rs b/crates/ui-egui/tests/it/control_timeout.rs similarity index 100% rename from crates/ui-egui/tests/control_timeout.rs rename to crates/ui-egui/tests/it/control_timeout.rs diff --git a/crates/ui-egui/tests/dialog_button_row.rs b/crates/ui-egui/tests/it/dialog_button_row.rs similarity index 100% rename from crates/ui-egui/tests/dialog_button_row.rs rename to crates/ui-egui/tests/it/dialog_button_row.rs diff --git a/crates/ui-egui/tests/dialog_confirm.rs b/crates/ui-egui/tests/it/dialog_confirm.rs similarity index 100% rename from crates/ui-egui/tests/dialog_confirm.rs rename to crates/ui-egui/tests/it/dialog_confirm.rs diff --git a/crates/ui-egui/tests/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs similarity index 100% rename from crates/ui-egui/tests/dialog_focus.rs rename to crates/ui-egui/tests/it/dialog_focus.rs diff --git a/crates/ui-egui/tests/dialog_move.rs b/crates/ui-egui/tests/it/dialog_move.rs similarity index 100% rename from crates/ui-egui/tests/dialog_move.rs rename to crates/ui-egui/tests/it/dialog_move.rs diff --git a/crates/ui-egui/tests/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs similarity index 100% rename from crates/ui-egui/tests/drag_cursor.rs rename to crates/ui-egui/tests/it/drag_cursor.rs diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs similarity index 100% rename from crates/ui-egui/tests/file_menu.rs rename to crates/ui-egui/tests/it/file_menu.rs diff --git a/crates/ui-egui/tests/fill_handle_double_click.rs b/crates/ui-egui/tests/it/fill_handle_double_click.rs similarity index 100% rename from crates/ui-egui/tests/fill_handle_double_click.rs rename to crates/ui-egui/tests/it/fill_handle_double_click.rs diff --git a/crates/ui-egui/tests/fill_handle_right_drag.rs b/crates/ui-egui/tests/it/fill_handle_right_drag.rs similarity index 100% rename from crates/ui-egui/tests/fill_handle_right_drag.rs rename to crates/ui-egui/tests/it/fill_handle_right_drag.rs diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs similarity index 100% rename from crates/ui-egui/tests/fill_options_button.rs rename to crates/ui-egui/tests/it/fill_options_button.rs diff --git a/crates/ui-egui/tests/filter_list_scroll.rs b/crates/ui-egui/tests/it/filter_list_scroll.rs similarity index 100% rename from crates/ui-egui/tests/filter_list_scroll.rs rename to crates/ui-egui/tests/it/filter_list_scroll.rs diff --git a/crates/ui-egui/tests/filter_menu_enter.rs b/crates/ui-egui/tests/it/filter_menu_enter.rs similarity index 100% rename from crates/ui-egui/tests/filter_menu_enter.rs rename to crates/ui-egui/tests/it/filter_menu_enter.rs diff --git a/crates/ui-egui/tests/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs similarity index 100% rename from crates/ui-egui/tests/find_dialog_focus.rs rename to crates/ui-egui/tests/it/find_dialog_focus.rs diff --git a/crates/ui-egui/tests/floating_pictures.rs b/crates/ui-egui/tests/it/floating_pictures.rs similarity index 100% rename from crates/ui-egui/tests/floating_pictures.rs rename to crates/ui-egui/tests/it/floating_pictures.rs diff --git a/crates/ui-egui/tests/font_size_box.rs b/crates/ui-egui/tests/it/font_size_box.rs similarity index 100% rename from crates/ui-egui/tests/font_size_box.rs rename to crates/ui-egui/tests/it/font_size_box.rs diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/it/format_painter.rs similarity index 100% rename from crates/ui-egui/tests/format_painter.rs rename to crates/ui-egui/tests/it/format_painter.rs diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs similarity index 100% rename from crates/ui-egui/tests/formula_locale.rs rename to crates/ui-egui/tests/it/formula_locale.rs diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs similarity index 100% rename from crates/ui-egui/tests/formula_point_headers.rs rename to crates/ui-egui/tests/it/formula_point_headers.rs diff --git a/crates/ui-egui/tests/formula_popup.rs b/crates/ui-egui/tests/it/formula_popup.rs similarity index 100% rename from crates/ui-egui/tests/formula_popup.rs rename to crates/ui-egui/tests/it/formula_popup.rs diff --git a/crates/ui-egui/tests/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs similarity index 100% rename from crates/ui-egui/tests/freeze_handles.rs rename to crates/ui-egui/tests/it/freeze_handles.rs diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs similarity index 100% rename from crates/ui-egui/tests/header_autofit.rs rename to crates/ui-egui/tests/it/header_autofit.rs diff --git a/crates/ui-egui/tests/header_column_drag.rs b/crates/ui-egui/tests/it/header_column_drag.rs similarity index 100% rename from crates/ui-egui/tests/header_column_drag.rs rename to crates/ui-egui/tests/it/header_column_drag.rs diff --git a/crates/ui-egui/tests/host_chrome.rs b/crates/ui-egui/tests/it/host_chrome.rs similarity index 100% rename from crates/ui-egui/tests/host_chrome.rs rename to crates/ui-egui/tests/it/host_chrome.rs diff --git a/crates/ui-egui/tests/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs similarity index 100% rename from crates/ui-egui/tests/hyperlink_ctrl_click.rs rename to crates/ui-egui/tests/it/hyperlink_ctrl_click.rs diff --git a/crates/ui-egui/tests/in_cell_editor_size.rs b/crates/ui-egui/tests/it/in_cell_editor_size.rs similarity index 100% rename from crates/ui-egui/tests/in_cell_editor_size.rs rename to crates/ui-egui/tests/it/in_cell_editor_size.rs diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/it/in_cell_editor_wrap.rs similarity index 100% rename from crates/ui-egui/tests/in_cell_editor_wrap.rs rename to crates/ui-egui/tests/it/in_cell_editor_wrap.rs diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/it/insert_copied_cells.rs similarity index 100% rename from crates/ui-egui/tests/insert_copied_cells.rs rename to crates/ui-egui/tests/it/insert_copied_cells.rs diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/it/interaction.rs similarity index 100% rename from crates/ui-egui/tests/interaction.rs rename to crates/ui-egui/tests/it/interaction.rs diff --git a/crates/ui-egui/tests/interface_scale.rs b/crates/ui-egui/tests/it/interface_scale.rs similarity index 100% rename from crates/ui-egui/tests/interface_scale.rs rename to crates/ui-egui/tests/it/interface_scale.rs diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/keyboard_shortcuts.rs rename to crates/ui-egui/tests/it/keyboard_shortcuts.rs diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs similarity index 100% rename from crates/ui-egui/tests/keytips_generated.rs rename to crates/ui-egui/tests/it/keytips_generated.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..b89253bb9 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,91 @@ +//! The crate's integration tests, one module per file, built as one test binary. +//! +//! `header_autofit_fonts.rs` and `keytips.rs` stay separate binaries: they set process-wide state +//! (the installed-font catalog, a registered test command) that would leak into these tests. + +mod accounting_fill; +mod advanced_filter; +mod argument_names; +mod autosave_persist; +mod borders; +mod cell_editor_caret; +mod cell_pictures; +mod chart_resize; +mod clipboard; +mod comments_pane; +mod commit_after_pointing; +mod committed_text_fields; +mod context_menu_stays_open; +mod control_timeout; +mod dialog_button_row; +mod dialog_confirm; +mod dialog_focus; +mod dialog_move; +mod drag_cursor; +mod file_menu; +mod fill_handle_double_click; +mod fill_handle_right_drag; +mod fill_options_button; +mod filter_list_scroll; +mod filter_menu_enter; +mod find_dialog_focus; +mod floating_pictures; +mod font_size_box; +mod format_painter; +mod formula_locale; +mod formula_point_headers; +mod formula_popup; +mod freeze_handles; +mod header_autofit; +mod header_column_drag; +mod host_chrome; +mod hyperlink_ctrl_click; +mod in_cell_editor_size; +mod in_cell_editor_wrap; +mod insert_copied_cells; +mod interaction; +mod interface_scale; +mod keyboard_shortcuts; +mod keytips_generated; +mod menu_hover; +mod merged_cells; +mod more_colors; +mod native_print; +mod nowrap_line_breaks; +mod number_stored_as_text; +mod open_encrypted; +mod outline; +mod page_layout_view; +mod page_setup_margins; +mod paste_special_dialog; +mod pivot_pane; +mod popup_dismissal; +mod protect_password; +mod right_to_left; +mod scroll_position; +mod scrollbar_column_fill; +mod selection; +mod selection_fill; +mod selection_glide; +mod selection_scroll; +mod sheet_navigator; +mod sheet_tab_rename; +mod sheet_tabs; +mod show_objects; +mod shrink_to_fit; +mod sort_dialog_columns; +mod symbol_glyphs; +mod system_theme; +mod table_headings; +mod text_box_editing; +mod text_overflow_scroll; +mod touch_pan; +mod trace_navigation; +mod translated_form_fields; +mod ui_clock; +mod validation_alert; +mod validation_input_message; +mod wheel_zoom; +mod whole_line_shortcuts; +mod widgets; +mod zoom_dialog; diff --git a/crates/ui-egui/tests/menu_hover.rs b/crates/ui-egui/tests/it/menu_hover.rs similarity index 100% rename from crates/ui-egui/tests/menu_hover.rs rename to crates/ui-egui/tests/it/menu_hover.rs diff --git a/crates/ui-egui/tests/merged_cells.rs b/crates/ui-egui/tests/it/merged_cells.rs similarity index 100% rename from crates/ui-egui/tests/merged_cells.rs rename to crates/ui-egui/tests/it/merged_cells.rs diff --git a/crates/ui-egui/tests/more_colors.rs b/crates/ui-egui/tests/it/more_colors.rs similarity index 100% rename from crates/ui-egui/tests/more_colors.rs rename to crates/ui-egui/tests/it/more_colors.rs diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/it/native_print.rs similarity index 100% rename from crates/ui-egui/tests/native_print.rs rename to crates/ui-egui/tests/it/native_print.rs diff --git a/crates/ui-egui/tests/nowrap_line_breaks.rs b/crates/ui-egui/tests/it/nowrap_line_breaks.rs similarity index 100% rename from crates/ui-egui/tests/nowrap_line_breaks.rs rename to crates/ui-egui/tests/it/nowrap_line_breaks.rs diff --git a/crates/ui-egui/tests/number_stored_as_text.rs b/crates/ui-egui/tests/it/number_stored_as_text.rs similarity index 100% rename from crates/ui-egui/tests/number_stored_as_text.rs rename to crates/ui-egui/tests/it/number_stored_as_text.rs diff --git a/crates/ui-egui/tests/open_encrypted.rs b/crates/ui-egui/tests/it/open_encrypted.rs similarity index 100% rename from crates/ui-egui/tests/open_encrypted.rs rename to crates/ui-egui/tests/it/open_encrypted.rs diff --git a/crates/ui-egui/tests/outline.rs b/crates/ui-egui/tests/it/outline.rs similarity index 100% rename from crates/ui-egui/tests/outline.rs rename to crates/ui-egui/tests/it/outline.rs diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/it/page_layout_view.rs similarity index 100% rename from crates/ui-egui/tests/page_layout_view.rs rename to crates/ui-egui/tests/it/page_layout_view.rs diff --git a/crates/ui-egui/tests/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs similarity index 100% rename from crates/ui-egui/tests/page_setup_margins.rs rename to crates/ui-egui/tests/it/page_setup_margins.rs diff --git a/crates/ui-egui/tests/paste_special_dialog.rs b/crates/ui-egui/tests/it/paste_special_dialog.rs similarity index 100% rename from crates/ui-egui/tests/paste_special_dialog.rs rename to crates/ui-egui/tests/it/paste_special_dialog.rs diff --git a/crates/ui-egui/tests/pivot_pane.rs b/crates/ui-egui/tests/it/pivot_pane.rs similarity index 100% rename from crates/ui-egui/tests/pivot_pane.rs rename to crates/ui-egui/tests/it/pivot_pane.rs diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/it/popup_dismissal.rs similarity index 100% rename from crates/ui-egui/tests/popup_dismissal.rs rename to crates/ui-egui/tests/it/popup_dismissal.rs diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/it/protect_password.rs similarity index 100% rename from crates/ui-egui/tests/protect_password.rs rename to crates/ui-egui/tests/it/protect_password.rs diff --git a/crates/ui-egui/tests/right_to_left.rs b/crates/ui-egui/tests/it/right_to_left.rs similarity index 100% rename from crates/ui-egui/tests/right_to_left.rs rename to crates/ui-egui/tests/it/right_to_left.rs diff --git a/crates/ui-egui/tests/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs similarity index 100% rename from crates/ui-egui/tests/scroll_position.rs rename to crates/ui-egui/tests/it/scroll_position.rs diff --git a/crates/ui-egui/tests/scrollbar_column_fill.rs b/crates/ui-egui/tests/it/scrollbar_column_fill.rs similarity index 100% rename from crates/ui-egui/tests/scrollbar_column_fill.rs rename to crates/ui-egui/tests/it/scrollbar_column_fill.rs diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/it/selection.rs similarity index 100% rename from crates/ui-egui/tests/selection.rs rename to crates/ui-egui/tests/it/selection.rs diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/it/selection_fill.rs similarity index 100% rename from crates/ui-egui/tests/selection_fill.rs rename to crates/ui-egui/tests/it/selection_fill.rs diff --git a/crates/ui-egui/tests/selection_glide.rs b/crates/ui-egui/tests/it/selection_glide.rs similarity index 100% rename from crates/ui-egui/tests/selection_glide.rs rename to crates/ui-egui/tests/it/selection_glide.rs diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/it/selection_scroll.rs similarity index 100% rename from crates/ui-egui/tests/selection_scroll.rs rename to crates/ui-egui/tests/it/selection_scroll.rs diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/it/sheet_navigator.rs similarity index 100% rename from crates/ui-egui/tests/sheet_navigator.rs rename to crates/ui-egui/tests/it/sheet_navigator.rs diff --git a/crates/ui-egui/tests/sheet_tab_rename.rs b/crates/ui-egui/tests/it/sheet_tab_rename.rs similarity index 100% rename from crates/ui-egui/tests/sheet_tab_rename.rs rename to crates/ui-egui/tests/it/sheet_tab_rename.rs diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/it/sheet_tabs.rs similarity index 100% rename from crates/ui-egui/tests/sheet_tabs.rs rename to crates/ui-egui/tests/it/sheet_tabs.rs diff --git a/crates/ui-egui/tests/show_objects.rs b/crates/ui-egui/tests/it/show_objects.rs similarity index 100% rename from crates/ui-egui/tests/show_objects.rs rename to crates/ui-egui/tests/it/show_objects.rs diff --git a/crates/ui-egui/tests/shrink_to_fit.rs b/crates/ui-egui/tests/it/shrink_to_fit.rs similarity index 100% rename from crates/ui-egui/tests/shrink_to_fit.rs rename to crates/ui-egui/tests/it/shrink_to_fit.rs diff --git a/crates/ui-egui/tests/sort_dialog_columns.rs b/crates/ui-egui/tests/it/sort_dialog_columns.rs similarity index 100% rename from crates/ui-egui/tests/sort_dialog_columns.rs rename to crates/ui-egui/tests/it/sort_dialog_columns.rs diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/it/symbol_glyphs.rs similarity index 100% rename from crates/ui-egui/tests/symbol_glyphs.rs rename to crates/ui-egui/tests/it/symbol_glyphs.rs diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/it/system_theme.rs similarity index 100% rename from crates/ui-egui/tests/system_theme.rs rename to crates/ui-egui/tests/it/system_theme.rs diff --git a/crates/ui-egui/tests/table_headings.rs b/crates/ui-egui/tests/it/table_headings.rs similarity index 100% rename from crates/ui-egui/tests/table_headings.rs rename to crates/ui-egui/tests/it/table_headings.rs diff --git a/crates/ui-egui/tests/text_box_editing.rs b/crates/ui-egui/tests/it/text_box_editing.rs similarity index 100% rename from crates/ui-egui/tests/text_box_editing.rs rename to crates/ui-egui/tests/it/text_box_editing.rs diff --git a/crates/ui-egui/tests/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs similarity index 100% rename from crates/ui-egui/tests/text_overflow_scroll.rs rename to crates/ui-egui/tests/it/text_overflow_scroll.rs diff --git a/crates/ui-egui/tests/touch_pan.rs b/crates/ui-egui/tests/it/touch_pan.rs similarity index 100% rename from crates/ui-egui/tests/touch_pan.rs rename to crates/ui-egui/tests/it/touch_pan.rs diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/it/trace_navigation.rs similarity index 100% rename from crates/ui-egui/tests/trace_navigation.rs rename to crates/ui-egui/tests/it/trace_navigation.rs diff --git a/crates/ui-egui/tests/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs similarity index 100% rename from crates/ui-egui/tests/translated_form_fields.rs rename to crates/ui-egui/tests/it/translated_form_fields.rs diff --git a/crates/ui-egui/tests/ui_clock.rs b/crates/ui-egui/tests/it/ui_clock.rs similarity index 100% rename from crates/ui-egui/tests/ui_clock.rs rename to crates/ui-egui/tests/it/ui_clock.rs diff --git a/crates/ui-egui/tests/validation_alert.rs b/crates/ui-egui/tests/it/validation_alert.rs similarity index 100% rename from crates/ui-egui/tests/validation_alert.rs rename to crates/ui-egui/tests/it/validation_alert.rs diff --git a/crates/ui-egui/tests/validation_input_message.rs b/crates/ui-egui/tests/it/validation_input_message.rs similarity index 100% rename from crates/ui-egui/tests/validation_input_message.rs rename to crates/ui-egui/tests/it/validation_input_message.rs diff --git a/crates/ui-egui/tests/wheel_zoom.rs b/crates/ui-egui/tests/it/wheel_zoom.rs similarity index 100% rename from crates/ui-egui/tests/wheel_zoom.rs rename to crates/ui-egui/tests/it/wheel_zoom.rs diff --git a/crates/ui-egui/tests/whole_line_shortcuts.rs b/crates/ui-egui/tests/it/whole_line_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/whole_line_shortcuts.rs rename to crates/ui-egui/tests/it/whole_line_shortcuts.rs diff --git a/crates/ui-egui/tests/widgets.rs b/crates/ui-egui/tests/it/widgets.rs similarity index 100% rename from crates/ui-egui/tests/widgets.rs rename to crates/ui-egui/tests/it/widgets.rs diff --git a/crates/ui-egui/tests/zoom_dialog.rs b/crates/ui-egui/tests/it/zoom_dialog.rs similarity index 100% rename from crates/ui-egui/tests/zoom_dialog.rs rename to crates/ui-egui/tests/it/zoom_dialog.rs diff --git a/docs/roadmap.md b/docs/roadmap.md index 4894d77b1..62e1735f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTable calculated fields) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (test command) · **Target:** Microsoft Excel (Microsoft 365) Forward-looking plan. The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); numbers and method are in @@ -100,7 +100,8 @@ to check our files, and Windows machines with the reported GPU drivers. - `cargo xtask parity` regenerates [`parity-checklist.md`](parity-checklist.md) (ribbon catalog vs registered commands); `cargo xtask ci` checks it is current. -- `cargo test --workspace`: engine, formula, function, XLSX and UI behaviour. +- `cargo xtask test`: engine, formula, function, XLSX and UI behaviour (cargo-nextest when + installed, else `cargo test`), then the doctests. - `cargo run --release -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`: look at the UI offscreen. - Function coverage: the script method in [function-parity.md](function-parity.md). A @@ -112,6 +113,7 @@ to check our files, and Windows machines with the reported GPU drivers. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Measuring: tests run through `cargo xtask test` | | 2026-10-11 | minor | PivotTable calculated fields landed | | 2026-10-10 | minor | Current focus and M15 updated for the 10-10 merges | | 2026-10-10 | minor | Added the alpha gate table (six core workflows; passes, stays alpha) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08a9124bd..99a1e0717 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -341,7 +341,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | -| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | +| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/it/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | | 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 63b1554a0..f6df981b7 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -1,6 +1,6 @@ //! Regenerate the keytip sequence table and its per-sequence test suite from `keytips.json`. //! -//! `cargo xtask keytips` regenerate the table + `tests/keytips_generated.rs` +//! `cargo xtask keytips` regenerate the table + `tests/it/keytips_generated.rs` //! `cargo xtask keytips --check` fail if either generated file differs from the JSON source //! //! Requires the default `engine` feature: the generator probes the live command @@ -92,7 +92,7 @@ pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> { { let json_path = root.join("crates/ui-egui/src/keytips/keytips.json"); let table_path = root.join("crates/ui-egui/src/keytips/keytip_table/generated.rs"); - let tests_path = root.join("crates/ui-egui/tests/keytips_generated.rs"); + let tests_path = root.join("crates/ui-egui/tests/it/keytips_generated.rs"); let source = fs::read_to_string(&json_path).map_err(|e| format!("read {}: {e}", json_path.display()))?; let (entries, probe) = parse_and_probe(&source)?; let generated = diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 14e9af32a..0c9f5547d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,17 +15,19 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask commands: - ci fmt --check, clippy -D warnings, test, assets, layers, wasm, parity --check - (stops at the first failure; the parity check runs only if docs/parity-checklist.md exists) + 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) + 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 layers enforce the crate dependency layering (AGENTS.md \"Layering\") - wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web) + wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call parity [--check] [--out PATH] write docs/parity-checklist.md from gridcraft_engine::catalog (implemented vs missing ids) keytips [--check] @@ -44,6 +46,7 @@ fn main() -> ExitCode { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root(), &rest), @@ -139,17 +142,19 @@ fn wasm_set() -> Result, String> { .collect()) } +/// One `cargo check` for the whole set; `--keep-going` reports every failing crate, not just the +/// first. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; if set.is_empty() { return Err("no wasm crates found in the workspace".into()); } let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown"]); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for p in &set { c.args(["-p", p]); } - let r = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {}", set.join(" -p "))); + let r = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))); println!("\nwasm32-unknown-unknown check ({}): {}", set.join(", "), if r.is_ok() { "ok" } else { "FAIL" }); if r.is_err() { println!("(if the target is missing: rustup target add wasm32-unknown-unknown)"); @@ -157,6 +162,35 @@ fn cmd_wasm() -> Result<(), String> { r } +/// Targets for the test build. Examples are left out so they aren't linked; clippy +/// `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") +} + fn cmd_ci() -> Result<(), String> { type Step = (&'static str, Box Result<(), String>>); let steps: Vec = vec![ @@ -172,18 +206,12 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets -- -D warnings") - }), - ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") + c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") }), ), + ("test", Box::new(cmd_test)), + ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), From 33a45130a1786bd9dce1aa2f8b431339a56ed8ee Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:16 -0400 Subject: [PATCH 32/63] Packaging: pin the signing client and appimagetool, and check their hashes (#2073) * Packaging: pin the Windows signing client and check its hash * Packaging: pin appimagetool and check its hash * Packaging: pin the AppImage runtime and stop clearly without a pinned tool * Packaging: shellcheck-clean empty hashes --- packaging/linux/package.sh | 66 +++++++++++++++++++++++++++++++------- packaging/windows/sign.ps1 | 13 ++++++-- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index f8c53d6b3..4de3aaf80 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -9,8 +9,9 @@ # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage -# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, +# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool and the AppImage +# runtime for the AppImage (pinned downloads into $CARGO_TARGET_DIR, or APPIMAGETOOL and +# APPIMAGE_RUNTIME, e.g. on riscv64, which has no pinned ones). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail @@ -25,7 +26,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,17p' "$0"; exit 0 ;; + -h | --help) sed -n '2,18p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -102,6 +103,32 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + # appimagetool would otherwise embed type2-runtime's unchecked `continuous` build. + APPIMAGE_RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + *) APPIMAGETOOL_SHA256='' APPIMAGE_RUNTIME_SHA256='' ;; + esac + # Downloads a pinned file into the cache once and checks its hash on every run. + fetch_pinned() { + local path="$1" url="$2" sha256="$3" + if [[ ! -x "$path" ]]; then + curl --proto '=https' -fsSL -o "$path.download" "$url" + printf '%s %s\n' "$sha256" "$path.download" | sha256sum -c - + chmod 755 "$path.download" + mv "$path.download" "$path" + fi + printf '%s %s\n' "$sha256" "$path" | sha256sum -c - + return + } APPDIR="$WORK/GridCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -111,18 +138,33 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [[ -z "$TOOL" ]]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [[ ! -x "$TOOL" ]]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" - fi + if [[ -z "$TOOL" ]] && [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi - # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be - # relative, e.g. target/agent-). + RUNTIME="${APPIMAGE_RUNTIME:-}" + if [[ -z "$RUNTIME" ]] && [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH; set APPIMAGE_RUNTIME" >&2 + exit 2 + fi + if [[ -z "$TOOL" ]]; then + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + fetch_pinned "$TOOL" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" \ + "$APPIMAGETOOL_SHA256" + fi + if [[ -z "$RUNTIME" ]]; then + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" + fetch_pinned "$RUNTIME" \ + "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" \ + "$APPIMAGE_RUNTIME_SHA256" + fi + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR, APPIMAGETOOL or + # APPIMAGE_RUNTIME may be relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -134,7 +176,7 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index a886a8de6..222d5705b 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'GridCraft', '/du', 'https://github.com/storytold/gridcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "gridcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "gridcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From aa8399be9507ebc789063117c71d02efb5fb7251 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:14:15 -0400 Subject: [PATCH 33/63] Grid: #### is sized by the width of #, so it fits its cell in every font (#2378) A number too wide for its column is drawn as ####, with as many # as fit. The count used the width of 0, and in fonts where # is wider than 0 the hashes ran past the cell edge. accounting_fill::too_wide_fill_number_shows_hashes_not_a_clipped_value caught it on FreeBSD and on Linux machines without the CI font set. --- crates/ui-egui/src/grid.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..590294904 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1300,7 +1300,9 @@ pub(crate) fn paint_quadrant( // Numbers and dates never overflow: they show #### when too narrow. let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; if hashed { - let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; + // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. + let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); + let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; } From 08820cb4b27c274258bd55576caa621ac31ffbb2 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:32:15 -0400 Subject: [PATCH 34/63] CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#2338) ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69. --- .github/workflows/ci.yml | 44 +++++++++-------------------------- .github/workflows/freebsd.yml | 5 ++++ 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77cde0cec..52068cea1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ -# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, -# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land -# a red tree that only the next agent session discovers. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -12,37 +11,16 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: gate: - name: cargo xtask ci - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Linux build dependencies (winit, wgpu, rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ - libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev + libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6cdde6e47..054491322 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,6 +2,9 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: branches: [release] @@ -10,6 +13,8 @@ on: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "57 2 * * *" workflow_dispatch: concurrency: From 530a8cc810ea82daf318575a560d99f5c48ac3e0 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:47:33 -0400 Subject: [PATCH 35/63] CI: the gate builds with the release craft-fonts pin (#2381) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52068cea1..c704eb4d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: + # The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers From 18ec913c4e84a426700942ec96a4c3f2c44a4ba0 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:57 -0400 Subject: [PATCH 36/63] 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. --- crates/engine/src/lib.rs | 32 +++++++++++++-------- crates/engine/src/tests/seams.rs | 48 ++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8614bc094..588a8a2a2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,6 +97,17 @@ struct OpenGroup { redo: Vec, } +/// The undo step the last [`Session::group_end`] made: its document and the workbook it led to. +/// The entry itself holds the workbook it started from, so a command can check that the step is +/// exactly its own (the same start and end as the command's) and keep the group's name for it. +/// A weak reference: the record keeps no workbook alive, and a workbook that is gone can't be +/// a command's result. +#[derive(Debug)] +struct GroupStep { + doc: u64, + after: std::sync::Weak, +} + /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { @@ -405,8 +416,9 @@ pub struct Session { next_group: u64, /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. group_epoch: u64, - /// The step the last group made: document, its undo length after the step, and the epoch. - group_step: Option<(u64, usize, u64)>, + /// The step the last `group_end` made, so `execute` can tell that a command's step is exactly + /// its group's. + group_step: Option, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. pub text_width: Option, @@ -567,12 +579,14 @@ impl Session { let mut label = spec.label.trim_end_matches('…').to_string(); if own_groups && let Some((_, len)) = start { // A command whose only step is its group's keeps that name ("Run Macro: Totals"). - // The step must have been made by a group this command ended (its epoch is - // past the one the command started with): an older record at the same length - // belongs to an earlier command, undone since. + // 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 - && self.group_step.is_some_and(|(doc, n, epoch)| doc == uid && n == len + 1 && epoch > group_epoch) && 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.after.upgrade().is_some_and(|after| Arc::ptr_eq(&after, &d.wb))) { label = last.label.clone(); } @@ -646,7 +660,7 @@ impl Session { 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); - self.group_step = Some((g.doc, d.undo.len(), self.group_epoch)); + self.group_step = Some(GroupStep { doc: g.doc, after: Arc::downgrade(&d.wb) }); d.redo.clear(); d.revision += 1; } @@ -679,10 +693,6 @@ impl Session { d.undo.truncate(g.start); d.redo = g.redo; d.revision += 1; - // A step the rollback dropped is no longer anyone's to name. - if self.group_step.is_some_and(|(doc, n, _)| doc == g.doc && n > g.start) { - self.group_step = None; - } Ok(()) } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index b3397c724..a598bb7c7 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -226,6 +226,27 @@ fn register_group_commands() { }); // A command that is one group keeps the group's name. register("test.onlyGroup", |s, _| s.grouped("Named Step", |s| put(s, "D1", "x")).map(|_| json!({}))); + // A group, then an edit the command makes itself (no nested command, so no undo entry). + register("test.groupThenEdit", |s, _| { + s.grouped("Named Step", |s| put(s, "D1", "x"))?; + crate::cmd::edit(s, |cx| { + let at = gridcraft_core::CellRef::parse("D2").ok_or_else(|| EngineError::Other("D2".into()))?; + cx.sheet_mut(0)?.set_value(at, gridcraft_core::Value::Text("y".into())); + cx.touch(0, at); + Ok(()) + })?; + Ok(json!({})) + }); + // A group with a change, then one that is rolled back: the step is still the first group's. + register("test.groupThenRolledBack", |s, _| { + s.grouped("Named Step", |s| put(s, "D1", "x"))?; + let failed: crate::Result<()> = s.grouped("Doomed", |s| { + put(s, "D3", "gone")?; + Err(EngineError::Other("no".into())) + }); + assert!(failed.is_err()); + Ok(json!({})) + }); // An edit, then the rollback of the innermost group that was open before the command. register("test.rollbackOpenGroup", |s, _| { put(s, "E1", "gone")?; @@ -266,9 +287,9 @@ fn a_command_that_runs_its_own_group_makes_one_step() { #[test] fn a_stale_group_step_does_not_name_a_later_command() { - // The record of the last group step (document, length, epoch) survives the Undo of that step. - // A later command that ends a group of its own at the same history length, after rolling - // another back, must not take the name of the nested edit it made. + // The record of the last group step survives the Undo of that step. A later command that + // ends a group of its own at the same history length, after rolling another back, must not + // take the name of the nested edit it made. register_group_commands(); let mut s = s(); s.execute("test.onlyGroup", json!({})).unwrap(); @@ -285,6 +306,27 @@ fn a_stale_group_step_does_not_name_a_later_command() { assert!(v(&s, "B2").is_empty() && v(&s, "D1").is_empty()); } +#[test] +fn a_command_keeps_its_groups_name_only_for_exactly_that_step() { + // The name comes from the group only when the group's step is the command's whole step: an + // edit after the group (no nested command, so the history length matches) makes it the + // command's own, while a later group that rolled back changes nothing. + register_group_commands(); + let mut s = s(); + s.execute("test.groupThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["test.groupThenEdit"]); + assert_eq!((v(&s, "D1").display(), v(&s, "D2").display()), ("x".into(), "y".into())); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty() && v(&s, "D2").is_empty()); + assert_eq!(undo_labels(&s), Vec::::new()); + s.execute("test.groupThenRolledBack", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Named Step"]); + assert_eq!(v(&s, "D1").display(), "x"); + assert!(v(&s, "D3").is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "D1").is_empty()); +} + #[test] fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback From a8e8bf851e7d7996c5321f21f3ce42d1e1a97280 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:57 -0400 Subject: [PATCH 37/63] 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. --- crates/engine/src/record.rs | 15 +++++++++++++-- crates/engine/src/tests/seams.rs | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs index dbbc1a8af..fefd07234 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -13,6 +13,10 @@ 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)] @@ -34,14 +38,21 @@ pub struct RecordedStep { #[derive(Clone, Debug, Default)] pub struct Recorder { pub steps: Vec, - /// Steps not kept because the recording reached [`RECORD_LIMIT`]. + /// 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) { - if self.steps.len() < RECORD_LIMIT { + 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 { + 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/tests/seams.rs b/crates/engine/src/tests/seams.rs index a598bb7c7..c524b397d 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -494,6 +494,31 @@ fn the_recorder_has_a_cap() { assert_eq!(rec.dropped, 3); } +#[test] +fn the_recorder_has_a_byte_cap_and_keeps_a_prefix() { + // A `file.open` step holds the whole file as base64: the buffer is bounded in bytes too, and + // once a step is dropped every later one is, so what is kept is always how the recording began. + let mut rec = crate::record::Recorder::default(); + let step = |n: usize| crate::record::RecordedStep { + doc: 1, + sheet: 0, + sheet_name: "Sheet1".into(), + selection: crate::Selection::default(), + id: "file.open".into(), + params: json!({"base64": "A".repeat(n)}), + }; + let big = crate::record::RECORD_MAX_BYTES / 2; + rec.push(step(big)); + assert_eq!((rec.steps.len(), rec.dropped), (1, 0)); + rec.push(step(big)); + assert_eq!((rec.steps.len(), rec.dropped), (1, 1), "the second does not fit beside the first"); + rec.push(step(1)); + assert_eq!((rec.steps.len(), rec.dropped), (1, 2), "a small step after a dropped one is dropped too"); + let mut rec = crate::record::Recorder::default(); + rec.push(step(crate::record::RECORD_MAX_BYTES + 1)); + assert_eq!((rec.steps.len(), rec.dropped), (0, 1), "a step larger than the whole budget is not kept"); +} + #[test] fn passwords_stay_out_of_the_recording_buffer() { const PW: &str = "synthetic-pw-5521"; From 524826687ccc8b9abb700e7c55c42f03c379a45a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:17:57 -0400 Subject: [PATCH 38/63] 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). --- docs/architecture.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b656afc66..f45774508 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **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). @@ -63,7 +63,9 @@ Actions made of many commands (macros, a debugger that runs one step per call) u (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 formulas or - validation pasted from one. Active content in a `FromFile` workbook asks before it runs. + validation pasted from one. 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. The older single-name `_sc_` @@ -94,6 +96,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-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | -| 2026-10-11 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | From 5ca886507c91f8de8ef90a3743e9be299ea6f62c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:21:48 -0400 Subject: [PATCH 39/63] 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. --- crates/engine/src/cmd/stash.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 9043262d2..683c42c2d 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -65,14 +65,17 @@ pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { } 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. - for i in 0..formulas.len() { - let name = format!("{PREFIX}{key}_{i}"); - if wb.names.iter().any(|n| n.scope.is_none() && !n.hidden && n.name.eq_ignore_ascii_case(&name)) { - return Err(EngineError::Other(format!( - "The workbook has a defined name '{name}', which GridCraft needs for its own data. Rename it first." - ))); - } + // 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() { From 114088d589bf661ecff010a9aa162470bbd2e11e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:33:42 -0400 Subject: [PATCH 40/63] 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. --- crates/engine/src/lib.rs | 73 ++++++++++++++++++++++++-------- crates/engine/src/tests/seams.rs | 63 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 588a8a2a2..b44ed0852 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,15 +97,19 @@ struct OpenGroup { redo: Vec, } -/// The undo step the last [`Session::group_end`] made: its document and the workbook it led to. -/// The entry itself holds the workbook it started from, so a command can check that the step is -/// exactly its own (the same start and end as the command's) and keep the group's name for it. -/// A weak reference: the record keeps no workbook alive, and a workbook that is gone can't be -/// a command's result. +/// 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: std::sync::Weak, + after: Arc, + selection: Selection, + len: usize, + stepped: bool, } /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. @@ -416,8 +420,7 @@ pub struct Session { next_group: u64, /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. group_epoch: u64, - /// The step the last `group_end` made, so `execute` can tell that a command's step is exactly - /// its group's. + /// Where the last group ended, so `execute` can tell a command's own changes from its group's. group_step: Option, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. @@ -549,6 +552,7 @@ impl Session { } if self.depth == 0 { self.trim_history(); + self.group_step = None; } return Err(EngineError::Internal(id.to_string(), msg)); } @@ -563,21 +567,44 @@ impl Session { } self.push_journal(id, cmd::journal_params(¶ms)); } - // Inside an undo group, or when the command ended one that was open before it, the group - // owns the step. A command that opened and closed its own groups makes one step of its - // own, which replaces the steps those groups made. + // 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 own_groups = epoch_changed && !in_group_now && start.is_some_and(|(was_open, _)| !was_open); - let grouped = !own_groups && (epoch_changed || in_group_now); + 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) { - if !grouped { - let mut label = spec.label.trim_end_matches('…').to_string(); - if own_groups && let Some((_, len)) = start { + 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, @@ -586,7 +613,7 @@ impl Session { 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.after.upgrade().is_some_and(|after| Arc::ptr_eq(&after, &d.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(); } @@ -603,6 +630,7 @@ impl Session { // lengths it compared stay true. if self.depth == 0 { self.trim_history(); + self.group_step = None; } result } @@ -660,10 +688,13 @@ impl Session { 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); - self.group_step = Some(GroupStep { doc: g.doc, after: Arc::downgrade(&d.wb) }); d.redo.clear(); d.revision += 1; } + 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(); @@ -693,6 +724,12 @@ impl Session { 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(()) } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index c524b397d..1f83bd67d 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -261,6 +261,20 @@ fn register_group_commands() { s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?; Ok(json!({})) }); + // The end of the innermost group that was open before the command, then an edit. + register("test.endOpenGroupThenEdit", |s, _| { + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }); + s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?)?; + put(s, "E1", "after")?; + Ok(json!({})) + }); + // An edit, then a group the command leaves open (a debugger's start). + register("test.editThenOpenGroup", |s, _| { + put(s, "A1", "first")?; + let _open = s.group_begin("Debug")?; + put(s, "A2", "second")?; + Ok(json!({})) + }); } #[test] @@ -327,6 +341,55 @@ fn a_command_keeps_its_groups_name_only_for_exactly_that_step() { assert!(v(&s, "D1").is_empty()); } +#[test] +fn what_a_command_changes_after_ending_a_group_is_its_own_step() { + // Ending a group that was open before the command leaves the group its step (if it made one) + // and makes a step of the command's own for what it changed after the end. + register_group_commands(); + let mut s = s(); + // The group made no step: the command's edit must still be undoable, under its own name. + let _unchanged = s.group_begin("Debug").unwrap(); + s.execute("test.endOpenGroupThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["test.endOpenGroupThenEdit"]); + assert_eq!(v(&s, "E1").display(), "after"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E1").is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "E1").display(), "after"); + // The group made a step: two steps, the group's and the command's. + s.execute("edit.undo", json!({})).unwrap(); + let _changed = s.group_begin("Debug").unwrap(); + set(&mut s, "B1", "inside"); + s.execute("test.endOpenGroupThenEdit", json!({})).unwrap(); + assert_eq!(undo_labels(&s), ["Debug", "test.endOpenGroupThenEdit"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "E1").is_empty()); + assert_eq!(v(&s, "B1").display(), "inside"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "B1").is_empty()); +} + +#[test] +fn a_group_a_command_leaves_open_starts_where_the_command_did() { + // A command that edits and then opens a group it leaves open has no undo step of its own: + // the group's step, made when it ends, covers the command's edit too, so no state from + // inside the command can come back. + register_group_commands(); + let mut s = s(); + set(&mut s, "C1", "base"); + s.execute("test.editThenOpenGroup", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert!(s.in_group(d.uid)); + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }).unwrap(); + assert!(s.group_end(token).unwrap()); + assert_eq!(undo_labels(&s), ["Enter Cell", "Debug"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(v(&s, "A1").is_empty() && v(&s, "A2").is_empty()); + assert_eq!(v(&s, "C1").display(), "base"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("first".into(), "second".into())); +} + #[test] fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback From 6ab8d97118a89eed61c289c333a00693c59e35e8 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:35:45 -0400 Subject: [PATCH 41/63] 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. --- crates/engine/src/lib.rs | 31 ++++++++++++++++++++++++++++++- crates/engine/src/tests/seams.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b44ed0852..6e37058dd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,6 +422,10 @@ pub struct Session { 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, @@ -533,9 +537,15 @@ impl Session { 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 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) => { @@ -627,10 +637,12 @@ impl Session { d.revision += 1; } // The history limit applies once the outermost command has collapsed its steps, so the - // lengths it compared stay true. + // 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 } @@ -702,6 +714,23 @@ impl Session { Ok(changed) } + /// 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) { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 1f83bd67d..3216cfd97 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -268,6 +268,19 @@ fn register_group_commands() { put(s, "E1", "after")?; Ok(json!({})) }); + // Many nested edits: the steps they make are bounded while the command runs. (Not undoable + // itself: like a script, its nested steps are the undo steps.) + crate::cmd::register_test_command("test.manyNestedEdits", |s, _| { + let start = s.doc()?.undo.len(); + for i in 0..crate::UNDO_LIMIT + 50 { + put(s, "A1", &i.to_string())?; + } + let len = s.doc()?.undo.len(); + if len > start + crate::UNDO_LIMIT { + return Err(EngineError::Other(format!("{} steps kept inside the command", len - start))); + } + Ok(json!({})) + }); // An edit, then a group the command leaves open (a debugger's start). register("test.editThenOpenGroup", |s, _| { put(s, "A1", "first")?; @@ -390,6 +403,25 @@ fn a_group_a_command_leaves_open_starts_where_the_command_did() { assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("first".into(), "second".into())); } +#[test] +fn steps_made_inside_a_command_are_bounded_while_it_runs() { + // The history limit applies when the outermost command finishes, so a command's bookkeeping + // holds; but a command that runs thousands of nested commands (a script, a subtotal) must not + // keep thousands of steps alive until then. Each nested step bounds the steps made since the + // command began, and leaves the history from before it alone. + register_group_commands(); + let mut s = s(); + let before = fill_history(&mut s); + s.execute("test.manyNestedEdits", json!({})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.undo.len(), crate::UNDO_LIMIT); + assert!(d.undo.iter().all(|e| e.label == "Enter Cell")); + assert!(!d.undo.iter().any(|e| before.iter().any(|w| std::sync::Arc::ptr_eq(w, &e.wb))), "the older history gave way to the newest steps"); + assert_eq!(v(&s, "A1").display(), "149"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "148"); +} + #[test] fn a_group_rolled_back_inside_a_command_keeps_the_redo_history() { // The debugger's Stop with Undo Changes rolls its group back inside a command. The rollback From 080b6d993b1682010a613ca41389dc953a22e6b1 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:36:34 -0400 Subject: [PATCH 42/63] 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. --- crates/engine/src/lib.rs | 12 ++++++++---- crates/engine/src/tests/seams.rs | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e37058dd..ad740e3f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -652,11 +652,15 @@ impl Session { /// 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(); match f(self) { - Ok(r) => { - self.group_end(token)?; - Ok(r) - } + 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), + }, 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. diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 3216cfd97..a5cd0d868 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -170,6 +170,27 @@ fn a_long_group_keeps_the_history_before_it() { assert_eq!(d.undo.iter().filter(|e| e.label == "Enter Cell").count(), 99); } +#[test] +fn a_grouped_action_that_closes_its_document_succeeds() { + // The group goes with the document; the action did what it was asked. + let mut s = s(); + let uid = s.doc().unwrap().uid; + s.grouped("Close", |s| s.execute("file.close", json!({}))).unwrap(); + assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid)); + // Opening a file into an untouched new workbook takes its place the same way. + s.new_workbook(); + let uid = s.doc().unwrap().uid; + let bytes = serde_json::to_vec(&gridcraft_model::Workbook::new()).unwrap(); + s.grouped("Open", |s| s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)}))).unwrap(); + assert!(s.docs.iter().all(|d| d.uid != uid) && !s.in_group(uid) && s.groups.is_empty()); + // A group ended by someone else while its document is still open is still an error. + let err = s.grouped("Ended", |s| { + let token = s.groups.last().map(|g| crate::GroupToken { id: g.id, doc: g.doc }); + s.group_end(token.ok_or_else(|| EngineError::Other("no group".into()))?).map(|_| ()) + }); + assert!(err.unwrap_err().to_string().contains("already ended")); +} + #[test] fn closing_a_document_drops_its_groups() { let mut s = s(); From 7e383f460a069e2eb44140d9026d46351815e0d7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:36:52 -0400 Subject: [PATCH 43/63] Docs: undo groups at a command's boundaries, and the bound on steps made inside a command --- docs/architecture.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index f45774508..e44e80083 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,10 @@ Actions made of many commands (macros, a debugger that runs one step per call) u While a group is open on a document, its commands add no undo steps of their own and Undo and Redo are disabled there. Groups nest; the outermost one makes the step. A command that opens and closes its own groups still makes one undo step for itself, covering its edits inside and outside - the groups. + 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. - **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 From 66e69cb9466095f8b87380c5bcc47049c26d3f0f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:01:21 -0400 Subject: [PATCH 44/63] 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. --- crates/engine/src/cmd/edit.rs | 5 +++-- crates/engine/src/tests/seams.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5e30f14f5..f3ac6270a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1262,9 +1262,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { })?; // 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 of values still writes formulas). + // 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::Formats | What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Values | What::ValuesAndNumberFormats)); + 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); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index a5cd0d868..d3e9f78db 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -731,3 +731,25 @@ fn pasting_from_a_file_carries_its_trust() { assert_eq!(Trust::FromFile.receive(Trust::Local), Trust::FromFile); assert_eq!(Trust::Local.receive(Trust::Local), Trust::Local); } + +#[test] +fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() { + // Paste Link writes a formula into every cell whatever `what` says, so a linked paste of + // formats from a file carries the file's trust like one of formulas. Column widths and notes + // write no cells, linked or not. + let mut s = s(); + open_json(&mut s, &gridcraft_model::Workbook::new()); + set(&mut s, "A1", "=1+1"); + s.execute("edit.copy", json!({"range": "A1"})).unwrap(); + let b1 = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).cloned(); + for what in ["columnWidths", "comments"] { + s.new_workbook(); + s.execute("edit.pasteSpecial", json!({"what": what, "link": true, "at": "B1"})).unwrap(); + assert_eq!(trust(&s), Trust::Local, "{what}"); + assert!(b1(&s).is_none(), "a linked paste of {what} writes no cell"); + } + s.new_workbook(); + s.execute("edit.pasteSpecial", json!({"what": "formats", "link": true, "at": "B1"})).unwrap(); + assert!(b1(&s).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); + assert_eq!(trust(&s), Trust::FromFile); +} From 6b7c48f1191e6de61e82ca6c4cf6517e3de7f977 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:01:21 -0400 Subject: [PATCH 45/63] 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. --- crates/engine/src/cmd/stash.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 683c42c2d..3c401a4dd 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -130,8 +130,10 @@ fn item_of(n: &DefinedName) -> Option<(&str, Option)> { fn parse_name(name: &str) -> Option<(&str, Option)> { if let Some(rest) = strip_prefix_ci(name, PREFIX) { let (key, k) = rest.rsplit_once('_')?; - let all_digits = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()); - if key.is_empty() || !all_digits { + // 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))); @@ -358,6 +360,23 @@ mod tests { 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(); From a3b5f8e45c0fc82f21ea56dfdb8909dd7143ba47 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:08:40 -0400 Subject: [PATCH 46/63] 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. --- crates/engine/src/lib.rs | 12 +++++++++++- crates/engine/src/tests/seams.rs | 22 ++++++++++++++++++++++ docs/architecture.md | 3 ++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ad740e3f6..9f79dd37f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -532,6 +532,9 @@ impl Session { 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 go with the workbook, and the redo history they cleared comes back. + let redo_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| d.redo.clone())); // 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()), @@ -551,13 +554,20 @@ impl Session { 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, and drop the undo groups the command opened. + // 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); 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((_, len)) = start { + d.undo.truncate(len); + } + if let Some(redo) = redo_before { + d.redo = redo; + } d.calc.rebuild(&d.wb); } if self.depth == 0 { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index d3e9f78db..dedd5c67b 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -543,6 +543,28 @@ fn a_group_ended_at_the_history_limit_replaces_only_the_oldest_step() { assert_undo_redo(&mut s, &[("F1", "one"), ("F2", "two")]); } +#[test] +fn a_command_that_panics_leaves_the_history_as_it_found_it() { + // A command that edits through a nested command and then panics: the nested step is gone + // with the workbook it changed, and the redo history the nested step cleared comes back. + crate::cmd::register_undoable_test_command("test.editThenPanic", |s, _| { + put(s, "A1", "half done")?; + panic!("after an edit"); + }); + let mut s = s(); + set(&mut s, "A1", "1"); + set(&mut s, "A1", "2"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (1, 1)); + let err = s.execute("test.editThenPanic", json!({})).unwrap_err().to_string(); + assert!(err.contains("after an edit"), "{err}"); + assert_eq!(v(&s, "A1").display(), "1", "the workbook is restored"); + assert_eq!(undo_labels(&s), ["Enter Cell"], "no step for the nested edit"); + assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history is back"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "2"); +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { diff --git a/docs/architecture.md b/docs/architecture.md index e44e80083..aa5e1e64d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,8 @@ including tests, 2026-10-10. `Session::execute(id, params)`. Commands are `CommandSpec { id, label, menu, shortcut, params, 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 (never-crash guard). + `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). 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 456cf73886f361a25f5ff30503fe833ff75a9b3a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:08:40 -0400 Subject: [PATCH 47/63] 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. --- crates/engine/src/lib.rs | 7 +++++++ crates/engine/src/record.rs | 9 +++++++++ crates/engine/src/tests/seams.rs | 13 +++++++++++++ 3 files changed, 29 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9f79dd37f..bf1100082 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -826,10 +826,17 @@ 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")); 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 fefd07234..5b8100ed4 100644 --- a/crates/engine/src/record.rs +++ b/crates/engine/src/record.rs @@ -58,4 +58,13 @@ 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 dedd5c67b..8a87279d3 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -571,6 +571,19 @@ fn recorded_ids(s: &Session) -> Vec { s.recorder.as_ref().unwrap().steps.iter().map(|st| st.id.clone()).collect() } +#[test] +fn a_headless_call_that_fails_for_want_of_a_file_is_not_recorded() { + // `file.open` with no file queues a dialog and succeeds; a headless caller gets an error for + // it instead, so the recording keeps no step for it either. + let mut s = s(); + s.start_recording(); + set(&mut s, "A1", "1"); + assert!(s.execute_headless("file.open", json!({})).is_err()); + assert_eq!(recorded_ids(&s), ["cell.set"]); + let rec = s.stop_recording().unwrap(); + assert_eq!(rec.dropped, 0); +} + #[test] fn the_recorder_keeps_top_level_calls_with_where_they_ran() { let mut s = s(); From d1e51a4af4d991522f221b1a8dac5ebc156db5dc Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:08:40 -0400 Subject: [PATCH 48/63] 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. --- crates/engine/src/cmd/stash.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index 3c401a4dd..bbef7f3ec 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -103,7 +103,8 @@ pub fn keys(wb: &Workbook, prefix: &str) -> Vec { let mut keys: Vec = wb .names .iter() - .filter_map(|n| item_of(n).map(|(k, _)| k.to_ascii_lowercase())) + // 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(); @@ -294,11 +295,13 @@ mod tests { 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, and the legacy name is still read. + // 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\")")); From 8440427c530cb4bc0b2e1ec43e3a4b5ceb03ffad Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:15:05 -0400 Subject: [PATCH 49/63] 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. --- crates/engine/src/tests/seams.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 8a87279d3..cb0902cdf 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -775,16 +775,26 @@ fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() { let mut s = s(); open_json(&mut s, &gridcraft_model::Workbook::new()); set(&mut s, "A1", "=1+1"); + s.execute("review.newComment", json!({"cell": "A1", "text": "theirs"})).unwrap(); + let wide = gridcraft_model::LineInfo { size: Some(123.0), ..Default::default() }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(0, wide); s.execute("edit.copy", json!({"range": "A1"})).unwrap(); - let b1 = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).cloned(); + let b1 = gridcraft_core::CellRef::parse("B1").unwrap(); + let sheet = |s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().clone(); for what in ["columnWidths", "comments"] { s.new_workbook(); s.execute("edit.pasteSpecial", json!({"what": what, "link": true, "at": "B1"})).unwrap(); assert_eq!(trust(&s), Trust::Local, "{what}"); - assert!(b1(&s).is_none(), "a linked paste of {what} writes no cell"); + let sh = sheet(&s); + assert!(sh.cell(b1).is_none(), "a linked paste of {what} writes no cell"); + if what == "comments" { + assert_eq!(sh.comments.get(&b1).map(|c| c.text.as_str()), Some("theirs")); + } else { + assert_eq!(sh.cols.get(&1).and_then(|c| c.size), Some(123.0)); + } } s.new_workbook(); s.execute("edit.pasteSpecial", json!({"what": "formats", "link": true, "at": "B1"})).unwrap(); - assert!(b1(&s).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); + assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula"); assert_eq!(trust(&s), Trust::FromFile); } From d7c3c18345f5e172340906c3bb0529db17dc31a3 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:47:40 -0400 Subject: [PATCH 50/63] 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. --- crates/engine/src/lib.rs | 11 +++++------ crates/engine/src/tests/seams.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf1100082..aaa8db4f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -533,8 +533,9 @@ impl Session { // 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 go with the workbook, and the redo history they cleared comes back. - let redo_before = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| d.redo.clone())); + // 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()))); // 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()), @@ -562,10 +563,8 @@ impl Session { { d.wb = wb.clone(); d.selection = sel.clone(); - if let Some((_, len)) = start { - d.undo.truncate(len); - } - if let Some(redo) = redo_before { + if let Some((undo, redo)) = history_before { + d.undo = undo; d.redo = redo; } d.calc.rebuild(&d.wb); diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index cb0902cdf..9c9aeb6ad 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -563,6 +563,20 @@ fn a_command_that_panics_leaves_the_history_as_it_found_it() { assert_eq!(s.doc().unwrap().redo.len(), 1, "the redo history is back"); s.execute("edit.redo", json!({})).unwrap(); assert_eq!(v(&s, "A1").display(), "2"); + + // A command that undoes through a nested command and then panics: the entry Undo removed + // comes back, and the redo entry it made goes. + crate::cmd::register_undoable_test_command("test.undoThenPanic", |s, _| { + s.execute("edit.undo", json!({}))?; + panic!("after an undo"); + }); + assert_eq!((undo_labels(&s).len(), s.doc().unwrap().redo.len()), (2, 0)); + assert!(s.execute("test.undoThenPanic", json!({})).is_err()); + assert_eq!(v(&s, "A1").display(), "2", "the workbook is restored"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Enter Cell"], "the undone step is back"); + assert_eq!(s.doc().unwrap().redo.len(), 0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1").display(), "1"); } // ---------------------------------------------------------------- recording buffer From 378ce3a5a08c69b22d0897afc918c06b9077dd31 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:28:05 -0400 Subject: [PATCH 51/63] Docs: the macro page describes this version The inputs paragraph described how a run binds inputs and the message it shows, which the runner adds in a later pull request. It now says what the declaration is for and that this version stores and checks it. --- docs/macros.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index 1c755e329..65160fad4 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-21 · **Change:** major (created: the macro format and storage) +> **Last updated:** 2026-10-24 · **Change:** major (created: the macro format and storage) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -38,9 +38,8 @@ version. `{"command": "cell.set", "params": {…}}` is accepted for a command step. - **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), - `nonEmpty` and `defaultRef`. A run binds each input to the range it is given, its default, or - (for the first input) the selection, and refuses with messages that name the cells: - "Input column 2 (Amount) must hold numbers; C4 holds text." + `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work + on it. This version stores and checks the declaration; it does not bind inputs or run macros. - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep. ## Storage From 3ca12525715a7feee8e265152ac3cb4def8e6995 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:36:09 -0400 Subject: [PATCH 52/63] 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. --- crates/engine/src/lib.rs | 18 +++++++++++++++ crates/engine/src/tests/seams.rs | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aaa8db4f6..09e39523b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -536,6 +536,13 @@ impl Session { // 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()))); + // 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()), @@ -558,6 +565,7 @@ impl Session { // 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) { @@ -567,6 +575,16 @@ impl Session { 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 { diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 9c9aeb6ad..48105da25 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -579,6 +579,45 @@ fn a_command_that_panics_leaves_the_history_as_it_found_it() { assert_eq!(v(&s, "A1").display(), "1"); } +#[test] +fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() { + // The command takes a token the test left for it, ends or rolls back that group, and panics. + // The group's token went with the command, so the group stays ended; its step runs from the + // group's start to the restored workbook, so Undo still returns to the state before the group. + static TOKEN: std::sync::Mutex> = std::sync::Mutex::new(None); + crate::cmd::register_undoable_test_command("test.endGroupThenPanic", |s, _| { + let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take(); + if let Some(token) = token { + s.group_end(token)?; + } + panic!("after ending a group"); + }); + crate::cmd::register_undoable_test_command("test.rollBackGroupThenPanic", |s, _| { + let token = TOKEN.lock().map_err(|_| EngineError::Other("lock".into()))?.take(); + if let Some(token) = token { + s.group_rollback(token)?; + } + panic!("after rolling back a group"); + }); + for cmd in ["test.endGroupThenPanic", "test.rollBackGroupThenPanic"] { + let mut s = s(); + set(&mut s, "A1", "before"); + let token = s.group_begin("Outer").unwrap(); + set(&mut s, "A1", "inside"); + set(&mut s, "A2", "inside too"); + *TOKEN.lock().unwrap() = Some(token); + assert!(s.execute(cmd, json!({})).is_err()); + let uid = s.doc().unwrap().uid; + assert!(!s.in_group(uid), "{cmd}: the group stays ended"); + assert_eq!(undo_labels(&s), ["Enter Cell", "Outer"], "{cmd}"); + assert_eq!(v(&s, "A1").display(), "inside", "{cmd}: the workbook is as the command found it"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((v(&s, "A1").display(), v(&s, "A2").display()), ("before".into(), String::new()), "{cmd}"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(v(&s, "A2").display(), "inside too", "{cmd}"); + } +} + // ---------------------------------------------------------------- recording buffer fn recorded_ids(s: &Session) -> Vec { From 7ee9d77483c124e3ee4bcbf6135b7ca73762169f Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:36:09 -0400 Subject: [PATCH 53/63] 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. --- docs/architecture.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index aa5e1e64d..f51f3a61b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,8 +66,11 @@ Actions made of many commands (macros, a debugger that runs one step per call) u Commands run by other commands are not recorded, 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 formulas or - validation pasted from one. Active content in a `FromFile` workbook asks before it runs. This + `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`). - **Workbook data in hidden names** (`cmd/stash.rs`). An item is split over hidden From 259b6e67e88324780815a75912dc4159be5b472a Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:54 -0400 Subject: [PATCH 54/63] Model: Repeat and If nest four deep, as the limit says The depth check refused any step list at the fifth level, so a fourth Repeat or If could not even hold an empty body: three levels nested, not the four MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If steps themselves, in the model's limits and in the parser's pass over the JSON. The test asks for four and refuses a fifth. --- crates/engine/src/macros/mod.rs | 8 +++++--- crates/engine/src/macros/tests.rs | 19 ++++++++++++++----- crates/model/src/macros.rs | 7 ++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 38016049d..2aa45e12c 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -53,9 +53,6 @@ fn check_size(m: &Macro) -> Result<(), String> { /// before the steps are deserialized. fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) }; - if level > gridcraft_model::MAX_MACRO_DEPTH { - return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH)); - } for st in list { let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) }; if !o.contains_key("type") @@ -64,6 +61,11 @@ fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> { o.insert("type".into(), json!("command")); o.insert("id".into(), id); } + // A step with a body is a Repeat or an If: one at the top is nested 1 deep. + let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key)); + if nested && level > gridcraft_model::MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH)); + } for key in ["body", "then", "else"] { if let Some(body) = o.get_mut(key) { normalize_steps(body, level + 1)?; diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 7b498f0b8..5fbc6d38c 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -55,16 +55,25 @@ fn macros_over_the_limits_are_refused() { let flat = |n: usize| json!({"name": "Big", "steps": vec![json!({"command": "cell.set"}); n]}); assert!(parse_macro(&flat(MAX_MACRO_STEPS)).is_ok()); assert!(parse_macro(&flat(MAX_MACRO_STEPS + 1)).unwrap_err().contains("10000 steps")); - // Nested Repeat steps: 4 levels are fine, 5 are not. - let nest = |levels: usize| { + // Nested Repeat steps: 4 Repeats one inside the other are fine (the innermost holds a + // command, or nothing), a 5th is not. + let nest = |repeats: usize| { let mut step = json!({"command": "cell.set"}); - for _ in 1..levels { + for _ in 0..repeats { step = json!({"type": "repeat", "over": "Input", "body": [step]}); } json!({"name": "Deep", "steps": [step]}) }; - assert!(parse_macro(&nest(4)).is_ok()); - assert!(parse_macro(&nest(5)).unwrap_err().contains("4 deep")); + assert!(parse_macro(&nest(MAX_MACRO_DEPTH)).is_ok()); + assert_eq!(parse_macro(&nest(MAX_MACRO_DEPTH)).unwrap().step_count(), MAX_MACRO_DEPTH + 1); + assert!(parse_macro(&nest(MAX_MACRO_DEPTH + 1)).unwrap_err().contains("4 deep")); + let mut empty = json!({"type": "repeat", "over": "Input", "body": []}); + for _ in 1..MAX_MACRO_DEPTH { + empty = json!({"type": "if", "cell": "A1", "op": "eq", "value": 1, "then": [empty], "else": []}); + } + let deep = parse_macro(&json!({"name": "Deep", "steps": [empty]})).unwrap(); + assert_eq!(deep.step_count(), MAX_MACRO_DEPTH); + assert!(deep.check_limits().is_ok(), "the model's check agrees with the parser"); // Far deeper than serde would allow is refused before it is read. assert!(parse_macro(&nest(200)).is_err()); for bad in [ diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 8dc657f90..03cb03cac 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -81,10 +81,8 @@ impl Macro { return Err(format!("A macro description can be at most {MAX_MACRO_TEXT} characters long.")); } // Count without recursing past the depth limit (a hostile file can nest deeper). + // A Repeat or If at the top is nested 1 deep; one inside it, 2 deep. fn walk(steps: &[Step], level: usize, count: &mut usize) -> Result<(), String> { - if level > MAX_MACRO_DEPTH { - return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); - } for s in steps { *count = count.saturating_add(1); if *count > MAX_MACRO_STEPS { @@ -93,6 +91,9 @@ impl Macro { if s.note.chars().count() > MAX_MACRO_TEXT { return Err(format!("A step note can be at most {MAX_MACRO_TEXT} characters long.")); } + if s.kind.children().next().is_some() && level > MAX_MACRO_DEPTH { + return Err(format!("Repeat and If steps can nest at most {MAX_MACRO_DEPTH} deep.")); + } for body in s.kind.children() { walk(body, level + 1, count)?; } From 8219c18b921d573192f01863a602a373f512ea35 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:45:54 -0400 Subject: [PATCH 55/63] Engine: macro storage shares the list, saves in one pass, keeps unknown names, checks once - Workbook::macros is a shared list (Macros, a copy-on-write newtype): a workbook copy made for a cell edit or an undo snapshot holds the same list, and the undo budget charges a list once a snapshot owns its own copy. Before, every mutating command copied every step of every macro, and a hundred undo entries kept a hundred copies the budget never saw. - Saving to xlsx drops the last save's items and writes the macros in one pass each (stash::set_many), instead of a scan of every name per macro. - A hidden _gc_macro__ name with a key this build did not write is kept on open and on save, as a visible one is, instead of being dropped without a warning. - macros::check is the one check for every path that adds a macro (JSON, a file, built in code): the model's limits and the xlsx size limit. Regression tests for each. --- crates/engine/src/cmd/stash.rs | 46 ++++++++++++-------- crates/engine/src/lib.rs | 4 ++ crates/engine/src/macros/mod.rs | 40 +++++++++++------- crates/engine/src/macros/tests.rs | 70 +++++++++++++++++++++++++++++++ crates/model/src/macros.rs | 69 ++++++++++++++++++++++++++++++ crates/model/src/workbook.rs | 6 +-- 6 files changed, 200 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs index bbef7f3ec..e36635aff 100644 --- a/crates/engine/src/cmd/stash.rs +++ b/crates/engine/src/cmd/stash.rs @@ -8,7 +8,7 @@ //! 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. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use gridcraft_model::{DefinedName, Workbook}; use serde_json::Value as Json; @@ -59,28 +59,40 @@ pub fn get(wb: &Workbook, key: &str) -> Option { /// 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)))); + set_many(wb, &[(key.to_string(), text.to_string())]) +} + +/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything +/// changes and one pass over the names for all of them, so writing a thousand items into a +/// workbook with many thousands of names stays near-linear. +pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> { + let mut planned = Vec::new(); + let mut keys: HashSet = HashSet::new(); + for (key, text) in items { + 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) + ))); + } + keys.insert(key.to_ascii_lowercase()); + 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 }); + } } - 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) { + // A visible name of an 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. + let names: HashSet = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect(); + if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) { 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 }); - } + // What was there under these keys goes, in either format, then the new chunks come. + wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase()))); + wb.names.extend(planned); Ok(()) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 886e845ce..4dad22d02 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -224,6 +224,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize { let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells); bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next)); } + // Snapshots share the macro list until a change to it; a snapshot with its own list keeps it. + if !older.macros.shares(&newer.macros) { + bytes = bytes.saturating_add(older.macros.estimated_bytes()); + } bytes } diff --git a/crates/engine/src/macros/mod.rs b/crates/engine/src/macros/mod.rs index 2aa45e12c..5a779758d 100644 --- a/crates/engine/src/macros/mod.rs +++ b/crates/engine/src/macros/mod.rs @@ -35,11 +35,18 @@ pub fn parse_macro(v: &Json) -> Result { } let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?; m.name = m.name.trim().to_string(); - m.check_limits()?; - check_size(&m)?; + check(&m)?; Ok(m) } +/// Every check a macro must pass before it is kept in a workbook: the model's limits +/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro, +/// from JSON, from a file or built in code, goes through this one check. +pub fn check(m: &Macro) -> Result<(), String> { + m.check_limits()?; + check_size(m) +} + /// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of /// JSON), so saving a workbook never fails because of one of its macros. fn check_size(m: &Macro) -> Result<(), String> { @@ -89,17 +96,18 @@ pub fn find(wb: &Workbook, name: &str) -> Option { /// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro__`) instead of the /// field, or `None` when there is nothing to change. pub fn with_names(wb: &Workbook) -> crate::Result> { - let stale = !stash::keys(wb, MACRO_KEY).is_empty(); - if wb.macros.is_empty() && !stale { + // Only the numbered items are this build's: a hidden `_gc_macro__` name with some + // other key stays, as `load` leaves it. + let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some()); + if wb.macros.is_empty() && !wb.names.iter().any(stored) { return Ok(None); } let mut out = wb.clone(); - for key in stash::keys(&out, MACRO_KEY) { - stash::remove(&mut out, &key); - } - for (i, m) in wb.macros.iter().enumerate() { - stash::set(&mut out, &format!("{MACRO_KEY}{i}"), ¯o_json(m).to_string())?; - } + // One pass drops the items of the last save, and one writes the macros (a file can hold a + // thousand macros and many thousands of names). + 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(); Ok(Some(out)) } @@ -125,8 +133,9 @@ pub fn load(wb: &mut Workbook) -> Vec { match macro_item(&n) { None => kept.push(n), Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n), - // A `macro_` item that isn't numbered isn't one this build wrote; it goes unread. - Some((_, None)) => {} + // A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is, + // unread, and a save keeps it. + Some((_, None)) => kept.push(n), } } wb.names = kept; @@ -154,7 +163,7 @@ pub fn load(wb: &mut Workbook) -> Vec { let checked = m.check_limits().and_then(|()| { m.name = taken.unique(m.name.trim()); // The macro as it is kept, renamed, must still pass, or the next save would lose it. - m.check_limits().and_then(|()| check_size(&m)) + check(&m) }); match checked { Ok(()) => { @@ -186,8 +195,9 @@ fn macro_item(n: &DefinedName) -> Option<(String, Option)> { Some((key, i)) } -/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A long name is shortened to make -/// room for the suffix, so the result stays within [`gridcraft_model::MAX_MACRO_NAME`] characters. +/// `name`, or `name (2)`, `name (3)`… if a macro already has it. A free name comes back as it is; +/// a renamed one is shortened to make room for the suffix, so it stays within +/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`]. pub fn unique_name(wb: &Workbook, name: &str) -> String { let mut taken = Taken::default(); for m in &wb.macros { diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 5fbc6d38c..77e9fb106 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -149,6 +149,76 @@ fn a_visible_name_that_looks_like_macro_storage_is_left_alone() { assert_eq!(wb.names, vec![name]); } +#[test] +fn a_hidden_macro_name_this_build_did_not_write_is_kept() { + // `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a + // save with macros keeps it beside the items written. + let mut wb = Workbook::new(); + let theirs = hidden("_gc_macro_index_0", "{}"); + wb.names.push(theirs.clone()); + wb.macros.push(sample_macro("Totals")); + let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + let names = names_of(&raw); + assert!(names.contains(&"_gc_macro_index_0".to_string()) && names.contains(&"_gc_macro_0_0".to_string()), "{names:?}"); + let (back, warnings) = crate::io::open_bytes("book.xlsx", &crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.macros, wb.macros); + assert_eq!(names_of(&back), ["_gc_macro_index_0"]); +} + +#[test] +fn workbook_snapshots_share_the_macro_list_until_it_changes() { + // An undo snapshot or a copy made for a cell edit holds the same list, not a copy of every + // step; the undo budget charges a list only once it is a snapshot's own. + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("Totals")); + let snapshot = wb.clone(); + assert!(wb.macros.shares(&snapshot.macros)); + assert_eq!(crate::history_retained_bytes(&snapshot, &wb), std::mem::size_of::()); + wb.macros.push(sample_macro("More")); + assert!(!wb.macros.shares(&snapshot.macros), "a write copies the list first"); + assert_eq!(snapshot.macros.len(), 1); + assert!(crate::history_retained_bytes(&snapshot, &wb) > std::mem::size_of::()); + // The copy is per list, not per workbook copy: cloning again shares the new list. + let again = wb.clone(); + assert!(wb.macros.shares(&again.macros)); + let rebuilt: Macros = wb.macros.iter().cloned().collect(); + assert_eq!(rebuilt, wb.macros); + assert_eq!(rebuilt.into_iter().count(), 2); +} + +#[test] +fn a_macro_built_in_code_goes_through_the_size_check_too() { + // `check` is the one check for every path, so a macro that passes it never makes a save fail. + let big = Macro { + name: "Big".into(), + steps: vec![Step::command("cell.set", json!({"input": "x".repeat(crate::cmd::stash::MAX_ITEM_CHARS)}))], + ..Macro::default() + }; + assert!(big.check_limits().is_ok(), "the model's limits say nothing about the size"); + assert!(check(&big).unwrap_err().contains("too large")); + let mut wb = Workbook::new(); + wb.macros.push(big); + assert!(crate::io::save_bytes(&wb, "book.xlsx").is_err(), "a macro that fails `check` fails the save"); +} + +#[test] +fn a_save_refuses_a_clashing_visible_name_before_changing_anything() { + let mut wb = Workbook::new(); + wb.macros.push(sample_macro("A")); + wb.macros.push(sample_macro("B")); + let own = DefinedName { hidden: false, ..hidden("_GC_MACRO_1_0", "mine") }; + wb.names.push(own.clone()); + wb.names.push(hidden("_gc_macro_7_0", "stale")); + let err = crate::io::save_bytes(&wb, "book.xlsx").unwrap_err().to_string(); + assert!(err.contains("_GC_MACRO_1_0"), "{err}"); + assert_eq!(wb.names.len(), 2, "nothing changed"); + wb.names.remove(0); + let (raw, _) = gridcraft_xlsx::read_xlsx(&crate::io::save_bytes(&wb, "book.xlsx").unwrap()).unwrap(); + let names = names_of(&raw); + assert_eq!(names, ["_gc_macro_0_0", "_gc_macro_1_0"], "the stale item went in the same pass"); +} + #[test] fn scripts_and_vba_stay_as_they_are() { // Automate scripts keep their own storage, and a VBA project is carried through unchanged. diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 03cb03cac..92afac61a 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -121,6 +121,75 @@ impl Macro { } } +/// The macros of a workbook. Workbook snapshots share the list until one of them changes it, so +/// an undo entry or a copy made for a cell edit holds the same list rather than a copy of every +/// step. Reads go through `Deref` to the list; a write through `DerefMut` first copies the list +/// when another snapshot shares it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Macros(std::sync::Arc>); + +impl Macros { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Whether both hold the one list, with no copy made between them. + pub fn shares(&self, other: &Macros) -> bool { + std::sync::Arc::ptr_eq(&self.0, &other.0) + } + + /// A rough size of the list in memory, for the undo budget. + pub fn estimated_bytes(&self) -> usize { + self.0.iter().fold(0usize, |bytes, m| { + let text = m.name.len().saturating_add(m.description.len()); + let steps = m.step_count().saturating_mul(std::mem::size_of::().saturating_add(64)); + bytes.saturating_add(std::mem::size_of::()).saturating_add(text).saturating_add(steps) + }) + } +} + +impl From> for Macros { + fn from(list: Vec) -> Macros { + Macros(std::sync::Arc::new(list)) + } +} + +impl std::ops::Deref for Macros { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.0 + } +} + +impl std::ops::DerefMut for Macros { + fn deref_mut(&mut self) -> &mut Vec { + std::sync::Arc::make_mut(&mut self.0) + } +} + +impl IntoIterator for Macros { + type Item = Macro; + type IntoIter = std::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + std::sync::Arc::unwrap_or_clone(self.0).into_iter() + } +} + +impl<'a> IntoIterator for &'a Macros { + type Item = &'a Macro; + type IntoIter = std::slice::Iter<'a, Macro>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl FromIterator for Macros { + fn from_iter>(iter: I) -> Macros { + Macros(std::sync::Arc::new(iter.into_iter().collect())) + } +} + /// Refuses a format version newer than [`MACRO_VERSION`]: this build would drop the fields it /// doesn't know, and a save would then write the rest back as if it were the whole macro. pub fn check_version(v: u64) -> Result<(), String> { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 57acd2a8d..5a47b75ed 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -181,8 +181,8 @@ pub struct Workbook { pub csv_delimiter: Option, /// GridCraft macros (typed command lists, not VBA), in the order the macro list shows them. /// The JSON format keeps them as this field; xlsx keeps them in hidden defined names. - #[serde(default, skip_serializing_if = "Vec::is_empty", skip_deserializing)] - pub macros: Vec, + #[serde(default, skip_serializing_if = "crate::macros::Macros::is_empty", skip_deserializing)] + pub macros: crate::macros::Macros, /// The `macros` entries of a JSON file as read, before the engine checks each one on its own /// and moves it into `macros`, so one damaged or newer macro doesn't stop the file opening. /// Never written. @@ -234,7 +234,7 @@ impl Workbook { phonetics: vec![], vba_project: None, csv_delimiter: None, - macros: vec![], + macros: crate::macros::Macros::default(), macros_read: vec![], preserved: None, source_dir: None, From 712bb766ecdf8063d28f74f94303bd5efa63ce6e Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:46:25 -0400 Subject: [PATCH 56/63] 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. --- crates/engine/src/lib.rs | 12 +++++++++--- crates/engine/src/tests/seams.rs | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 09e39523b..8a8d8bcfa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -680,20 +680,26 @@ impl Session { pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { let token = self.group_begin(label)?; let doc = token.doc(); - match f(self) { - Ok(r) => match self.group_end(token) { + // 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), }, - 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) + } } } diff --git a/crates/engine/src/tests/seams.rs b/crates/engine/src/tests/seams.rs index 48105da25..0f1bc3072 100644 --- a/crates/engine/src/tests/seams.rs +++ b/crates/engine/src/tests/seams.rs @@ -579,6 +579,31 @@ fn a_command_that_panics_leaves_the_history_as_it_found_it() { assert_eq!(v(&s, "A1").display(), "1"); } +#[test] +fn a_grouped_action_that_panics_is_rolled_back_before_the_panic_goes_on() { + // Outside a command, a caller that catches the panic finds the group rolled back and no + // group left open, so Undo is enabled and the next grouped action does not nest. + let mut s = s(); + set(&mut s, "A1", "before"); + let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + s.grouped::<()>("Doomed", |s| { + set(s, "A1", "half done"); + panic!("inside the group"); + }) + })); + assert!(caught.is_err(), "the panic goes on to the caller"); + let uid = s.doc().unwrap().uid; + assert!(!s.in_group(uid)); + assert_eq!(v(&s, "A1").display(), "before"); + assert_eq!(undo_labels(&s), ["Enter Cell"]); + s.grouped("Next", |s| { + set(s, "A1", "after"); + Ok(()) + }) + .unwrap(); + assert_eq!(undo_labels(&s), ["Enter Cell", "Next"]); +} + #[test] fn a_group_ended_by_a_command_that_then_panics_keeps_its_step() { // The command takes a token the test left for it, ends or rolls back that group, and panics. From 4672b6da51b5cf89093e919f7fe1720c69dc4fd9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:48:53 -0400 Subject: [PATCH 57/63] Model: an input name that reads as a cell reference is refused A macro could declare an input called Q1, AB12 or Sum1, and a step written with plain A1 text, as a recorded one is, would then resolve to the input's range instead of the cell. check_limits refuses such names; the docs say so. Regression test. --- crates/engine/src/macros/tests.rs | 14 ++++++++++++++ crates/model/src/macros.rs | 4 ++++ docs/macros.md | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/macros/tests.rs b/crates/engine/src/macros/tests.rs index 77e9fb106..99235ec68 100644 --- a/crates/engine/src/macros/tests.rs +++ b/crates/engine/src/macros/tests.rs @@ -149,6 +149,20 @@ fn a_visible_name_that_looks_like_macro_storage_is_left_alone() { assert_eq!(wb.names, vec![name]); } +#[test] +fn input_names_that_read_as_cell_references_are_refused() { + // A recorded step holds plain A1 text; an input called `Q1` would take its place. + let with_input = |name: &str| json!({"name": "M", "inputs": [{"name": name}]}); + for name in ["Q1", "AB12", "Sum1", "a1"] { + let err = parse_macro(&with_input(name)).unwrap_err(); + assert!(err.contains("reads as a cell reference"), "{name}: {err}"); + } + for name in ["Amount", "Q", "Sales_Q1", "Total1a"] { + assert!(parse_macro(&with_input(name)).is_ok(), "{name}"); + } + assert!(parse_macro(&with_input("Row")).unwrap_err().contains("token of its own")); +} + #[test] fn a_hidden_macro_name_this_build_did_not_write_is_kept() { // `_gc_macro_index_0` is not a numbered item: it is neither read nor dropped on open, and a diff --git a/crates/model/src/macros.rs b/crates/model/src/macros.rs index 92afac61a..54f975b04 100644 --- a/crates/model/src/macros.rs +++ b/crates/model/src/macros.rs @@ -113,6 +113,10 @@ impl Macro { if ["Output", "Row", "Column", "Name"].iter().any(|r| r.eq_ignore_ascii_case(n)) { return Err(format!("'{n}' is a token of its own and can't name an input.")); } + // A step written with plain A1 text (a recorded one) must keep meaning the cell. + if gridcraft_core::RangeRef::parse(n).is_some() { + return Err(format!("'{n}' reads as a cell reference and can't name an input.")); + } if self.inputs.iter().take(k).any(|o| o.name.eq_ignore_ascii_case(n)) { return Err(format!("Two inputs are called '{n}'.")); } diff --git a/docs/macros.md b/docs/macros.md index 65160fad4..f98435bb5 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -39,7 +39,10 @@ version. - **Inputs**: `kind` (`cell` or `range`), `rows` and `cols` as `[min, max]` (0 for no maximum; rows exclude the header), `header`, `colTypes` (`number`, `text`, `date`, `bool`, `any`), `nonEmpty` and `defaultRef`. These declare what a range must look like for the macro to work - on it. This version stores and checks the declaration; it does not bind inputs or run macros. + on it. An input's name is letters, digits and `_`, starting with a letter, and is neither a + token of its own (`Output`, `Row`, `Column`, `Name`) nor something that reads as a cell + reference (`Q1`), so a step written with plain A1 text keeps meaning the cell. This version + stores and checks the declaration; it does not bind inputs or run macros. - **Limits**: 1,000 macros a workbook, 10,000 steps a macro, and Repeat and If nested 4 deep. ## Storage From 00b63468e6f529ae32d3e9f551e877b34a33d7e6 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:02:45 -0400 Subject: [PATCH 58/63] Engine: macro tokens resolve only where they mean a reference, and refuse instead of guessing Six fixes from a review of the resolver, each with a regression test that fails without it: - A bare Row or Column under a general reference key (to, from, at, area, source, target, destination, location) stays text: Change Spelling's replacement word and a pivot field's area were being rewritten into the current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still resolve there, and the bare word still resolves under cell and range keys. - {{{{ in a string is a literal {{, so text that needs the braces can be written; an unknown token is still an error. - Input.data on a range that is only a header row, and Name: of a table with no data rows, are refused instead of reading the header. - below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead of becoming 1. - A defined name on a sheet whose name holds a dollar sign resolves: the dollar anchors are stripped from the reference part only. - quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which quotes a sheet named like a cell reference, an R1C1 token or a boolean and leaves a period unquoted. docs/macros.md says which keys take a bare token and how to write a literal brace. --- crates/engine/src/macros/resolve.rs | 167 ++++++++++++++++++++++++---- docs/macros.md | 11 +- 2 files changed, 156 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/macros/resolve.rs b/crates/engine/src/macros/resolve.rs index b3cf438fa..03b9988da 100644 --- a/crates/engine/src/macros/resolve.rs +++ b/crates/engine/src/macros/resolve.rs @@ -109,6 +109,19 @@ pub fn is_ref_key(key: &str) -> bool { REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end)) } +/// Reference keys that other commands use for plain words: `to` is a replacement word in +/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a +/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token. +fn is_general_key(key: &str) -> bool { + ["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key)) +} + +/// `Row` or `Column` on its own, with no method or sheet after it. +fn is_bare_item_word(text: &str) -> bool { + let t = text.trim(); + t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column") +} + /// The most nesting [`resolve_params`] walks into. const MAX_JSON_DEPTH: usize = 64; @@ -159,13 +172,15 @@ fn plain_ref(t: &str, env: &Env) -> R { /// A defined name (by its formula) or a table (its data rows). fn named(name: &str, env: &Env) -> R { if let Some(n) = env.wb.name(name, env.active_sheet) { - let f = n.formula.trim().trim_start_matches('=').replace('$', ""); - let (sheet, body) = crate::cmd::split_sheet(&f); + // The sheet part keeps its characters (a sheet may be called `US$`); only the reference + // loses its `$` anchors. + let f = n.formula.trim().trim_start_matches('='); + let (sheet, body) = crate::cmd::split_sheet(f); let si = match sheet { Some(s) => env.wb.sheet_index(&s), None => n.scope.or(Some(env.active_sheet)), }; - if let (Some(sheet), Some(range)) = (si, RangeRef::parse(body)) { + if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) { return Ok(Place { sheet, range }); } return bad(format!("The name '{name}' doesn't refer to a range.")); @@ -173,7 +188,12 @@ fn named(name: &str, env: &Env) -> R { if let Some((si, ti)) = env.wb.table(name) && let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti)) { - return Ok(Place { sheet: si, range: t.data_range().unwrap_or(t.range) }); + // A table stands for its data rows; one with none (a header, perhaps a totals row) has + // nothing to give. + let Some(range) = t.data_range() else { + return bad(format!("The table '{name}' has no data rows.")); + }; + return Ok(Place { sheet: si, range }); } bad(format!("There is no name or table called '{name}'.")) } @@ -244,19 +264,23 @@ pub fn resolve_token(text: &str, env: &Env) -> R { RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?) } ("data", []) => { - if header && r.height() > 1 { + if header { + // A header row with nothing under it has no data rows to give. + if r.height() < 2 { + return bad(format!("'{t}': the range is only a header row, with no data under it.")); + } RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) } else { r } } ("below", []) | ("below", [Arg::Int(_)]) => { - let n = arg_or_one(&args); + let n = count_arg(&args, t)?; let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?; RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?) } ("right", []) | ("right", [Arg::Int(_)]) => { - let n = arg_or_one(&args); + let n = count_arg(&args, t)?; let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?; RangeRef::new(left, shift(r.end, 0, n, t)?) } @@ -279,10 +303,12 @@ pub fn resolve_token(text: &str, env: &Env) -> R { Ok(place) } -fn arg_or_one(args: &[Arg]) -> i64 { +/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1. +fn count_arg(args: &[Arg], t: &str) -> R { match args.first() { - Some(Arg::Int(n)) => (*n).max(1), - _ => 1, + Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")), + Some(Arg::Int(n)) => Ok(*n), + _ => Ok(1), } } @@ -453,11 +479,9 @@ pub fn a1(place: Place, env: &Env) -> String { /// `Sheet1` or `'My Sheet'` (quotes doubled). pub fn quote_sheet(name: &str) -> String { - if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') && !name.starts_with(|c: char| c.is_ascii_digit()) { - name.to_string() - } else { - format!("'{}'", name.replace('\'', "''")) - } + // The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean + // is quoted too, or `Q1!A1` would be read as a reference to cell Q1. + gridcraft_core::quote_sheet(name) } /// Step parameters with every token replaced by A1 text. @@ -485,7 +509,7 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { // Items of a list keep their key's kind (`ranges: ["Input", …]`). Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::>>()?), Json::String(s) => { - if key.is_some_and(is_ref_key) && is_token(s, env) { + if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) { Json::String(a1(resolve_token(s, env)?, env)) } else { Json::String(interpolate(s, env)?) @@ -495,7 +519,8 @@ fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R { }) } -/// Replaces each `{{token}}` in `s`. +/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces +/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`). pub fn interpolate(s: &str, env: &Env) -> R { if !s.contains("{{") { return Ok(s.to_string()); @@ -504,6 +529,11 @@ pub fn interpolate(s: &str, env: &Env) -> R { let mut rest = s; while let Some(i) = rest.find("{{") { out.push_str(rest.get(..i).unwrap_or("")); + if rest.get(i..i + 4) == Some("{{{{") { + out.push_str("{{"); + rest = rest.get(i + 4..).unwrap_or(""); + continue; + } let after = rest.get(i + 2..).unwrap_or(""); let Some(j) = after.find("}}") else { out.push_str(rest.get(i..).unwrap_or("")); @@ -793,8 +823,107 @@ mod tests { #[test] fn sheet_names_are_quoted_when_needed() { - for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] { - assert_eq!(quote_sheet(name), want); + // The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean + // is quoted too, and a period on its own is not a reason to quote. + for (name, want) in [ + ("Sheet1", "Sheet1"), + ("My Data", "'My Data'"), + ("O'Hara", "'O''Hara'"), + ("2024", "'2024'"), + ("", "''"), + ("Q1", "'Q1'"), + ("R1C1", "'R1C1'"), + ("TRUE", "'TRUE'"), + ("Sales.Q1", "Sales.Q1"), + ] { + assert_eq!(quote_sheet(name), want, "{name}"); + } + } + + #[test] + fn a_bare_row_or_column_under_a_general_key_is_text() { + // `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a + // pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not; + // the token forms still resolve there, and the bare word resolves under `range`. + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] { + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item }; + let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"}); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}"); + } + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) }; + let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"}); + let r = resolve_params(&p, &env).unwrap(); + assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"})); + } + + #[test] + fn a_header_row_alone_and_a_table_without_data_rows_are_refused() { + let mut wb = wb(); + let sh = wb.sheet_mut(0).unwrap(); + sh.tables.push(gridcraft_model::Table { + id: 1, + name: "Empty".into(), + range: RangeRef::parse("H1:J1").unwrap(), + header_row: true, + totals_row: false, + columns: Vec::new(), + style: String::new(), + banded_rows: false, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: false, + filter: Vec::new(), + table_type: None, + connection_id: None, + }); + let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row")); + assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row")); + check(&env, "Plain.data", "B2:D2"); + check(&env, "Input.col(2)", "C2"); + assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows")); + } + + #[test] + fn doubled_braces_are_literal_braces() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},"); + assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}"); + assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error"); + } + + #[test] + fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() { + let mut wb = wb(); + wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$"))); + wb.names.push(gridcraft_model::DefinedName { + name: "Dollars".into(), + scope: None, + formula: "'US$'!$F$20".into(), + comment: String::new(), + hidden: false, + }); + let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None }; + check(&env, "Name:Dollars", "'US$'!F20"); + } + + #[test] + fn below_and_right_refuse_a_count_under_one() { + let wb = wb(); + let inputs = vec![("Input".to_string(), place("B2:D11"), true)]; + let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None }; + check(&env, "Input.below", "B12:D12"); + check(&env, "Input.below(2)", "B12:D13"); + check(&env, "Input.right(1)", "E2:E11"); + for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] { + assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}"); } } } diff --git a/docs/macros.md b/docs/macros.md index dd14951bc..6daf54c9e 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -63,9 +63,14 @@ exists is used and methods follow it. Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside -any string, such as a formula. Plain A1 references stay absolute; a `Name:` anchor follows -inserted rows. A reference with a sheet name is never a token, so `Output!A1` is a cell on a sheet -called Output, even when a token or input has that name. +any string, such as a formula; `{{{{` in a string is a literal `{{`. Under the general keys that +other commands use for plain words (`to`, `from`, `at`, `area`, `source`, `target`, +`destination`, `location`), a bare `Row` or `Column` stays text (a replacement word, a pivot +area); write `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` there. Plain A1 references stay +absolute; a `Name:` anchor follows inserted rows. A reference with a sheet name is never a token, +so `Output!A1` is a cell on a sheet called Output, even when a token or input has that name. +`Input.data` on a range that is only a header row, and `Name:` of a table with no data rows, are +refused rather than read as the header. ## Storage From 74ff8ac6254c681ae50cd7657119cae9d54c6da9 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:13:18 -0400 Subject: [PATCH 59/63] Engine: macro runs refuse a shift that moves an input, stay clean when nothing changed, take the answer however ask was given, and share one step budget Four fixes from a review of the runner, each with a regression test that fails without it: - A shift inserts cells over the block of every written cell, which moves that block's columns (or rows) from there on. When a bound input lay in the way, its data moved and sheared while the run kept the old coordinates and reported success. The shift is now refused, naming the input and its place. The output is not counted: it is where the macro writes, and the second footprint catches data a shift moved into it. - A dry run or a refused run left a clean workbook dirty: view.zoom, sheet.activate and the view modes move the saved mark to the current workbook on a clean document, and the rollback put the workbook back but not the mark. Saved keeps and restores it. - overwrite: true answers the question however ask was given; with an explicit onConflict: ask the answer was ignored and the question came back forever. - The dry runs and the run share one step budget as they share one clock; each phase counted its own 100,000 steps before. ran reports the run's own steps. --- crates/engine/src/macros/run.rs | 79 +++++++++++++++++------ crates/engine/src/tests/macros.rs | 104 ++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 36fd1ac41..4699a5e7c 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -769,6 +769,9 @@ fn compare(v: &Value, op: CompareOp, want: &Json) -> bool { #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Footprint { + /// Steps executed by the end of the dry run that made this footprint (the run's one budget). + #[serde(skip)] + pub executed: u64, pub sheets: Vec, pub sheets_added: Vec, pub sheets_removed: Vec, @@ -839,6 +842,9 @@ impl Footprint { struct Saved { active: usize, docs: Vec, + /// The workbook as last saved: `sheet.activate` and the view commands move it to the + /// current workbook on a clean document, and the rollback puts the workbook back but not this. + saved: Option>, sheet_selections: Vec, unlocked_ranges: Vec<(String, String)>, trust: Trust, @@ -863,6 +869,7 @@ impl Saved { Saved { active: s.active, docs: s.docs.iter().map(|d| d.uid).collect(), + saved: d.map(|d| d.saved.clone()), sheet_selections: d.map(|d| d.sheet_selections.clone()).unwrap_or_default(), unlocked_ranges: d.map(|d| d.unlocked_ranges.clone()).unwrap_or_default(), trust: d.map_or(Trust::Local, |d| d.trust), @@ -885,6 +892,9 @@ impl Saved { fn restore(self, s: &mut Session, doc: u64) { s.docs.retain(|d| self.docs.contains(&d.uid)); if let Some(d) = s.docs.iter_mut().find(|d| d.uid == doc) { + if let Some(saved) = self.saved { + d.saved = saved; + } d.sheet_selections = self.sheet_selections; d.unlocked_ranges = self.unlocked_ranges; d.trust = self.trust; @@ -908,7 +918,7 @@ impl Saved { /// Runs the macro in dry-run mode on its workbook, puts everything back, and says what it would /// change. The dry run stops once `time_limit_ms` has passed on `clock`. -pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64) -> Result { +pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: f64, executed: u64) -> Result { let i = s.docs.iter().position(|d| d.uid == p.doc).ok_or(EngineError::NoDocument)?; s.set_active(i); let saved = Saved::take(s, p.doc); @@ -918,7 +928,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: d.touched = Some(Vec::new()); } let mut m = Machine::new(p, true); - (m.clock, m.time_limit_ms) = (clock, time_limit_ms); + (m.clock, m.time_limit_ms, m.executed) = (clock, time_limit_ms, executed); let outcome = m.run_to_end(s); let (after, touched) = match s.docs.iter_mut().find(|d| d.uid == p.doc) { Some(d) => (d.wb.clone(), d.touched.take().unwrap_or_default()), @@ -929,6 +939,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: rolled?; let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.executed = m.executed; fp.error = outcome.err(); fp.stopped = m.stopped.map(|(msg, _)| msg); fp.partial = m.partial.map(|(step, reason)| json!({"step": step, "reason": reason})); @@ -1069,16 +1080,38 @@ pub(crate) fn ranges(cells: &mut [CellRef]) -> Vec { } /// Inserts blank cells where the footprint lands (moving what is there down or right), so the -/// macro writes into empty cells. -fn make_room(s: &mut Session, fp: &Footprint, down: bool) -> Result<()> { +/// macro writes into empty cells. The block is every written cell's, so a row the macro writes +/// stays together. A shift that would move one of the macro's inputs is refused: the inputs were +/// checked and bound where they are, and only the block's columns (or rows) would move. +fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Result<()> { if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); } let wb = s.doc()?.wb.clone(); + let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; + let mut moves = Vec::new(); for sh in fp.sheets.iter().filter(|s| s.conflict_count > 0) { - let Some(bounds) = sh.bounds_range else { continue }; - let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; - let range = resolve::a1(Place { sheet: sh.index, range: bounds }, &env); + let Some(block) = sh.bounds_range else { continue }; + // What the insertion moves: the block's columns from its top row down, or its rows from + // its left column rightwards. + let moved = |r: &RangeRef| { + if down { + r.start.col <= block.end.col && r.end.col >= block.start.col && r.end.row >= block.start.row + } else { + r.start.row <= block.end.row && r.end.row >= block.start.row && r.end.col >= block.start.col + } + }; + // The output is where the macro writes, so moving what is there is the point; the second + // footprint catches data the shift moved into it. + if let Some((name, pl)) = p.inputs.iter().map(|(n, pl, _)| (n.as_str(), *pl)).find(|(_, pl)| pl.sheet == sh.index && moved(&pl.range)) { + return Err(EngineError::Other(format!( + "Shifting the data would move {name} ({}). Run with onConflict overwrite or fail, or make room first.", + resolve::a1(pl, &env) + ))); + } + moves.push(resolve::a1(Place { sheet: sh.index, range: block }, &env)); + } + for range in moves { s.execute("home.insertCells", json!({"range": range, "shift": if down { "down" } else { "right" }}))?; } Ok(()) @@ -1127,7 +1160,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { if rule == OnConflict::Overwrite { return Ok((machine, false)); } - let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?; + // The dry runs and the run share one step budget as they share one clock. + let mut fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; + machine.executed = fp.executed; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1145,8 +1180,9 @@ pub fn make_way(s: &mut Session, b: &Begun) -> Result<(Machine, bool)> { return Err(EngineError::Other(format!("Macro '{}' would write over cells that hold data ({what}). Nothing was changed.", m.name))); } OnConflict::ShiftDown | OnConflict::ShiftRight => { - make_room(s, &fp, rule == OnConflict::ShiftDown)?; - fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms)?; + make_room(s, &fp, &b.prepared, rule == OnConflict::ShiftDown)?; + fp = footprint(s, &b.prepared, machine.clock, b.time_limit_ms, machine.executed)?; + machine.executed = fp.executed; if let Some(e) = fp.error.take() { return Err(e.into_engine(&m.name)); } @@ -1176,11 +1212,13 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { s.grouped(&label, |s| { let (mut machine, shifted) = make_way(s, &b)?; let name = &b.mac.name; + // `ran` counts the run's own steps; the budget also holds the dry runs'. + let before_run = machine.executed; machine.run_to_end(s).map_err(|e| e.into_engine(name))?; match machine.stopped { Some((msg, true)) => Err(EngineError::Other(format!("Macro '{name}' stopped: {msg} Nothing was changed."))), stopped => { - let mut out = json!({"macro": name, "ran": machine.executed, "inputs": b.inputs}); + let mut out = json!({"macro": name, "ran": machine.executed.saturating_sub(before_run), "inputs": b.inputs}); if let Some((msg, _)) = stopped { out["stopped"] = json!(msg); } @@ -1198,17 +1236,18 @@ fn time_limit(p: &Json) -> f64 { p.get("timeLimit").and_then(Json::as_f64).map(|t| (t * 1000.0).clamp(1.0, 600_000.0)).unwrap_or(DEFAULT_TIME_LIMIT_MS) } -/// The conflict rule for this run: `onConflict`, or `overwrite: true` (the answer to the -/// question), else the macro's own. +/// The conflict rule for this run: `onConflict`, else the macro's own. `overwrite: true` is the +/// answer to the question, so it turns `ask` into `overwrite` whichever way `ask` was given. fn conflict_rule(p: &Json, m: &Macro) -> Result { - if let Some(t) = p.get("onConflict").and_then(Json::as_str) { - return OnConflict::parse(t) - .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'."))); - } - if p.get("overwrite").and_then(Json::as_bool) == Some(true) { + let rule = match p.get("onConflict").and_then(Json::as_str) { + Some(t) => OnConflict::parse(t) + .ok_or_else(|| EngineError::Other(format!("onConflict must be ask, fail, overwrite, shiftDown or shiftRight, not '{t}'.")))?, + None => m.output.on_conflict, + }; + if rule == OnConflict::Ask && p.get("overwrite").and_then(Json::as_bool) == Some(true) { return Ok(OnConflict::Overwrite); } - Ok(m.output.on_conflict) + Ok(rule) } /// `macro.check`: what running the macro would do, without changing anything. @@ -1247,7 +1286,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { if !asks.is_empty() && !approved { return Ok(out); } - let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p))?; + let fp = footprint(s, &prepared, Stopwatch::start(), time_limit(p), 0)?; out["ok"] = json!(fp.error.is_none()); out["conflicts"] = json!(fp.conflict_count()); if let Some(e) = &fp.error { diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index a42a60dd7..d1babe4eb 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -232,6 +232,110 @@ fn shifting_makes_room_and_is_checked_again() { assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); } +#[test] +fn a_shift_that_would_move_an_input_is_refused() { + // A shift inserts cells over the conflicting block, which moves the block's columns (or rows) + // from there on. When one of the macro's own inputs lies in the way, its data would move and + // shear while the run keeps the old coordinates, so the shift is refused and nothing changes. + let mut s = s(); + data(&mut s); + set(&mut s, "B1", "Old title"); + put( + &mut s, + json!({"name": "Title", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let start = s.doc().unwrap().wb.clone(); + let err = s.execute("macro.run", json!({"name": "Title", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input (") && err.contains("B2:C5"), "{err}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "nothing changed"); + assert_eq!((v(&s, "B1").display(), v(&s, "B3").display()), ("Old title".into(), "Ann".into())); + // Writing left of the input and shifting right would move it too. + put( + &mut s, + json!({"name": "Left", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "Input.offset(1,-1).cell(1,1)", "input": "x"}} + ]}), + ); + set(&mut s, "A3", "in the way"); + let err = s.execute("macro.run", json!({"name": "Left", "inputs": "B2:C5", "onConflict": "shiftRight"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // The output counts as well. + put( + &mut s, + json!({"name": "Out", "inputs": [{"name": "Input", "header": true}], "output": {"anchor": "Input.below(1)"}, "steps": [ + {"command": "cell.set", "params": {"cell": "Input.cell(0,1)", "input": "Report"}} + ]}), + ); + let err = s.execute("macro.run", json!({"name": "Out", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("would move Input"), "{err}"); + // A shift that moves nothing of the macro's own still works, over the block of every written + // cell, so a row the macro writes stays together. + set(&mut s, "H1", "elsewhere"); + put( + &mut s, + json!({"name": "Wide", "inputs": [{"name": "Input", "header": true}], "steps": [ + {"command": "cell.set", "params": {"cell": "H1", "input": "over"}}, + {"command": "cell.set", "params": {"cell": "H9", "input": "far"}} + ]}), + ); + s.execute("macro.run", json!({"name": "Wide", "inputs": "B2:C5", "onConflict": "shiftDown"})).unwrap(); + assert_eq!((v(&s, "H1").display(), v(&s, "H9").display(), v(&s, "H10").display()), ("over".into(), "far".into(), "elsewhere".into())); + assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); +} + +#[test] +fn a_dry_run_leaves_a_clean_workbook_clean() { + // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean + // document; the dry run's rollback puts the workbook back, so the mark must come back too. + let mut s = s(); + data(&mut s); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(!s.doc().unwrap().is_dirty()); + put(&mut s, json!({"name": "Look", "steps": [{"command": "view.zoom", "params": {"percent": 120}}]})); + s.execute("macro.check", json!({"name": "Look"})).unwrap(); + assert!(!s.doc().unwrap().is_dirty(), "a dry run changed nothing"); + // A run refused by the conflict rule changed nothing either. + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let d = s.doc_mut().unwrap(); + d.saved = d.wb.clone(); + assert!(s.execute("macro.run", json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "fail"})).is_err()); + assert!(!s.doc().unwrap().is_dirty()); +} + +#[test] +fn the_answer_to_the_question_counts_however_ask_was_given() { + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + set(&mut s, "E3", "taken"); + let p = json!({"name": "Totals", "inputs": "B2:C5", "onConflict": "ask"}); + assert!(matches!(s.execute("macro.run", p.clone()), Err(EngineError::Confirm(_)))); + let mut answered = p; + answered["overwrite"] = json!(true); + s.execute("macro.run", answered).unwrap(); + assert_eq!(v(&s, "E3").display(), "400"); +} + +#[test] +fn the_step_limit_is_one_budget_across_the_dry_run_and_the_run() { + // 300 x 300 passes is under the limit for one phase; the dry run and the run together are not. + let mut s = s(); + put( + &mut s, + json!({"name": "Spin", "steps": [{"type": "repeat", "over": "A1:A300", "body": [ + {"type": "repeat", "over": "A1:A300", "body": [{"type": "if", "cell": "A1", "op": "empty"}]} + ]}]}), + ); + let err = s.execute("macro.run", json!({"name": "Spin"})).unwrap_err().to_string(); + assert!(err.contains("more than 100000 steps"), "{err}"); + // With no dry run (overwrite), the same macro fits. + s.execute("macro.run", json!({"name": "Spin", "onConflict": "overwrite"})).unwrap(); +} + #[test] fn the_footprint_covers_other_sheets_objects_and_edges() { let mut s = s(); From fb4acb04a2d70a88db7ec0fd0c998f08775cca0c Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:14 -0400 Subject: [PATCH 60/63] Engine: closing the debugged workbook ends the debugger file.close is not an edit, so the debugger's lock let it through, and the session kept the debugger with a token for a group that went with the document: the next workbook could not start a debugger, and the state reported that no document was open. close_document (also the path that replaces an untouched blank workbook on file.open) now ends a debugger whose workbook it closes. Regression test. --- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/tests/macros.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 399bb1c88..c1de0c401 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -454,6 +454,10 @@ impl Session { if i < self.docs.len() { let d = self.docs.remove(i); self.groups.retain(|g| g.doc != d.uid); + // A macro being debugged in this workbook ends with it; its group just went. + if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) { + self.macro_debug = None; + } if self.active >= self.docs.len() { self.active = self.docs.len().saturating_sub(1); } diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index 207e4c944..95e3b632e 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -232,6 +232,26 @@ fn shifting_makes_room_and_is_checked_again() { assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start)); } +#[test] +fn closing_the_debugged_workbook_ends_the_debugger() { + // `file.close` is not an edit, so the debugger's lock lets it through; the group went with + // the document, and so must the debugger, or the next workbook could not debug anything and + // the state would report that no document is open. + let mut s = s(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + assert!(s.macro_debug.is_some()); + s.execute("file.close", json!({"force": true})).unwrap(); + assert!(s.macro_debug.is_none(), "the debugger ended with its workbook"); + s.new_workbook(); + data(&mut s); + put(&mut s, totals()); + s.execute("macro.debug", json!({"action": "start", "name": "Totals", "inputs": "B2:C5", "breakpoints": ["1"]})).unwrap(); + assert!(s.execute("macro.debug", json!({})).is_ok(), "the new workbook can be debugged"); + s.execute("macro.debug", json!({"action": "stop"})).unwrap(); +} + #[test] fn the_footprint_covers_other_sheets_objects_and_edges() { let mut s = s(); From a626705c958040ecf0c39af748c97351443e6ab7 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:35 -0400 Subject: [PATCH 61/63] Engine: a dry run puts the journal back even after the journal trimmed itself Every nested command a dry run executes is journaled, so a long macro pushes the journal past its limit and the journal drops its oldest entries. The restore cut the journal back to its old length, which then kept entries from the dry run and pointed Record Actions at the wrong place. The session now counts the entries its journal has dropped (journal_dropped) and truncates with its size bookkeeping (truncate_journal); the restore keeps the old length less what was dropped meanwhile and moves the recording's start back by the same count. Regression test with 12,000 nested steps. --- crates/engine/src/lib.rs | 19 +++++++++++++++++++ crates/engine/src/macros/run.rs | 11 +++++++++-- crates/engine/src/tests/macros.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 80084f7ca..1a89aa2b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -405,6 +405,9 @@ pub struct Session { /// Serialized size of each journal entry, measured once when it is pushed, and their sum. journal_sizes: Vec, journal_bytes: usize, + /// Entries the journal has dropped from its front to stay within its limits, ever; a dry run + /// that puts the journal back needs to know how many went while it ran. + journal_dropped: u64, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -529,10 +532,26 @@ impl Session { self.journal.drain(..n); self.journal_sizes.drain(..n); self.journal_bytes = kept; + self.journal_dropped = self.journal_dropped.saturating_add(n as u64); self.recording = self.recording.map(|r| r.saturating_sub(n)); } } + /// Entries dropped from the journal's front so far, for a caller that puts the journal back. + pub(crate) fn journal_dropped(&self) -> u64 { + self.journal_dropped + } + + /// Cuts the journal back to its first `keep` entries, with its size bookkeeping. + pub(crate) fn truncate_journal(&mut self, keep: usize) { + if self.journal_sizes.len() == self.journal.len() { + self.journal_sizes.truncate(keep); + self.journal_bytes = self.journal_sizes.iter().sum(); + } + self.journal.truncate(keep); + self.recording = self.recording.map(|r| r.min(self.journal.len())); + } + pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 4699a5e7c..889d3dd0f 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -851,7 +851,10 @@ struct Saved { clipboard: Option, prefs: crate::Prefs, mode: crate::Mode, + /// The journal's length and how many entries it had dropped from its front; a dry run that + /// pushes past the journal's limits drops more, which a length alone cannot see. journal: usize, + journal_dropped: u64, recording: Option, ui_requests: usize, format_painter: Option<(Arc, usize, RangeRef, bool)>, @@ -877,6 +880,7 @@ impl Saved { prefs: s.prefs.clone(), mode: s.mode, journal: s.journal.len(), + journal_dropped: s.journal_dropped(), recording: s.recording, ui_requests: s.ui_requests.len(), format_painter: s.format_painter.clone(), @@ -903,8 +907,11 @@ impl Saved { s.clipboard = self.clipboard; s.prefs = self.prefs; s.mode = self.mode; - s.journal.truncate(self.journal); - s.recording = self.recording; + // Entries the dry run pushed go; entries the journal dropped meanwhile are gone for good, + // and the recording's start moves back with them. + let dropped = usize::try_from(s.journal_dropped().saturating_sub(self.journal_dropped)).unwrap_or(usize::MAX); + s.truncate_journal(self.journal.saturating_sub(dropped)); + s.recording = self.recording.map(|r| r.saturating_sub(dropped)); s.ui_requests.truncate(self.ui_requests); s.format_painter = self.format_painter; s.last_find = self.last_find; diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index d1babe4eb..d2aa786d8 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -306,6 +306,36 @@ fn a_dry_run_leaves_a_clean_workbook_clean() { assert!(!s.doc().unwrap().is_dirty()); } +#[test] +fn a_dry_run_puts_the_journal_back_even_after_it_trimmed_it() { + // Every nested command a dry run executes is journaled, so a long macro pushes the journal + // past its limit and the journal drops its oldest entries. Putting the journal back by its + // old length would then keep entries from the dry run and point a recording at the wrong + // place; the restore counts what was dropped. + let mut s = s(); + data(&mut s); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + let journal = s.journal.clone(); + s.recording = Some(journal.len().saturating_sub(1)); + put( + &mut s, + json!({"name": "Long", "steps": [{"type": "repeat", "over": "A1:A200", "body": [ + {"type": "repeat", "over": "A1:A60", "body": [{"command": "cell.set", "params": {"cell": "Row.cell(1,1)", "input": "x"}}]} + ]}]}), + ); + s.execute("macro.check", json!({"name": "Long"})).unwrap(); + let sets = |j: &[(String, Json)]| j.iter().filter(|(id, _)| id == "cell.set").count(); + assert!(sets(&s.journal) <= sets(&journal), "no dry-run entry stays"); + assert!(s.journal.len() <= journal.len(), "{} entries, had {}", s.journal.len(), journal.len()); + assert!(journal.ends_with(&s.journal), "what stays is the journal's own tail"); + assert!(s.recording.is_some_and(|r| r <= s.journal.len()), "{:?}", s.recording); + // A short dry run leaves the journal exactly as it was. + put(&mut s, json!({"name": "Short", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "x"}}]})); + let (journal, recording) = (s.journal.clone(), s.recording); + s.execute("macro.check", json!({"name": "Short"})).unwrap(); + assert_eq!((&s.journal, s.recording), (&journal, recording)); +} + #[test] fn the_answer_to_the_question_counts_however_ask_was_given() { let mut s = s(); From 879b96f858dfc12e10662392d181f44e83ba5d09 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:35 -0400 Subject: [PATCH 62/63] Docs: the footprint dry run is skipped when the conflict rule is overwrite --- docs/macros.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/macros.md b/docs/macros.md index b757d26cb..a9f7f1340 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -96,8 +96,9 @@ refused rather than read as the header. are when the step runs. A macro that touches files, or that came with a workbook opened from a file, runs once the person approves it: the call fails with `approve: …` and runs with `approve: true`. Approval lasts for the session, until the macro changes. -2. **Footprint.** The macro first runs in dry-run mode on the real workbook, and everything is put - back. Cells it would write outside its inputs that hold data, and sheets and objects it would +2. **Footprint.** Unless the conflict rule is `overwrite`, the macro first runs in dry-run mode on + the real workbook, and everything is put back. Cells it would write outside its + inputs that hold data, and sheets and objects it would delete, are conflicts, handled by `onConflict` (or the macro's `output.onConflict`): `ask` (the call fails with `confirm: …`; `overwrite: true` answers it), `fail`, `overwrite`, `shiftDown` or `shiftRight` (Insert Cells makes room, then the footprint is checked again). Steps that touch From 2215934c9d3b105c5f22b7d519caf3563285505d Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:55:39 -0400 Subject: [PATCH 63/63] Engine: a shift is refused when the footprint cannot follow every write The touch log keeps at most a million writes; past that, a write that kept a cell's value is missing from the footprint, so a shift derived from the block of written cells could split a row the macro writes. The footprint now says when the log filled up (logFull in macro.check), and a shift is refused then, as it is when the macro deletes sheets or objects. Regression test with a million-cell fill in a dry run. --- crates/engine/src/cmd/mod.rs | 4 +++- crates/engine/src/macros/run.rs | 8 ++++++++ crates/engine/src/tests/macros.rs | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7527b37aa..ec73e2e2a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -401,7 +401,9 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } /// The most written cells a macro's footprint check records. -const TOUCH_LOG_LIMIT: usize = 1_000_000; +/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes +/// that kept a cell's value, so a shift is refused then. +pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000; /// Excel's message for an edit of a locked cell on a protected sheet. pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet."; diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index 889d3dd0f..ab67a9ce2 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -772,6 +772,10 @@ pub struct Footprint { /// Steps executed by the end of the dry run that made this footprint (the run's one budget). #[serde(skip)] pub executed: u64, + /// The dry run wrote more cells than the touch log keeps, so writes that kept a cell's value + /// may be missing from `cells`: a shift cannot trust the block. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub log_full: bool, pub sheets: Vec, pub sheets_added: Vec, pub sheets_removed: Vec, @@ -946,6 +950,7 @@ pub fn footprint(s: &mut Session, p: &Prepared, clock: Stopwatch, time_limit_ms: rolled?; let (after, touched) = if matches!(m.stopped, Some((_, true))) { (before.clone(), Vec::new()) } else { (after, touched) }; let mut fp = diff(&before, &after, &p.inputs, &touched); + fp.log_full = touched.len() >= crate::cmd::TOUCH_LOG_LIMIT; fp.executed = m.executed; fp.error = outcome.err(); fp.stopped = m.stopped.map(|(msg, _)| msg); @@ -1094,6 +1099,9 @@ fn make_room(s: &mut Session, fp: &Footprint, p: &Prepared, down: bool) -> Resul if !fp.sheets_removed.is_empty() || fp.objects_removed > 0 { return Err(EngineError::Other("Shifting can't make room: the macro deletes sheets or objects.".into())); } + if fp.log_full { + return Err(EngineError::Other("Shifting can't make room: the macro writes more cells than the footprint can follow.".into())); + } let wb = s.doc()?.wb.clone(); let env = Env { wb: &wb, active_sheet: usize::MAX, inputs: &[], output: None, item: None }; let mut moves = Vec::new(); diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index d2aa786d8..285716b83 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -285,6 +285,28 @@ fn a_shift_that_would_move_an_input_is_refused() { assert_eq!(v(&s, "B3").display(), "Ann", "the input did not move"); } +#[test] +fn a_shift_is_refused_when_the_footprint_cannot_follow_every_write() { + // The touch log keeps at most a million writes; past that, a write that kept a cell's value + // is missing from the footprint, so a shift could split a row the macro writes. The footprint + // says so and the shift is refused (overwrite, with no dry run, still runs). + let mut s = s(); + set(&mut s, "A1", "x"); + set(&mut s, "E2", "old"); + put( + &mut s, + json!({"name": "Flood", "steps": [ + {"command": "edit.fillDown", "params": {"range": "A1:A1000001"}}, + {"command": "cell.set", "params": {"cell": "E2", "input": "new"}} + ]}), + ); + let c = s.execute("macro.check", json!({"name": "Flood"})).unwrap(); + assert_eq!(c["footprint"]["logFull"], true, "{c}"); + let err = s.execute("macro.run", json!({"name": "Flood", "onConflict": "shiftDown"})).unwrap_err().to_string(); + assert!(err.contains("more cells than the footprint can follow"), "{err}"); + assert_eq!(v(&s, "E2").display(), "old", "nothing changed"); +} + #[test] fn a_dry_run_leaves_a_clean_workbook_clean() { // `view.zoom` and `sheet.activate` move the saved mark to the current workbook on a clean