Skip to content

Commit 7510c9f

Browse files
fix: correctness, architecture, security, and UX sweep (pass 2)
Builds on 1482b7e. Roughly 150 fixes across 25 files. All 538 unit tests + ~143 PTY/integration tests pass; one new regression test added for the cross-sheet cascade. Correctness — formula engine - TEXTBEFORE/TEXTAFTER empty-delim infinite loop → #VALUE! - CLEAN strips only control chars (preserves CJK/accents) - CHAR(-1) / CODE("") / FIND not-found / MID overflow → typed errors - GCD([]) / PMT nper=0 / DATE NaN / TIME negative / EDATE fractional - SORT comparator no longer treats text as 0 (Excel mixed-type rule) - MATCH/VLOOKUP/XLOOKUP approximate match works on text keys - Cross-type equality strict (1="1" is FALSE per Excel) - OFFSET returns Value::Array (INDEX over OFFSET now correct) - INDIRECT bad string returns Value::Error(Ref) — trappable by IFERROR - 110 registry Err returns swept to Ok(Value::Error) with proper kinds - numbers_equal tightened: no more 1e-15 == 2e-15 bucket bug Correctness — workbook / dep graph - Cross-sheet propagation now triggers downstream same-sheet recalc; Sheet1!A1 → Sheet2!A1 → Sheet2!B1 no longer leaves B1 stale. Regression test added. - 3-D range refs (Sheet1:Sheet3!A1) expand all intermediate sheets at registration; changes on Sheet2!A1 now trigger recalc. - Workbook clone in *_on_active gated on whether any qualified ref exists. Common case (single-sheet, no cross-sheet refs) skips the per-write clone entirely. App state - Snapshot-based undo (UndoAction::WorkbookSnapshot) + with_snapshot_undo helper. :sheet new/delete, :name, :unname now reversible. - Autofill produces one batched undo entry (was N entries). - CSV import uses set_many — fixes O(N²) dep-graph cascade on import. - replace_current advances past formula-skip; replace_all preserves redo on no-op. - :iterative on/off/max/epsilon triggers recalc + sets dirty. - :freeze/:unfreeze set dirty. - NumberFormat::General preserves bold/color (was wiping style). - paste_tsv runs cycle check (was allowing self-ref formulas). - CellData::numeric() helper centralizes f64 parse with finite check; iterative-calc loop uses it. UI/UX - #N/A styled as error (matcher extended to known-error set). - Clipped cells get ellipsis indicator (per-column display-width). - Popup widths use unicode display width not byte length (CJK fix). - Chart popup no longer panics on tiny terminal; Esc hint added. - Frozen-cell color brighter (was near-invisible on dark themes). - F5 binding wired up (was documented but unbound). - search_results_set HashSet mirror — kills O(N×visible) Vec::contains per frame during search. Security / IO - GET() refuses unsafe reqwest::blocking::get fallback when Client::build fails (closed SSRF-bypass hole). - .tshts load rejects files without 'sheets'/'cells' field (was loading bare {} as empty workbook → silent data loss on next save). - Sidecar reader capped at MAX_SIDECAR_BYTES (was uncapped → OOM risk). - .xlsx save skips view-state snapshot (xlsx can't round-trip it). - XLSX numeric <v> defensively XML-escaped. - recent.json keeps entries on permission-denied (was purging on temporarily-unreachable mounts). Process / shutdown - SIGTERM/SIGHUP handlers via signal-hook. Logout/shutdown no longer drops queued autosave writes. - autosave::flush_now + wait_until_idle for synchronous shutdown flush. - Panic-path terminal restore no longer swallows run_app's original error.
1 parent 1482b7e commit 7510c9f

37 files changed

Lines changed: 1009 additions & 323 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ unicode-width = "0.2"
2525
regex = "1"
2626
calamine = "0.24"
2727
zip = { version = "2", default-features = false, features = ["deflate"] }
28+
# SIGTERM / SIGHUP handler — flips an AtomicBool the main loop polls so we
29+
# can flush the autosave worker before exit instead of letting logout/
30+
# shutdown drop queued writes.
31+
signal-hook = "0.3"
2832

2933
[dev-dependencies]
3034
tempfile = "3.0"

src/application/state/autofill.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ impl App {
3636
}
3737

3838
let num_changes = changes.len();
39-
for (row, col, cell_data) in changes {
40-
self.set_cell_with_undo(row, col, cell_data);
41-
}
39+
// Single batched undo entry rather than one per cell. A 20-cell
40+
// autofill used to require 20 separate `u` presses to roll back;
41+
// now it's one.
42+
self.set_many_with_undo(changes);
4243

4344
if num_changes > 0 {
4445
let suffix = if skipped > 0 {

src/application/state/clipboard.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,36 @@ impl App {
279279
// way for plain-text formula paste. The tshts→tshts path in
280280
// `paste()` handles relative adjustment properly.
281281
let new_cell = if value.starts_with('=') {
282+
// Cycle check: pasted text bypasses finish_editing_in_direction,
283+
// so a `=A1` pasted into A1 would otherwise be accepted.
284+
if !self.iterative_calc {
285+
let names = self.workbook.named_ranges.clone();
286+
let evaluator = FormulaEvaluator::for_workbook(
287+
&self.workbook,
288+
self.workbook.current_sheet(),
289+
&names,
290+
);
291+
let same = evaluator.would_create_circular_reference(
292+
value,
293+
(target_row, target_col),
294+
);
295+
let precedents = evaluator.extract_qualified_refs(value);
296+
let sheet_name = self
297+
.workbook
298+
.sheet_names[self.workbook.active_sheet]
299+
.clone();
300+
let cross = self.workbook.would_create_cross_sheet_cycle(
301+
&sheet_name,
302+
target_row,
303+
target_col,
304+
&precedents,
305+
);
306+
if same || cross {
307+
// Skip this one cell; keep going. Surfacing via
308+
// status_message at end of paste.
309+
continue;
310+
}
311+
}
282312
let evaluator = FormulaEvaluator::new(self.workbook.current_sheet());
283313
let evaluated = evaluator.evaluate_formula(value);
284314
CellData {

src/application/state/command.rs

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,13 @@ impl App {
6868
if let Some(rest) = trimmed.strip_prefix("w ").or_else(|| trimmed.strip_prefix("W ")) {
6969
let name = rest.trim().to_string();
7070
if !name.is_empty() {
71-
self.snapshot_view_state_to_active_sheet();
72-
let result = if name.to_lowercase().ends_with(".xlsx") {
71+
let is_xlsx = name.to_lowercase().ends_with(".xlsx");
72+
// See save_in_place_or_prompt: xlsx doesn't round-trip
73+
// App-only view state, so skip the snapshot for that target.
74+
if !is_xlsx {
75+
self.snapshot_view_state_to_active_sheet();
76+
}
77+
let result = if is_xlsx {
7378
crate::infrastructure::xlsx::save_xlsx(&self.workbook, &name)
7479
.map(|_| name.clone())
7580
} else {
@@ -183,11 +188,15 @@ impl App {
183188
let max_c = sheet.cols.saturating_sub(1);
184189
self.frozen_rows = self.selected_row.min(max_r);
185190
self.frozen_cols = self.selected_col.min(max_c);
191+
// Freeze is persistent view state — mark dirty so it
192+
// survives :q after a freeze with no other edits.
193+
self.dirty = true;
186194
self.status_message = Some(format!("Frozen {} rows, {} cols", self.frozen_rows, self.frozen_cols));
187195
}
188196
["unfreeze"] => {
189197
self.frozen_rows = 0;
190198
self.frozen_cols = 0;
199+
self.dirty = true;
191200
self.status_message = Some("Unfrozen all panes".to_string());
192201
}
193202
["format", "general"] => self.set_selection_format(NumberFormat::General),
@@ -231,33 +240,33 @@ impl App {
231240
}
232241
["sheet", "new"] | ["new", "sheet"] | ["addsheet"] => {
233242
let name = format!("Sheet{}", self.workbook.sheets.len() + 1);
234-
self.workbook.add_sheet(name.clone());
235-
self.workbook.active_sheet = self.workbook.sheets.len() - 1;
236-
self.selected_row = 0;
237-
self.selected_col = 0;
238-
self.scroll_row = 0;
239-
self.scroll_col = 0;
240-
self.dirty = true;
241-
crate::infrastructure::autosave::mark_dirty();
242-
self.status_message = Some(format!("Added sheet '{}'", name));
243+
let label = format!("Add sheet '{}'", name);
244+
self.with_snapshot_undo(&label, |app| {
245+
app.workbook.add_sheet(name.clone());
246+
app.workbook.active_sheet = app.workbook.sheets.len() - 1;
247+
app.selected_row = 0;
248+
app.selected_col = 0;
249+
app.scroll_row = 0;
250+
app.scroll_col = 0;
251+
});
252+
self.status_message = Some(format!("Added sheet (undo with u)"));
243253
}
244254
["sheet", "delete"] | ["delsheet"] => {
245255
let name = self.workbook.sheet_names[self.workbook.active_sheet].clone();
246-
if self.workbook.remove_sheet(self.workbook.active_sheet) {
247-
self.selected_row = 0;
248-
self.selected_col = 0;
249-
self.scroll_row = 0;
250-
self.scroll_col = 0;
251-
self.dirty = true;
252-
crate::infrastructure::autosave::mark_dirty();
253-
// remove_sheet may have shifted the active sheet (the
254-
// workbook re-anchors it). Drop search/find-replace state
255-
// since their (row, col) results belonged to the old
256-
// active sheet.
257-
self.invalidate_cross_sheet_state();
258-
self.status_message = Some(format!("Deleted sheet '{}'", name));
259-
} else {
256+
if self.workbook.sheets.len() <= 1 {
260257
self.status_message = Some("Cannot delete the last sheet".to_string());
258+
} else {
259+
let label = format!("Delete sheet '{}'", name);
260+
let active = self.workbook.active_sheet;
261+
self.with_snapshot_undo(&label, |app| {
262+
app.workbook.remove_sheet(active);
263+
app.selected_row = 0;
264+
app.selected_col = 0;
265+
app.scroll_row = 0;
266+
app.scroll_col = 0;
267+
app.invalidate_cross_sheet_state();
268+
});
269+
self.status_message = Some(format!("Deleted sheet '{}' (undo with u)", name));
261270
}
262271
}
263272
["sheet", "next"] | ["sn"] => {
@@ -539,6 +548,11 @@ impl App {
539548
for s in &mut self.workbook.sheets {
540549
s.iterative_calc = true;
541550
}
551+
// Existing circular formulas need to resolve under the new
552+
// mode; without this they keep showing the previous #CYCLE!
553+
// value until the next user edit.
554+
self.recalc_all();
555+
self.dirty = true;
542556
self.status_message = Some(format!(
543557
"Iterative calc: on (max {} iters, eps {})",
544558
self.workbook.current_sheet().iter_max,
@@ -550,13 +564,19 @@ impl App {
550564
for s in &mut self.workbook.sheets {
551565
s.iterative_calc = false;
552566
}
567+
self.recalc_all();
568+
self.dirty = true;
553569
self.status_message = Some("Iterative calc: off".to_string());
554570
}
555571
["iterative", "max", n] => {
556572
if let Ok(v) = n.parse::<usize>() {
557573
for s in &mut self.workbook.sheets {
558574
s.iter_max = v;
559575
}
576+
if self.iterative_calc {
577+
self.recalc_all();
578+
}
579+
self.dirty = true;
560580
self.status_message = Some(format!("Iterative max = {}", v));
561581
} else {
562582
self.status_message = Some("iterative max: bad number".to_string());
@@ -567,6 +587,10 @@ impl App {
567587
for s in &mut self.workbook.sheets {
568588
s.iter_epsilon = v;
569589
}
590+
if self.iterative_calc {
591+
self.recalc_all();
592+
}
593+
self.dirty = true;
570594
self.status_message = Some(format!("Iterative epsilon = {}", v));
571595
} else {
572596
self.status_message = Some("iterative epsilon: bad number".to_string());
@@ -948,16 +972,26 @@ impl App {
948972
// Join the remaining tokens so values with spaces (e.g.
949973
// `LAMBDA(x, x*2)`) survive intact.
950974
let value = rest.join(" ");
951-
self.workbook.set_name(name, &value);
952-
self.dirty = true;
975+
let label = format!("Name '{}' = {}", name, value);
976+
let name_owned = (*name).to_string();
977+
let value_owned = value.clone();
978+
self.with_snapshot_undo(&label, |app| {
979+
app.workbook.set_name(&name_owned, &value_owned);
980+
});
953981
self.status_message = Some(format!("Named '{}' = {}", name, value));
954982
}
955983
["unname", name] => {
956-
if self.workbook.remove_name(name) {
957-
self.dirty = true;
958-
self.status_message = Some(format!("Removed name '{}'", name));
959-
} else {
984+
if !self.workbook.named_ranges.contains_key(*name)
985+
&& !self.workbook.named_ranges.keys().any(|k| k.eq_ignore_ascii_case(name))
986+
{
960987
self.status_message = Some(format!("No such name: {}", name));
988+
} else {
989+
let label = format!("Remove name '{}'", name);
990+
let name_owned = (*name).to_string();
991+
self.with_snapshot_undo(&label, |app| {
992+
app.workbook.remove_name(&name_owned);
993+
});
994+
self.status_message = Some(format!("Removed name '{}'", name));
961995
}
962996
}
963997
["names"] => {

src/application/state/formatting.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,19 @@ impl App {
153153
.into_iter()
154154
.map(|(r, c)| {
155155
let mut cell = self.workbook.current_sheet().get_cell(r, c);
156-
cell.format = match &number_format {
157-
NumberFormat::General => None,
158-
_ => {
159-
let existing_style = cell.format.as_ref().map(|f| f.style.clone()).unwrap_or_default();
160-
Some(CellFormat { number_format: number_format.clone(), style: existing_style })
161-
}
156+
// Preserve the existing style (bold, colors, etc.) when
157+
// changing only the number_format. Switching to General
158+
// previously dropped the entire format including style.
159+
let existing_style = cell.format.as_ref().map(|f| f.style.clone()).unwrap_or_default();
160+
cell.format = if matches!(&number_format, NumberFormat::General)
161+
&& existing_style == Default::default()
162+
{
163+
None
164+
} else {
165+
Some(CellFormat {
166+
number_format: number_format.clone(),
167+
style: existing_style,
168+
})
162169
};
163170
(r, c, cell)
164171
})

src/application/state/io.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ use super::*;
55
impl App {
66
pub fn save_in_place_or_prompt(&mut self) {
77
if let Some(filename) = self.filename.clone() {
8-
// Capture App-only view state (freezes, hidden ranges, filter,
9-
// validations) into the active sheet so they round-trip.
10-
self.snapshot_view_state_to_active_sheet();
11-
let result = if filename.to_lowercase().ends_with(".xlsx") {
8+
let is_xlsx = filename.to_lowercase().ends_with(".xlsx");
9+
// Only snapshot view state (freezes, hidden ranges, filter,
10+
// validations) when the file format will actually round-trip
11+
// it. Doing it unconditionally before an .xlsx save mutates
12+
// the in-memory workbook with state that xlsx then drops on
13+
// load — a silently lossy save.
14+
if !is_xlsx {
15+
self.snapshot_view_state_to_active_sheet();
16+
}
17+
let result = if is_xlsx {
1218
crate::infrastructure::xlsx::save_xlsx(&self.workbook, &filename)
1319
.map(|_| filename.clone())
1420
} else {

src/application/state/mod.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,18 @@ pub enum UndoAction {
182182
at: usize,
183183
pre: Box<Workbook>,
184184
},
185+
/// Coarse workbook-level snapshot. Used as an escape hatch for
186+
/// structural operations (sheet add/delete/rename, freeze, filter,
187+
/// table create, iterative-calc toggles, named-range edits) where
188+
/// fine-grained reversal would require its own variant and the
189+
/// command is rare enough that round-tripping a whole workbook is
190+
/// acceptable. `pre`/`post` are the workbook state before/after the
191+
/// command; revert and apply just swap them in.
192+
WorkbookSnapshot {
193+
description: String,
194+
pre: Box<Workbook>,
195+
post: Box<Workbook>,
196+
},
185197
}
186198

187199
impl UndoAction {
@@ -216,6 +228,9 @@ impl UndoAction {
216228
UndoAction::ColDeleted { pre, .. } => {
217229
restore_workbook(workbook, pre);
218230
}
231+
UndoAction::WorkbookSnapshot { pre, .. } => {
232+
restore_workbook(workbook, pre);
233+
}
219234
}
220235
}
221236

@@ -253,6 +268,9 @@ impl UndoAction {
253268
with_active_sheet(workbook, *sheet_idx, |wb| wb.delete_col_on_active(*at));
254269
}
255270
}
271+
UndoAction::WorkbookSnapshot { post, .. } => {
272+
restore_workbook(workbook, post);
273+
}
256274
}
257275
}
258276
}
@@ -348,8 +366,15 @@ pub struct App {
348366
pub redo_stack: VecDeque<UndoAction>,
349367
/// Search query input buffer
350368
pub search_query: String,
351-
/// Search results as (row, col) coordinates
369+
/// Search results as (row, col) coordinates, in row-major order so
370+
/// next/prev navigation is deterministic.
352371
pub search_results: Vec<(usize, usize)>,
372+
/// HashSet mirror of `search_results` for O(1) lookup during render.
373+
/// A 1k-hit search rendered to a 900-cell viewport at 10fps used to
374+
/// burn ~9M Vec::contains comparisons per second; this drops it to
375+
/// hash-table lookups. Kept in sync wherever `search_results` is
376+
/// mutated.
377+
pub search_results_set: std::collections::HashSet<(usize, usize)>,
353378
/// Current search result index
354379
pub search_result_index: usize,
355380
/// Selection start position (row, col)
@@ -497,6 +522,7 @@ impl Default for App {
497522
redo_stack: VecDeque::new(),
498523
search_query: String::new(),
499524
search_results: Vec::new(),
525+
search_results_set: std::collections::HashSet::new(),
500526
search_result_index: 0,
501527
selection_start: None,
502528
selection_end: None,
@@ -552,6 +578,7 @@ impl App {
552578
pub fn dismiss_transients(&mut self) {
553579
self.clear_selection();
554580
self.search_results.clear();
581+
self.search_results_set.clear();
555582
self.search_result_index = 0;
556583
self.status_message = None;
557584
self.chart_popup = None;
@@ -605,6 +632,27 @@ impl App {
605632
crate::infrastructure::autosave::mark_dirty();
606633
}
607634

635+
/// Run a structural mutation with a coarse snapshot-based undo entry.
636+
/// `description` shows in :history-style listings (currently only used
637+
/// for debugging). The snapshot pair is captured around the closure;
638+
/// no-op mutations don't push an undo entry.
639+
///
640+
/// Use for ops that have no cheap fine-grained inverse: :sheet add/
641+
/// delete, :name/:unname, :freeze, :filter, :table create, :iterative
642+
/// toggles. The clone is bounded by the workbook size (~10 MB for
643+
/// 100k-cell workbooks); these commands run at most a few times per
644+
/// session so the cost is acceptable.
645+
pub(crate) fn with_snapshot_undo<F: FnOnce(&mut App)>(&mut self, description: &str, f: F) {
646+
let pre = Box::new(self.workbook.clone());
647+
f(self);
648+
let post = Box::new(self.workbook.clone());
649+
self.record_action(UndoAction::WorkbookSnapshot {
650+
description: description.to_string(),
651+
pre,
652+
post,
653+
});
654+
}
655+
608656
pub fn undo(&mut self) {
609657
if let Some(action) = self.undo_stack.pop_back() {
610658
action.revert(&mut self.workbook);

src/application/state/navigation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ impl App {
3434
/// n/N after a sheet switch would jump to phantom cells.
3535
pub(crate) fn invalidate_cross_sheet_state(&mut self) {
3636
self.search_results.clear();
37+
self.search_results_set.clear();
3738
self.search_result_index = 0;
3839
self.find_replace_results.clear();
3940
self.find_replace_index = 0;

0 commit comments

Comments
 (0)