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:
@ -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(),
|
||||
|
||||
@ -245,7 +245,13 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
||||
}
|
||||
|
||||
f.render_widget(
|
||||
GridWidget::new(&app.model, &app.mode, &app.search_query, &app.buffers),
|
||||
GridWidget::new(
|
||||
&app.model,
|
||||
&app.mode,
|
||||
&app.search_query,
|
||||
&app.buffers,
|
||||
app.drill_state.as_ref(),
|
||||
),
|
||||
grid_area,
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,6 +13,20 @@ use crate::model::Model;
|
||||
use crate::persistence;
|
||||
use crate::view::GridLayout;
|
||||
|
||||
/// Drill-down state: frozen record snapshot + pending edits that have not
|
||||
/// yet been applied to the model.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DrillState {
|
||||
/// Frozen snapshot of records shown in the drill view.
|
||||
pub records: Vec<(
|
||||
crate::model::cell::CellKey,
|
||||
crate::model::cell::CellValue,
|
||||
)>,
|
||||
/// Pending edits keyed by (record_idx, column_name) → new string value.
|
||||
/// column_name is either "Value" or a category name.
|
||||
pub pending_edits: std::collections::HashMap<(usize, String), String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
@ -72,6 +86,11 @@ pub struct App {
|
||||
pub view_back_stack: Vec<String>,
|
||||
/// Views that were "back-ed" from, available for forward navigation (`>`).
|
||||
pub view_forward_stack: Vec<String>,
|
||||
/// Frozen records list for the drill view. When present, this is the
|
||||
/// snapshot that records-mode layouts iterate — records don't disappear
|
||||
/// when filters would change. Pending edits are stored alongside and
|
||||
/// applied to the model on commit/navigate-away.
|
||||
pub drill_state: Option<DrillState>,
|
||||
/// Named text buffers for text-entry modes
|
||||
pub buffers: HashMap<String, String>,
|
||||
/// Transient keymap for Emacs-style prefix key sequences (g→gg, y→yy, etc.)
|
||||
@ -101,6 +120,7 @@ impl App {
|
||||
tile_cat_idx: 0,
|
||||
view_back_stack: Vec::new(),
|
||||
view_forward_stack: Vec::new(),
|
||||
drill_state: None,
|
||||
buffers: HashMap::new(),
|
||||
transient_keymap: None,
|
||||
keymap_set: KeymapSet::default_keymaps(),
|
||||
@ -109,7 +129,8 @@ impl App {
|
||||
|
||||
pub fn cmd_context(&self, key: KeyCode, _mods: KeyModifiers) -> CmdContext<'_> {
|
||||
let view = self.model.active_view();
|
||||
let layout = GridLayout::new(&self.model, view);
|
||||
let frozen_records = self.drill_state.as_ref().map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(&self.model, view, frozen_records);
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
CmdContext {
|
||||
model: &self.model,
|
||||
@ -135,6 +156,21 @@ impl App {
|
||||
none_cats: layout.none_cats.clone(),
|
||||
view_back_stack: self.view_back_stack.clone(),
|
||||
view_forward_stack: self.view_forward_stack.clone(),
|
||||
records_col: if layout.is_records_mode() {
|
||||
Some(layout.col_label(sel_col))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
records_value: if layout.is_records_mode() {
|
||||
// Check pending edits first, then fall back to original
|
||||
let col_name = layout.col_label(sel_col);
|
||||
let pending = self.drill_state.as_ref().and_then(|s| {
|
||||
s.pending_edits.get(&(sel_row, col_name.clone())).cloned()
|
||||
});
|
||||
pending.or_else(|| layout.records_display(sel_row, sel_col))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
key_code: key,
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,6 +337,91 @@ impl Effect for SetTileCatIdx {
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate the drill state with a frozen snapshot of records.
|
||||
/// Clears any previous drill state.
|
||||
#[derive(Debug)]
|
||||
pub struct StartDrill(pub Vec<(CellKey, CellValue)>);
|
||||
impl Effect for StartDrill {
|
||||
fn apply(&self, app: &mut App) {
|
||||
app.drill_state = Some(super::app::DrillState {
|
||||
records: self.0.clone(),
|
||||
pending_edits: std::collections::HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply any pending edits to the model and clear the drill state.
|
||||
#[derive(Debug)]
|
||||
pub struct ApplyAndClearDrill;
|
||||
impl Effect for ApplyAndClearDrill {
|
||||
fn apply(&self, app: &mut App) {
|
||||
let Some(drill) = app.drill_state.take() else {
|
||||
return;
|
||||
};
|
||||
// For each pending edit, update the cell
|
||||
for ((record_idx, col_name), new_value) in &drill.pending_edits {
|
||||
let Some((orig_key, _)) = drill.records.get(*record_idx) else {
|
||||
continue;
|
||||
};
|
||||
if col_name == "Value" {
|
||||
// Update the cell's value
|
||||
let value = if new_value.is_empty() {
|
||||
app.model.clear_cell(orig_key);
|
||||
continue;
|
||||
} else if let Ok(n) = new_value.parse::<f64>() {
|
||||
CellValue::Number(n)
|
||||
} else {
|
||||
CellValue::Text(new_value.clone())
|
||||
};
|
||||
app.model.set_cell(orig_key.clone(), value);
|
||||
} else {
|
||||
// Rename a coordinate: remove old cell, insert new with updated coord
|
||||
let value = match app.model.get_cell(orig_key) {
|
||||
Some(v) => v.clone(),
|
||||
None => continue,
|
||||
};
|
||||
app.model.clear_cell(orig_key);
|
||||
// Build new key by replacing the coord
|
||||
let new_coords: Vec<(String, String)> = orig_key
|
||||
.0
|
||||
.iter()
|
||||
.map(|(c, i)| {
|
||||
if c == col_name {
|
||||
(c.clone(), new_value.clone())
|
||||
} else {
|
||||
(c.clone(), i.clone())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let new_key = CellKey::new(new_coords);
|
||||
// Ensure the new item exists in that category
|
||||
if let Some(cat) = app.model.category_mut(col_name) {
|
||||
cat.add_item(new_value.clone());
|
||||
}
|
||||
app.model.set_cell(new_key, value);
|
||||
}
|
||||
}
|
||||
app.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a pending edit in the drill state.
|
||||
#[derive(Debug)]
|
||||
pub struct SetDrillPendingEdit {
|
||||
pub record_idx: usize,
|
||||
pub col_name: String,
|
||||
pub new_value: String,
|
||||
}
|
||||
impl Effect for SetDrillPendingEdit {
|
||||
fn apply(&self, app: &mut App) {
|
||||
if let Some(drill) = &mut app.drill_state {
|
||||
drill
|
||||
.pending_edits
|
||||
.insert((self.record_idx, self.col_name.clone()), self.new_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Side effects ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@ -21,6 +21,7 @@ pub struct GridWidget<'a> {
|
||||
pub mode: &'a AppMode,
|
||||
pub search_query: &'a str,
|
||||
pub buffers: &'a std::collections::HashMap<String, String>,
|
||||
pub drill_state: Option<&'a crate::ui::app::DrillState>,
|
||||
}
|
||||
|
||||
impl<'a> GridWidget<'a> {
|
||||
@ -29,19 +30,22 @@ impl<'a> GridWidget<'a> {
|
||||
mode: &'a AppMode,
|
||||
search_query: &'a str,
|
||||
buffers: &'a std::collections::HashMap<String, String>,
|
||||
drill_state: Option<&'a crate::ui::app::DrillState>,
|
||||
) -> Self {
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
search_query,
|
||||
buffers,
|
||||
drill_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_grid(&self, area: Rect, buf: &mut Buffer) {
|
||||
let view = self.model.active_view();
|
||||
|
||||
let layout = GridLayout::new(self.model, view);
|
||||
let frozen = self.drill_state.map(|s| s.records.clone());
|
||||
let layout = GridLayout::with_frozen_records(self.model, view, frozen);
|
||||
let (sel_row, sel_col) = view.selected;
|
||||
let row_offset = view.row_offset;
|
||||
let col_offset = view.col_offset;
|
||||
@ -542,7 +546,7 @@ mod tests {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let bufs = std::collections::HashMap::new();
|
||||
GridWidget::new(model, &AppMode::Normal, "", &bufs).render(area, &mut buf);
|
||||
GridWidget::new(model, &AppMode::Normal, "", &bufs, None).render(area, &mut buf);
|
||||
buf
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,27 @@ pub struct GridLayout {
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
/// Build a layout. When records-mode is active and `frozen_records`
|
||||
/// is provided, use that snapshot instead of re-querying the store.
|
||||
pub fn with_frozen_records(
|
||||
model: &Model,
|
||||
view: &View,
|
||||
frozen_records: Option<Vec<(CellKey, CellValue)>>,
|
||||
) -> Self {
|
||||
let mut layout = Self::new(model, view);
|
||||
if layout.is_records_mode() {
|
||||
if let Some(records) = frozen_records {
|
||||
// Re-build with the frozen records instead
|
||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
layout.row_items = row_items;
|
||||
layout.records = Some(records);
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
pub fn new(model: &Model, view: &View) -> Self {
|
||||
let row_cats: Vec<String> = view
|
||||
.categories_on(Axis::Row)
|
||||
@ -131,7 +152,7 @@ impl GridLayout {
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
|
||||
// Synthesize col items: one per regular category + "Value"
|
||||
// Synthesize col items: one per category + "Value"
|
||||
let cat_names: Vec<String> = model
|
||||
.category_names()
|
||||
.into_iter()
|
||||
@ -227,7 +248,17 @@ impl GridLayout {
|
||||
|
||||
/// Build the CellKey for the data cell at (row, col), including the active
|
||||
/// page-axis filter. Returns None if row or col is out of bounds.
|
||||
/// In records mode: returns the real underlying CellKey when the column
|
||||
/// is "Value" (editable); returns None for coord columns (read-only).
|
||||
pub fn cell_key(&self, row: usize, col: usize) -> Option<CellKey> {
|
||||
if let Some(records) = &self.records {
|
||||
// Records mode: only the Value column maps to a real, editable cell.
|
||||
if self.col_label(col) == "Value" {
|
||||
return records.get(row).map(|(k, _)| k.clone());
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let row_item = self
|
||||
.row_items
|
||||
.iter()
|
||||
@ -393,6 +424,79 @@ mod tests {
|
||||
use super::{AxisEntry, GridLayout};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::Axis;
|
||||
|
||||
fn records_model() -> Model {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Region").unwrap();
|
||||
m.add_category("Measure").unwrap();
|
||||
m.category_mut("Region").unwrap().add_item("North");
|
||||
m.category_mut("Measure").unwrap().add_item("Revenue");
|
||||
m.category_mut("Measure").unwrap().add_item("Cost");
|
||||
m.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("Measure".into(), "Revenue".into()),
|
||||
]),
|
||||
CellValue::Number(100.0),
|
||||
);
|
||||
m.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Region".into(), "North".into()),
|
||||
("Measure".into(), "Cost".into()),
|
||||
]),
|
||||
CellValue::Number(50.0),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_activated_when_index_and_dim_on_axes() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert!(layout.is_records_mode());
|
||||
assert_eq!(layout.row_count(), 2); // 2 cells
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_cell_key_editable_for_value_column() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
assert!(layout.is_records_mode());
|
||||
// Find the "Value" column index
|
||||
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||||
// cell_key should be Some for Value column
|
||||
let key = layout.cell_key(0, value_col);
|
||||
assert!(key.is_some(), "Value column should be editable");
|
||||
// cell_key should be None for coord columns
|
||||
let region_col = cols.iter().position(|c| c == "Region").unwrap();
|
||||
assert!(
|
||||
layout.cell_key(0, region_col).is_none(),
|
||||
"Region column should not be editable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_cell_key_maps_to_real_cell() {
|
||||
let mut m = records_model();
|
||||
let v = m.active_view_mut();
|
||||
v.set_axis("_Index", Axis::Row);
|
||||
v.set_axis("_Dim", Axis::Column);
|
||||
let layout = GridLayout::new(&m, m.active_view());
|
||||
let cols: Vec<String> = (0..layout.col_count()).map(|i| layout.col_label(i)).collect();
|
||||
let value_col = cols.iter().position(|c| c == "Value").unwrap();
|
||||
// The CellKey at (0, Value) should look up a real cell value
|
||||
let key = layout.cell_key(0, value_col).unwrap();
|
||||
let val = m.evaluate(&key);
|
||||
assert!(val.is_some(), "cell_key should resolve to a real cell");
|
||||
}
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
CellKey::new(
|
||||
|
||||
Reference in New Issue
Block a user