Browse / github.com/storytold/gridcraft / patch
fix(ui): support Y/N answers in Save Changes (#2572)
Support Y/N shortcuts in the Save Changes dialog, alongside Esc ### Environment GridCraft 0.12.1 on Windows. ### Request Please support single-key answers in the Save Changes confirmation shown when closing a workbook or exiting the app: - **Y**: Save, then continue closing only if saving succeeds. - **N**: Don't Save, then continue closing. - **Esc**: Cancel closing and keep the workbook open (retain the existing behavior). ### Current experience After editing a workbook and closing it with Ctrl+W or Alt+F4, Y and N do not select the corresponding choices in the save confirmation. This interrupts a familiar keyboard-only workflow. Ctrl+S already works;
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- TheRenegadeDev (imported from pull request #2675, open)
- Base
d8c5f5885fc5320f8f1023d5fdc9f484a185ba86- Size
- +331 −4 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f9b207d6e86dda2a0253a397
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/dialogs.rs crates/ui-egui/tests/doc_windows.rs crates/ui-egui/tests/it/file_menu.rs docs/ui-parity.md
@@ -45,6 +45,8 @@ pub struct Dialog { /// The dialog this one was opened from (Name Manager › New…): it comes back when this closes. back: Option<Box<Dialog>>, focus_initialized: bool,+ /// Save/Don't Save answer on release, before quitting can move focus to another viewport.+ save_changes_armed: Option<Key>, /// The formula box that takes references clicked or dragged on the grid (New Formatting Rule). pick: Option<RefPick>, }@@ -157,6 +159,7 @@ impl Dialog { picture_target: None, back: None, focus_initialized: false,+ save_changes_armed: None, pick: None, } }@@ -178,6 +181,7 @@ impl Dialog { picture_target: None, back: None, focus_initialized: false,+ save_changes_armed: None, pick: None, } }@@ -1455,15 +1459,21 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "saveChanges" => { ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true);+ let mut answer = save_changes_shortcut(&mut d, ui.ctx()); ui.horizontal(|ui| {- for (label, answer) in+ for (label, choice) in [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] {- if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) {- open = false;+ if ui.button(label).clicked() {+ answer = Some(choice); } } });+ if let Some(answer) = answer+ && app.answer_save_changes(answer, quitting, ui.ctx())+ {+ open = false;+ } } "start" => { ui.heading("Start a new workbook");@@ -1573,6 +1583,42 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } +fn save_changes_shortcut(d: &mut Dialog, ctx: &egui::Context) -> Option<crate::SaveAnswer> {+ let mut answer = None;+ ctx.input_mut(|i| {+ // Windows can synthesize key releases before its focus-loss event. Even if focus+ // returns in this batch, those releases must not answer the question.+ let focused = i.focused && !i.events.iter().any(|event| matches!(event, egui::Event::WindowFocused(false)));+ if !focused {+ d.save_changes_armed = None;+ }+ i.events.retain(|event| {+ match event {+ egui::Event::Key { key, pressed, repeat, modifiers, .. } => {+ if matches!(key, Key::Y | Key::N) {+ if *pressed {+ if !repeat {+ d.save_changes_armed = (focused && modifiers.is_none()).then_some(*key);+ }+ } else if d.save_changes_armed.take() == Some(*key) && focused && modifiers.is_none() {+ answer.get_or_insert(if *key == Key::Y { crate::SaveAnswer::Save } else { crate::SaveAnswer::DontSave });+ }+ } else if *key == Key::Escape && *pressed && !repeat && focused && modifiers.is_none() {+ answer.get_or_insert(crate::SaveAnswer::Cancel);+ }+ // Consume the whole answer gesture, including repeats, before another+ // workbook's question can be drawn in this frame.+ return !modifiers.is_none() || !matches!(key, Key::Y | Key::N | Key::Escape);+ }+ egui::Event::Text(text) if matches!(text.as_str(), "y" | "Y" | "n" | "N") => return false,+ _ => {}+ }+ true+ });+ });+ answer+}+ /// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip /// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options /// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same.@@ -16,6 +16,8 @@ use serde_json::json; thread_local! { /// Each viewport's input for the next frame (focus, close button). static INFOS: RefCell<egui::ViewportIdMap<ViewportInfo>> = RefCell::new(egui::ViewportIdMap::default());+ /// Keyboard input belongs to one native viewport, as it does in eframe.+ static KEYS: RefCell<egui::ViewportIdMap<Vec<egui::Event>>> = RefCell::new(egui::ViewportIdMap::default()); /// The viewport commands the app sent this frame. static SENT: RefCell<Vec<(ViewportId, ViewportCommand)>> = const { RefCell::new(Vec::new()) }; }@@ -39,12 +41,14 @@ struct Desk { impl Desk { fn new(quit_with_last_window: bool) -> Desk { INFOS.with(|i| i.borrow_mut().clear());+ KEYS.with(|i| i.borrow_mut().clear()); let ctx = Context::default(); ctx.set_embed_viewports(false); Context::set_immediate_viewport_renderer(|ctx, viewport| { let id = viewport.ids.this; let mut input = RawInput { viewport_id: id, ..RawInput::default() }; input.viewports = INFOS.with(|i| i.borrow().clone());+ input.events = KEYS.with(|i| i.borrow_mut().remove(&id).unwrap_or_default()); input.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); let mut cb = viewport.viewport_ui_cb; record(ctx.run_ui(input, |ui| cb(ui)));@@ -64,6 +68,7 @@ impl Desk { SENT.with(|s| s.borrow_mut().clear()); let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() }; input.viewports = INFOS.with(|i| i.borrow().clone());+ input.events = KEYS.with(|i| i.borrow_mut().remove(&ViewportId::ROOT).unwrap_or_default()); input.viewports.entry(ViewportId::ROOT).or_default(); let Desk { ctx, app, windows } = self; let mut act = Some(act);@@ -86,6 +91,20 @@ impl Desk { (0..n).flat_map(|_| self.frame_with(|_, _| {})).collect() } + fn answer_key(&mut self, window: u64, key: egui::Key, pressed: bool) -> Vec<(ViewportId, ViewportCommand)> {+ let vp = self.viewport(window);+ KEYS.with(|i| {+ i.borrow_mut().entry(vp).or_default().push(egui::Event::Key {+ key,+ physical_key: None,+ pressed,+ repeat: false,+ modifiers: egui::Modifiers::NONE,+ })+ });+ self.frames(3)+ }+ fn viewport(&self, window: u64) -> ViewportId { self.windows.viewport_of(window).unwrap_or_else(|| panic!("window {window} has no native window")) }@@ -121,6 +140,74 @@ fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &Viewpor sent.iter().any(|(id, c)| *id == vp && c == cmd) } +#[test]+fn held_n_discards_only_one_workbook_when_quit_moves_to_another_native_viewport() {+ let mut desk = Desk::new(true);+ // Keep the clean root window, so the two dirty workbooks have distinct child viewports.+ for _ in 0..2 {+ desk.app.run("file.new", json!({})).unwrap();+ desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();+ }+ desk.frames(3);+ let (w2, w3) = (desk.window_of(1), desk.window_of(2));+ assert_ne!(desk.viewport(w2), desk.viewport(w3));+ desk.frame_with(|app, ctx| app.request_quit(ctx));+ desk.frames(3);+ for _ in 0..3 {+ desk.answer_key(w2, egui::Key::N, true);+ assert_eq!(desk.app.session.documents().len(), 3, "holding N does not close a window");+ }+ desk.answer_key(w2, egui::Key::N, false);+ assert_eq!(desk.app.session.documents().len(), 2, "release discards the first dirty workbook");+ assert_eq!(desk.app.session.active_window(), Some(w3));+ assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges"));+ desk.answer_key(w3, egui::Key::N, false);+ assert_eq!(desk.app.session.documents().len(), 2, "a stray release cannot answer the next question");+ desk.answer_key(w3, egui::Key::N, true);+ let sent = desk.answer_key(w3, egui::Key::N, false);+ assert_eq!(desk.app.session.documents().len(), 1);+ assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close));+}++#[test]+fn held_y_saves_only_one_workbook_when_quit_moves_to_another_native_viewport() {+ let dir = std::env::temp_dir().join(format!("gridcraft-native-held-y-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let paths = [dir.join("First.xlsx"), dir.join("Second.xlsx")];+ let mut desk = Desk::new(true);+ // Both dirty child windows have a file, so Y can save without a picker.+ for path in &paths {+ desk.app.run("file.new", json!({})).unwrap();+ desk.app.run("file.save", json!({"path": path})).unwrap();+ desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();+ }+ desk.frames(3);+ let (w2, w3) = (desk.window_of(1), desk.window_of(2));+ assert_ne!(desk.viewport(w2), desk.viewport(w3));+ desk.frame_with(|app, ctx| app.request_quit(ctx));+ desk.frames(3);+ for _ in 0..3 {+ desk.answer_key(w2, egui::Key::Y, true);+ assert_eq!(desk.app.session.documents().len(), 3, "holding Y must not advance to another window");+ }+ desk.answer_key(w2, egui::Key::Y, false);+ assert_eq!(desk.app.session.documents().len(), 2);+ assert_eq!(desk.app.session.active_window(), Some(w3));+ assert!(desk.app.session.doc().unwrap().is_dirty(), "the second workbook has not been saved");+ let (saved, _) = gridcraft_engine::io::open_bytes("First.xlsx", &std::fs::read(&paths[0]).unwrap()).unwrap();+ assert_eq!(saved.active().unwrap().value(CellRef::new(0, 0)), Value::Number(42.0));+ let (untouched, _) = gridcraft_engine::io::open_bytes("Second.xlsx", &std::fs::read(&paths[1]).unwrap()).unwrap();+ assert_eq!(untouched.active().unwrap().value(CellRef::new(0, 0)), Value::Empty);+ desk.answer_key(w3, egui::Key::Y, false);+ assert_eq!(desk.app.session.documents().len(), 2, "a stray release does not answer the next question");+ desk.answer_key(w3, egui::Key::Y, true);+ assert_eq!(desk.app.session.documents().len(), 2);+ let sent = desk.answer_key(w3, egui::Key::Y, false);+ assert_eq!(desk.app.session.documents().len(), 1);+ assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close));+ std::fs::remove_dir_all(dir).unwrap();+}+ #[test] fn each_window_keeps_its_own_selection_and_draft() { let mut desk = Desk::new(true);@@ -300,6 +300,198 @@ fn quitting_with_one_of_two_workbooks_unsaved_asks_once() { assert!(h.state().dialog.is_none()); } +#[test]+fn save_changes_shortcuts_save_before_closing() {+ let dir = std::env::temp_dir().join(format!("gridcraft-close-y-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("Book.xlsx");+ let mut h = harness(Services::default());+ h.state_mut().run("file.save", serde_json::json!({"path": path.to_string_lossy()})).unwrap();+ h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();+ request_window_close(&mut h);+ assert_eq!(h.state().dialog.as_ref().unwrap().values.get("quit"), Some(&serde_json::json!(true)));+ h.key_press(egui::Key::Y);+ let sent = step_commands(&mut h);+ assert!(sent.contains(&egui::ViewportCommand::Close), "Y saves and continues quitting: {sent:?}");+ assert!(h.state().dialog.is_none());+ let (wb, _) = gridcraft_engine::io::open_bytes("Book.xlsx", &std::fs::read(&path).unwrap()).unwrap();+ assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(42.0));+ std::fs::remove_dir_all(dir).unwrap();+}++#[test]+fn save_changes_shortcuts_cancel_or_discard_only_the_requested_workbook() {+ let mut h = harness(Services::default());+ h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();+ h.state_mut().run("file.new", serde_json::json!({})).unwrap();+ h.state_mut().run("cell.set", serde_json::json!({"cell": "B2", "input": "7"})).unwrap();+ let kept = h.state().session.doc().unwrap().uid;+ // File Close asks about this workbook without starting an application quit.+ h.state_mut().run_or_alert("file.close", serde_json::json!({}));+ h.run_steps(3);+ h.key_press(egui::Key::Escape);+ let sent = step_commands(&mut h);+ assert!(!sent.contains(&egui::ViewportCommand::Close));+ assert!(h.state().dialog.is_none());+ assert_eq!(h.state().session.documents().len(), 2);+ assert_eq!(h.state().session.doc().unwrap().uid, kept);+ h.state_mut().run_or_alert("file.close", serde_json::json!({}));+ h.run_steps(3);+ h.key_press(egui::Key::N);+ let sent = step_commands(&mut h);+ assert!(!sent.contains(&egui::ViewportCommand::Close));+ assert_eq!(h.state().session.documents().len(), 1);+ assert_ne!(h.state().session.doc().unwrap().uid, kept);+ assert!(h.state().dialog.is_none());+}++#[test]+fn save_changes_shortcuts_ignore_modified_answers_and_held_n() {+ let mut h = harness(Services::default());+ for _ in 0..3 {+ h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();+ h.state_mut().run("file.new", serde_json::json!({})).unwrap();+ }+ request_window_close(&mut h);+ let count = h.state().session.documents().len();+ for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND, egui::Modifiers::ALT, egui::Modifiers::SHIFT, egui::Modifiers::MAC_CMD] {+ for key in [egui::Key::Y, egui::Key::N] {+ h.key_press_modifiers(modifiers, key);+ step_commands(&mut h);+ assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));+ assert_eq!(h.state().session.documents().len(), count);+ assert!(h.state().session.doc().unwrap().is_dirty());+ }+ }+ // Don't discard until release: a quit can move its next question to a different native+ // viewport, whose independent input state would treat a held N as a fresh press.+ h.input_mut().events.push(egui::Event::Key {+ key: egui::Key::N,+ physical_key: None,+ pressed: true,+ repeat: false,+ modifiers: egui::Modifiers::NONE,+ });+ step_commands(&mut h);+ assert_eq!(h.state().session.documents().len(), count);+ let current = h.state().session.doc().unwrap().uid;+ for _ in 0..3 {+ h.input_mut().events.push(egui::Event::Key {+ key: egui::Key::N,+ physical_key: None,+ pressed: true,+ repeat: true,+ modifiers: egui::Modifiers::NONE,+ });+ let sent = step_commands(&mut h);+ assert!(!sent.contains(&egui::ViewportCommand::Close));+ assert_eq!(h.state().session.documents().len(), count);+ assert_eq!(h.state().session.doc().unwrap().uid, current);+ assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));+ }+ h.input_mut().events.push(egui::Event::Key {+ key: egui::Key::N,+ physical_key: None,+ pressed: false,+ repeat: false,+ modifiers: egui::Modifiers::NONE,+ });+ step_commands(&mut h);+ assert_eq!(h.state().session.documents().len(), count - 1, "releasing N answers exactly one question");+ h.key_press(egui::Key::N);+ step_commands(&mut h);+ assert_eq!(h.state().session.documents().len(), count - 2, "a fresh press can answer the next question");+}++#[test]+fn save_changes_shortcuts_ignore_releases_from_focus_loss() {+ let dir = std::env::temp_dir().join(format!("gridcraft-answer-focus-loss-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ for key in [egui::Key::N, egui::Key::Y] {+ for regain_focus in [false, true] {+ let mut h = harness(Services::default());+ let path = dir.join(format!("{key:?}-{regain_focus}.xlsx"));+ h.state_mut().run("file.save", serde_json::json!({"path": path})).unwrap();+ h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();+ let uid = h.state().session.doc().unwrap().uid;+ request_window_close(&mut h);+ h.key_down(key);+ h.run_steps(1);+ // Windows synthesizes releases before reporting focus loss. Focus may also+ // return before the next frame; neither batch represents a physical answer.+ h.input_mut().focused = regain_focus;+ h.input_mut().events.extend([+ egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE },+ egui::Event::WindowFocused(false),+ ]);+ if regain_focus {+ h.input_mut().events.push(egui::Event::WindowFocused(true));+ }+ let sent = step_commands(&mut h);+ assert!(!sent.contains(&egui::ViewportCommand::Close), "{key:?} must not answer on focus loss");+ assert_eq!(h.state().session.doc().unwrap().uid, uid);+ assert!(h.state().session.doc().unwrap().is_dirty());+ assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));+ h.input_mut().focused = true;+ h.input_mut().events.push(egui::Event::WindowFocused(true));+ h.run_steps(1);+ h.key_press(key);+ let sent = step_commands(&mut h);+ assert!(sent.contains(&egui::ViewportCommand::Close), "a fresh {key:?} gesture after focus returns still works");+ }+ }+ std::fs::remove_dir_all(dir).unwrap();+}++#[test]+fn save_changes_shortcuts_keep_unsaved_work_after_picker_cancel_or_save_failure() {+ for fail in [false, true] {+ let dir = std::env::temp_dir().join(format!("gridcraft-close-y-failure-{}.xlsx", std::process::id()));+ // A directory is an invalid file destination on every supported platform.+ std::fs::create_dir_all(&dir).unwrap();+ let target = dir.clone();+ let picked = Arc::new(Mutex::new(0));+ let capture = picked.clone();+ let mut h = harness(Services {+ pick_save: Some(Box::new(move |_| {+ *capture.lock().unwrap() += 1;+ fail.then(|| target.to_string_lossy().into_owned())+ })),+ ..Default::default()+ });+ h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();+ let uid = h.state().session.doc().unwrap().uid;+ request_window_close(&mut h);+ h.key_press(egui::Key::Y);+ let sent = step_commands(&mut h);+ assert_eq!(*picked.lock().unwrap(), 1, "Y attempts Save As");+ assert!(!sent.contains(&egui::ViewportCommand::Close));+ assert_eq!(h.state().session.doc().unwrap().uid, uid);+ assert!(h.state().session.doc().unwrap().is_dirty());+ assert_eq!(a1(&h, 0), Value::Number(42.0));+ assert_eq!(h.state().message.is_some(), fail);+ std::fs::remove_dir_all(dir).unwrap();+ }+}++#[test]+fn save_changes_shortcuts_do_not_answer_other_dialogs_or_steal_cell_text() {+ let mut h = harness(Services::default());+ h.input_mut().events.push(egui::Event::Text("yn".into()));+ h.run_steps(2);+ assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("yn"));+ h.key_press(egui::Key::Enter);+ h.run_steps(2);+ h.state_mut().open_dialog("about", serde_json::json!({}));+ h.run_steps(3);+ for key in [egui::Key::Y, egui::Key::N] {+ h.key_press(key);+ h.run_steps(3);+ assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("about"));+ assert_eq!(h.state().session.documents().len(), 1);+ }+}+ /// Book1 has a cell being typed when File › New runs (the macOS menu runs the command directly). fn typing_in_book1_then_new(h: &mut egui_kittest::Harness<'static, SheetApp>) -> u64 { h.input_mut().events.push(egui::Event::Text("hello".into()));@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (single-key answers in Save Changes) · **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@@ -104,6 +104,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w |---|---|---|---| | Ctrl+C / X / V, Ctrl+Z / Y, Ctrl+A, Ctrl+B / I / U | same with Cmd | clipboard, undo/redo, select all, bold/italic/underline | present | | Ctrl+S, Ctrl+Shift+S, Ctrl+O, Ctrl+N, Ctrl+W | Cmd | save, save as, open, new, close | present |+| Y / N / Esc in Save Changes | same | Save / Don't Save / Cancel | added (#2572); Y/N require no modifiers and answer on release so holding a key cannot answer successive workbooks; focus loss cancels the gesture; a cancelled or failed save keeps the workbook open | | Ctrl+F, Ctrl+H, Ctrl+G, Ctrl+K, Ctrl+T | Cmd (Ctrl+H) | find, replace, go to, hyperlink, table | present | | Ctrl+D, Ctrl+R, Ctrl+E, Ctrl+1, Ctrl+9 / 0, Ctrl+Shift+9 / 0 | Cmd (Ctrl+E on Mac: see below) | fill down/right, Flash Fill, Format Cells, hide/unhide | present | | Ctrl+- , Ctrl+Shift+= | Cmd | Delete / Insert cells dialog | present |@@ -187,6 +188,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Save Changes accepts unmodified Y (Save) and N (Don't Save) on release through the existing button actions; Esc still cancels. Held keys cannot answer successive workbooks, and focus loss cancels the gesture; cancelling Save As or a failed save keeps the workbook open (#2572) | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |