Grid: deleting several Ctrl-selected rows removes them all (#758)

home.deleteRows/deleteColumns used only the last selected area. They now
take every area (or a comma list like "3:3,7:8"), merge them and delete
from the last to the first inside one edit, so it is one undo step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-12 19:24:36 +02:00
parent fc45b7f6d8
commit afce16c8d2
3 changed files with 73 additions and 14 deletions

View File

@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |

View File

@ -288,27 +288,62 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
Ok(out)
}
/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the
/// last line to the first so deleting one never shifts the next. A comma-separated list
/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form.
fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
let key = if axis == Axis::Rows { "rows" } else { "cols" };
let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() {
let (at, count) = lines(s, p, axis)?;
vec![(at, at.saturating_add(count - 1))]
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
t.split(',')
.map(|part| {
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
})
.collect::<Result<_>>()?
} else {
s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()
};
let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
spans.sort_unstable();
let mut merged: Vec<(u32, u32)> = Vec::new();
for (a, b) in spans {
match merged.last_mut() {
Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect())
}
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?;
check_lines_protection(s, p, axis, at, count, false)?;
let areas = delete_areas(s, p, axis)?;
for &(at, count) in &areas {
check_lines_protection(s, p, axis, at, count, false)?;
}
let sheet = target_sheet(s, p)?;
let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true;
{
let sh = cx.sheet_mut(sheet)?;
let block = match axis {
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
};
sh.take_cells(block);
match axis {
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
// Last area first, so the earlier ones keep their positions.
for (at, count) in areas {
{
let sh = cx.sheet_mut(sheet)?;
let block = match axis {
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
};
sh.take_cells(block);
match axis {
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
}
shift_sheet_features(sh, axis, at, count, false);
}
shift_sheet_features(sh, axis, at, count, false);
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
}
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
cx.structural = true;
Ok(Json::Null)
})?;

View File

@ -560,6 +560,28 @@ fn insert_delete_rows_adjust() {
assert_eq!(v(&s, "A1"), Value::Number(10.0));
}
#[test]
fn deleting_a_multi_area_row_selection_removes_every_area_and_undoes_once() {
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A6"})).unwrap();
// Ctrl-select rows 2, 4 and 5:6 (A1 refs are 1-based).
let sel = &mut s.doc_mut().unwrap().selection;
sel.ranges = vec![gridcraft_core::RangeRef::rows(1, 1), gridcraft_core::RangeRef::rows(3, 3), gridcraft_core::RangeRef::rows(4, 5)];
sel.active = CellRef::new(4, 0);
s.doc_mut().unwrap().undo.clear();
s.execute("home.deleteRows", json!({})).unwrap();
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(3.0), Value::Empty));
assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=#REF!");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!((1..=6).map(|r| v(&s, &format!("A{r}"))).collect::<Vec<_>>(), (1..=6).map(|n| Value::Number(n as f64)).collect::<Vec<_>>());
assert!(s.doc().unwrap().undo.is_empty(), "one undo step restored all rows");
// The explicit list form behaves the same, for columns too.
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("home.deleteColumns", json!({"cols": "A:A,C:C"})).unwrap();
assert_eq!((v(&s, "A1"), v(&s, "B1"), v(&s, "C1")), (Value::Number(2.0), Value::Number(4.0), Value::Empty));
}
#[test]
fn move_columns_reorders_without_overwriting_and_undoes_once() {
let mut s = s();