From 58e876a4d8fc7ccda8aff2c7c8cca31367f56684 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 07:52:14 -0600 Subject: [PATCH] #2120: Remove Hyperlink clears links on cells with nothing stored Addresses review comment 4455436875: `clear` clipped each range to the sheet's used range (stored cells) before removing links and notes, so a link from a file on an empty cell outside it was left in place, and nothing was removed on a sheet with no cells. Notes and links are now cleared over the whole range; cells and merges still only within the used range. Test: engine tests::clear_hyperlinks_removes_links_on_cells_with_nothing_stored. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 28 +++++++++++++--------------- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..9a74a2d9d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -245,20 +245,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) }; edit(s, |cx| { - for r in &ranges { - let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) { - Some(u) => match r.intersection(&u) { - Some(x) => x, - None => continue, - }, - None => continue, + for &whole in &ranges { + // Cells are only stored inside the used range. Notes and links are kept apart from + // them, so they are cleared over the whole range: one from a file can sit on a cell + // with nothing stored in it. + let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u)); + let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default(); + let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) { + (Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(), + _ => Vec::new(), }; - let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default(); - let cells: Vec<(CellRef, Cell)> = cx - .wb - .sheet(sheet) - .map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect()) - .unwrap_or_default(); // Report every stored cell we touch, even beyond a million coordinates in a sparse // selection. Removed formula anchors must also reach calc so their spills disappear. cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c))); @@ -294,14 +290,16 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { _ => {} } } - let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row); + let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row); if matches!(what, "all" | "comments") { sh.comments.retain(|c, _| !cleared(*c)); } if matches!(what, "all" | "hyperlinks") { sh.hyperlinks.retain(|c, _| !cleared(*c)); } - if matches!(what, "all" | "formats") { + if let Some(r) = used + && matches!(what, "all" | "formats") + { let mut unmerged = false; sh.merges.retain(|m| { let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5f92718ba..272abffe2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5457,3 +5457,23 @@ fn a_view_kept_through_redo_doesnt_make_the_saved_workbook_dirty() { assert_eq!(d.wb.active().unwrap().view, gridcraft_model::SheetViewMode::PageBreakPreview); assert!(!d.is_dirty(), "back at the saved edits, the view alone isn't a change"); } + +/// A link from a file can sit on a cell with nothing stored in it, past the used range (or on a +/// sheet with no cells at all). Clear Hyperlinks, and Remove Hyperlink in the cell menu, must +/// still remove it. +#[test] +fn clear_hyperlinks_removes_links_on_cells_with_nothing_stored() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + let link = || gridcraft_model::Hyperlink { target: "https://example.com".into(), tooltip: None, external: true }; + let (a1, c5) = (CellRef::parse("A1").unwrap(), CellRef::parse("C5").unwrap()); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(c5, link()); + s.execute("edit.clearHyperlinks", json!({"range": "C5"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&c5), "the link past the used range is removed"); + + // No cells at all. + s.execute("edit.clearAll", json!({"range": "A1"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().hyperlinks.insert(a1, link()); + s.execute("edit.clearHyperlinks", json!({"range": "A1"})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().hyperlinks.contains_key(&a1), "the link on an empty sheet is removed"); +}