feat: add records-mode drill-down with staged edits

Introduce records-mode drill-down functionality that allows users to
edit individual records without immediately modifying the underlying model.

Key changes:
- Added DrillState struct to hold frozen records snapshot and pending edits
- New effects: StartDrill, ApplyAndClearDrill, SetDrillPendingEdit
- Extended CmdContext with records_col and records_value for records mode
- CommitCellEdit now stages edits in pending_edits when in records mode
- DrillIntoCell captures a snapshot before switching to drill view
- GridLayout supports frozen records for stable view during edits
- GridWidget renders with drill_state for pending edit display

In records mode, edits are staged and only applied to the model when
the user navigates away or commits. This prevents data loss and allows
batch editing of records.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-05 11:45:36 -07:00
parent 19645a34cf
commit 78df3a4949
6 changed files with 311 additions and 12 deletions

View File

@ -41,6 +41,12 @@ pub struct CmdContext<'a> {
/// View navigation stacks (for drill back/forward)
pub view_back_stack: Vec<String>,
pub view_forward_stack: Vec<String>,
/// Records-mode info (drill view). None for normal pivot views.
/// When Some, edits stage to drill_state.pending_edits.
pub records_col: Option<String>,
/// The display value at the cursor in records mode (including any
/// pending edit override). None for normal pivot views.
pub records_value: Option<String>,
/// The key that triggered this command
pub key_code: KeyCode,
}
@ -984,7 +990,11 @@ impl Cmd for ViewBackCmd {
if ctx.view_back_stack.is_empty() {
vec![effect::set_status("No previous view")]
} else {
vec![Box::new(effect::ViewBack)]
// Apply any pending drill edits first, then navigate back.
vec![
Box::new(effect::ApplyAndClearDrill),
Box::new(effect::ViewBack),
]
}
}
}
@ -1020,6 +1030,27 @@ impl Cmd for DrillIntoCell {
let drill_name = "_Drill".to_string();
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
// Capture the records snapshot NOW (before we switch views).
let records: Vec<(crate::model::cell::CellKey, crate::model::cell::CellValue)> =
if self.key.0.is_empty() {
ctx.model
.data
.iter_cells()
.map(|(k, v)| (k, v.clone()))
.collect()
} else {
ctx.model
.data
.matching_cells(&self.key.0)
.into_iter()
.map(|(k, v)| (k, v.clone()))
.collect()
};
let n = records.len();
// Freeze the snapshot in the drill state
effects.push(Box::new(effect::StartDrill(records)));
// Create (or replace) the drill view
effects.push(Box::new(effect::CreateView(drill_name.clone())));
effects.push(Box::new(effect::SwitchView(drill_name)));
@ -1060,7 +1091,7 @@ impl Cmd for DrillIntoCell {
}));
}
effects.push(effect::set_status("Drilled into cell"));
effects.push(effect::set_status(format!("Drilled into cell: {n} rows")));
effects
}
}
@ -1599,10 +1630,15 @@ impl Cmd for PopChar {
// ── Commit commands (mode-specific buffer consumers) ────────────────────────
/// Commit a cell edit: set cell value, advance cursor, return to Normal.
/// In records mode, stages the edit in drill_state.pending_edits instead of
/// writing directly to the model.
#[derive(Debug)]
pub struct CommitCellEdit {
pub key: crate::model::cell::CellKey,
pub value: String,
/// Records-mode edit: (record_idx, column_name). When Some, stage in
/// pending_edits; otherwise write to the model directly.
pub records_edit: Option<(usize, String)>,
}
impl Cmd for CommitCellEdit {
fn name(&self) -> &'static str {
@ -1611,20 +1647,29 @@ impl Cmd for CommitCellEdit {
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
if self.value.is_empty() {
if let Some((record_idx, col_name)) = &self.records_edit {
// Stage the edit in drill_state.pending_edits
effects.push(Box::new(effect::SetDrillPendingEdit {
record_idx: *record_idx,
col_name: col_name.clone(),
new_value: self.value.clone(),
}));
} else if self.value.is_empty() {
effects.push(Box::new(effect::ClearCell(self.key.clone())));
effects.push(effect::mark_dirty());
} else if let Ok(n) = self.value.parse::<f64>() {
effects.push(Box::new(effect::SetCell(
self.key.clone(),
CellValue::Number(n),
)));
effects.push(effect::mark_dirty());
} else {
effects.push(Box::new(effect::SetCell(
self.key.clone(),
CellValue::Text(self.value.clone()),
)));
effects.push(effect::mark_dirty());
}
effects.push(effect::mark_dirty());
effects.push(effect::change_mode(AppMode::Normal));
// Advance cursor down (typewriter-style)
let adv = EnterAdvance {
@ -2501,7 +2546,11 @@ pub fn default_registry() -> CmdRegistry {
// ── Commit ───────────────────────────────────────────────────────────
r.register(
&CommitCellEdit { key: CellKey::new(vec![]), value: String::new() },
&CommitCellEdit {
key: CellKey::new(vec![]),
value: String::new(),
records_edit: None,
},
|args| {
// parse: commit-cell-edit <value> <Cat/Item>...
if args.len() < 2 {
@ -2510,12 +2559,26 @@ pub fn default_registry() -> CmdRegistry {
Ok(Box::new(CommitCellEdit {
key: parse_cell_key_from_args(&args[1..]),
value: args[0].clone(),
records_edit: None,
}))
},
|_args, ctx| {
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
let value = read_buffer(ctx, "edit");
Ok(Box::new(CommitCellEdit { key, value }))
// In records mode, stage the edit instead of writing to the model
if let Some(col_name) = &ctx.records_col {
let record_idx = ctx.selected.0;
return Ok(Box::new(CommitCellEdit {
key: CellKey::new(vec![]), // ignored in records mode
value,
records_edit: Some((record_idx, col_name.clone())),
}));
}
let key = ctx.cell_key.clone().ok_or("no cell at cursor")?;
Ok(Box::new(CommitCellEdit {
key,
value,
records_edit: None,
}))
},
);
r.register_nullary(|| Box::new(CommitFormula));
@ -2583,6 +2646,7 @@ mod tests {
none_cats: layout.none_cats.clone(),
view_back_stack: Vec::new(),
view_forward_stack: Vec::new(),
records_col: None,
cell_key: layout.cell_key(sr, sc),
row_count: layout.row_count(),
col_count: layout.col_count(),