Files
improvise/src/ui/effect.rs
Edward Langley 78df3a4949 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)
2026-04-05 11:45:36 -07:00

818 lines
25 KiB
Rust

use std::fmt::Debug;
use std::path::PathBuf;
use crate::model::cell::{CellKey, CellValue};
use crate::view::Axis;
use super::app::{App, AppMode};
/// A discrete state change produced by a command.
/// Effects know how to apply themselves to the App.
pub trait Effect: Debug {
fn apply(&self, app: &mut App);
}
// ── Model mutations ──────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct AddCategory(pub String);
impl Effect for AddCategory {
fn apply(&self, app: &mut App) {
let _ = app.model.add_category(&self.0);
}
}
#[derive(Debug)]
pub struct AddItem {
pub category: String,
pub item: String,
}
impl Effect for AddItem {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item(&self.item);
}
}
}
#[derive(Debug)]
pub struct AddItemInGroup {
pub category: String,
pub item: String,
pub group: String,
}
impl Effect for AddItemInGroup {
fn apply(&self, app: &mut App) {
if let Some(cat) = app.model.category_mut(&self.category) {
cat.add_item_in_group(&self.item, &self.group);
}
}
}
#[derive(Debug)]
pub struct SetCell(pub CellKey, pub CellValue);
impl Effect for SetCell {
fn apply(&self, app: &mut App) {
app.model.set_cell(self.0.clone(), self.1.clone());
}
}
#[derive(Debug)]
pub struct ClearCell(pub CellKey);
impl Effect for ClearCell {
fn apply(&self, app: &mut App) {
app.model.clear_cell(&self.0);
}
}
#[derive(Debug)]
pub struct AddFormula {
pub raw: String,
pub target_category: String,
}
impl Effect for AddFormula {
fn apply(&self, app: &mut App) {
if let Ok(formula) = crate::formula::parse_formula(&self.raw, &self.target_category) {
app.model.add_formula(formula);
}
}
}
#[derive(Debug)]
pub struct RemoveFormula {
pub target: String,
pub target_category: String,
}
impl Effect for RemoveFormula {
fn apply(&self, app: &mut App) {
app.model
.remove_formula(&self.target, &self.target_category);
}
}
// ── View mutations ───────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct CreateView(pub String);
impl Effect for CreateView {
fn apply(&self, app: &mut App) {
app.model.create_view(&self.0);
}
}
#[derive(Debug)]
pub struct DeleteView(pub String);
impl Effect for DeleteView {
fn apply(&self, app: &mut App) {
let _ = app.model.delete_view(&self.0);
}
}
#[derive(Debug)]
pub struct SwitchView(pub String);
impl Effect for SwitchView {
fn apply(&self, app: &mut App) {
let current = app.model.active_view.clone();
if current != self.0 {
app.view_back_stack.push(current);
app.view_forward_stack.clear();
}
let _ = app.model.switch_view(&self.0);
}
}
/// Go back in view history (pop back stack, push current to forward stack).
#[derive(Debug)]
pub struct ViewBack;
impl Effect for ViewBack {
fn apply(&self, app: &mut App) {
if let Some(prev) = app.view_back_stack.pop() {
let current = app.model.active_view.clone();
app.view_forward_stack.push(current);
let _ = app.model.switch_view(&prev);
}
}
}
/// Go forward in view history (pop forward stack, push current to back stack).
#[derive(Debug)]
pub struct ViewForward;
impl Effect for ViewForward {
fn apply(&self, app: &mut App) {
if let Some(next) = app.view_forward_stack.pop() {
let current = app.model.active_view.clone();
app.view_back_stack.push(current);
let _ = app.model.switch_view(&next);
}
}
}
#[derive(Debug)]
pub struct SetAxis {
pub category: String,
pub axis: Axis,
}
impl Effect for SetAxis {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.set_axis(&self.category, self.axis);
}
}
#[derive(Debug)]
pub struct SetPageSelection {
pub category: String,
pub item: String,
}
impl Effect for SetPageSelection {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.set_page_selection(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct ToggleGroup {
pub category: String,
pub group: String,
}
impl Effect for ToggleGroup {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.toggle_group_collapse(&self.category, &self.group);
}
}
#[derive(Debug)]
pub struct HideItem {
pub category: String,
pub item: String,
}
impl Effect for HideItem {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.hide_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct ShowItem {
pub category: String,
pub item: String,
}
impl Effect for ShowItem {
fn apply(&self, app: &mut App) {
app.model
.active_view_mut()
.show_item(&self.category, &self.item);
}
}
#[derive(Debug)]
pub struct TransposeAxes;
impl Effect for TransposeAxes {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().transpose_axes();
}
}
#[derive(Debug)]
pub struct CycleAxis(pub String);
impl Effect for CycleAxis {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().cycle_axis(&self.0);
}
}
#[derive(Debug)]
pub struct SetNumberFormat(pub String);
impl Effect for SetNumberFormat {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().number_format = self.0.clone();
}
}
// ── Navigation ───────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct SetSelected(pub usize, pub usize);
impl Effect for SetSelected {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().selected = (self.0, self.1);
}
}
#[derive(Debug)]
pub struct SetRowOffset(pub usize);
impl Effect for SetRowOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().row_offset = self.0;
}
}
#[derive(Debug)]
pub struct SetColOffset(pub usize);
impl Effect for SetColOffset {
fn apply(&self, app: &mut App) {
app.model.active_view_mut().col_offset = self.0;
}
}
// ── App state ────────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct ChangeMode(pub AppMode);
impl Effect for ChangeMode {
fn apply(&self, app: &mut App) {
app.mode = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetStatus(pub String);
impl Effect for SetStatus {
fn apply(&self, app: &mut App) {
app.status_msg = self.0.clone();
}
}
#[derive(Debug)]
pub struct MarkDirty;
impl Effect for MarkDirty {
fn apply(&self, app: &mut App) {
app.dirty = true;
}
}
#[derive(Debug)]
pub struct SetYanked(pub Option<CellValue>);
impl Effect for SetYanked {
fn apply(&self, app: &mut App) {
app.yanked = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetSearchQuery(pub String);
impl Effect for SetSearchQuery {
fn apply(&self, app: &mut App) {
app.search_query = self.0.clone();
}
}
#[derive(Debug)]
pub struct SetSearchMode(pub bool);
impl Effect for SetSearchMode {
fn apply(&self, app: &mut App) {
app.search_mode = self.0;
}
}
/// Set a named buffer's contents.
#[derive(Debug)]
pub struct SetBuffer {
pub name: String,
pub value: String,
}
impl Effect for SetBuffer {
fn apply(&self, app: &mut App) {
// "search" is special — it writes to search_query for backward compat
if self.name == "search" {
app.search_query = self.value.clone();
} else {
app.buffers.insert(self.name.clone(), self.value.clone());
}
}
}
#[derive(Debug)]
pub struct SetTileCatIdx(pub usize);
impl Effect for SetTileCatIdx {
fn apply(&self, app: &mut App) {
app.tile_cat_idx = self.0;
}
}
/// 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)]
pub struct Save;
impl Effect for Save {
fn apply(&self, app: &mut App) {
if let Some(ref path) = app.file_path {
match crate::persistence::save(&app.model, path) {
Ok(()) => {
app.dirty = false;
app.status_msg = format!("Saved to {}", path.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
}
}
} else {
app.status_msg = "No file path — use :w <path>".to_string();
}
}
}
#[derive(Debug)]
pub struct SaveAs(pub PathBuf);
impl Effect for SaveAs {
fn apply(&self, app: &mut App) {
match crate::persistence::save(&app.model, &self.0) {
Ok(()) => {
app.file_path = Some(self.0.clone());
app.dirty = false;
app.status_msg = format!("Saved to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Save error: {e}");
}
}
}
}
/// Dispatch a key event to the import wizard.
/// The wizard has its own internal state machine; this effect handles
/// all wizard key interactions and App-level side effects.
#[derive(Debug)]
pub struct WizardKey {
pub key_code: crossterm::event::KeyCode,
}
impl Effect for WizardKey {
fn apply(&self, app: &mut App) {
use crate::import::wizard::WizardStep;
let Some(wizard) = &mut app.wizard else {
return;
};
match &wizard.step.clone() {
WizardStep::Preview => match self.key_code {
crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Char(' ') => {
wizard.advance()
}
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::SelectArrayPath => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.confirm_path(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ReviewProposals => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_proposal(),
crossterm::event::KeyCode::Char('c') => wizard.cycle_proposal_kind(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::ConfigureDates => match self.key_code {
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Char(' ') => wizard.toggle_date_component(),
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::DefineFormulas => {
if wizard.formula_editing {
match self.key_code {
crossterm::event::KeyCode::Enter => wizard.confirm_formula(),
crossterm::event::KeyCode::Esc => wizard.cancel_formula_edit(),
crossterm::event::KeyCode::Backspace => wizard.pop_formula_char(),
crossterm::event::KeyCode::Char(c) => wizard.push_formula_char(c),
_ => {}
}
} else {
match self.key_code {
crossterm::event::KeyCode::Char('n') => wizard.start_formula_edit(),
crossterm::event::KeyCode::Char('d') => wizard.delete_formula(),
crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
wizard.move_cursor(-1)
}
crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
wizard.move_cursor(1)
}
crossterm::event::KeyCode::Enter => wizard.advance(),
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
}
}
}
WizardStep::NameModel => match self.key_code {
crossterm::event::KeyCode::Char(c) => wizard.push_name_char(c),
crossterm::event::KeyCode::Backspace => wizard.pop_name_char(),
crossterm::event::KeyCode::Enter => match wizard.build_model() {
Ok(mut model) => {
model.normalize_view_state();
app.model = model;
app.formula_cursor = 0;
app.dirty = true;
app.status_msg = "Import successful! Press :w <path> to save.".to_string();
app.mode = AppMode::Normal;
app.wizard = None;
}
Err(e) => {
if let Some(w) = &mut app.wizard {
w.message = Some(format!("Error: {e}"));
}
}
},
crossterm::event::KeyCode::Esc => {
app.mode = AppMode::Normal;
app.wizard = None;
}
_ => {}
},
WizardStep::Done => {
app.mode = AppMode::Normal;
app.wizard = None;
}
}
}
}
/// Start the import wizard from a JSON file path.
#[derive(Debug)]
pub struct StartImportWizard(pub String);
impl Effect for StartImportWizard {
fn apply(&self, app: &mut App) {
match std::fs::read_to_string(&self.0) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
app.wizard = Some(crate::import::wizard::ImportWizard::new(json));
app.mode = AppMode::ImportWizard;
}
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
}
},
Err(e) => {
app.status_msg = format!("Cannot read file: {e}");
}
}
}
}
#[derive(Debug)]
pub struct ExportCsv(pub PathBuf);
impl Effect for ExportCsv {
fn apply(&self, app: &mut App) {
let view_name = app.model.active_view.clone();
match crate::persistence::export_csv(&app.model, &view_name, &self.0) {
Ok(()) => {
app.status_msg = format!("Exported to {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Export error: {e}");
}
}
}
}
/// Load a model from a file, replacing the current one.
#[derive(Debug)]
pub struct LoadModel(pub PathBuf);
impl Effect for LoadModel {
fn apply(&self, app: &mut App) {
match crate::persistence::load(&self.0) {
Ok(mut loaded) => {
loaded.normalize_view_state();
app.model = loaded;
app.status_msg = format!("Loaded from {}", self.0.display());
}
Err(e) => {
app.status_msg = format!("Load error: {e}");
}
}
}
}
/// Headless JSON/CSV import: read file, analyze, build model, replace current.
#[derive(Debug)]
pub struct ImportJsonHeadless {
pub path: PathBuf,
pub model_name: Option<String>,
pub array_path: Option<String>,
}
impl Effect for ImportJsonHeadless {
fn apply(&self, app: &mut App) {
use crate::import::analyzer::{
analyze_records, extract_array_at_path, find_array_paths, FieldKind,
};
use crate::import::wizard::ImportPipeline;
let is_csv = self
.path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"));
let records = if is_csv {
match crate::import::csv_parser::parse_csv(&self.path) {
Ok(recs) => recs,
Err(e) => {
app.status_msg = format!("CSV error: {e}");
return;
}
}
} else {
let content = match std::fs::read_to_string(&self.path) {
Ok(c) => c,
Err(e) => {
app.status_msg = format!("Cannot read '{}': {e}", self.path.display());
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
app.status_msg = format!("JSON parse error: {e}");
return;
}
};
if let Some(ap) = self.array_path.as_deref().filter(|s| !s.is_empty()) {
match extract_array_at_path(&value, ap) {
Some(arr) => arr.clone(),
None => {
app.status_msg = format!("No array at path '{ap}'");
return;
}
}
} else if let Some(arr) = value.as_array() {
arr.clone()
} else {
let paths = find_array_paths(&value);
if let Some(first) = paths.first() {
match extract_array_at_path(&value, first) {
Some(arr) => arr.clone(),
None => {
app.status_msg = "Could not extract records array".to_string();
return;
}
}
} else {
app.status_msg = "No array found in JSON".to_string();
return;
}
}
};
let proposals = analyze_records(&records);
let raw = if is_csv {
serde_json::Value::Array(records.clone())
} else {
serde_json::from_str(&std::fs::read_to_string(&self.path).unwrap_or_default())
.unwrap_or(serde_json::Value::Array(records.clone()))
};
let pipeline = ImportPipeline {
raw,
array_paths: vec![],
selected_path: self.array_path.as_deref().unwrap_or("").to_string(),
records,
proposals: proposals
.into_iter()
.map(|mut p| {
p.accepted = p.kind != FieldKind::Label;
p
})
.collect(),
model_name: self
.model_name
.as_deref()
.unwrap_or("Imported Model")
.to_string(),
formulas: vec![],
};
match pipeline.build_model() {
Ok(new_model) => {
app.model = new_model;
app.status_msg = "Imported successfully".to_string();
}
Err(e) => {
app.status_msg = format!("Import error: {e}");
}
}
}
}
#[derive(Debug)]
pub struct SetPanelOpen {
pub panel: Panel,
pub open: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum Panel {
Formula,
Category,
View,
}
impl Effect for SetPanelOpen {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_panel_open = self.open,
Panel::Category => app.category_panel_open = self.open,
Panel::View => app.view_panel_open = self.open,
}
}
}
#[derive(Debug)]
pub struct SetPanelCursor {
pub panel: Panel,
pub cursor: usize,
}
impl Effect for SetPanelCursor {
fn apply(&self, app: &mut App) {
match self.panel {
Panel::Formula => app.formula_cursor = self.cursor,
Panel::Category => app.cat_panel_cursor = self.cursor,
Panel::View => app.view_panel_cursor = self.cursor,
}
}
}
// ── Convenience constructors ─────────────────────────────────────────────────
pub fn mark_dirty() -> Box<dyn Effect> {
Box::new(MarkDirty)
}
pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {
Box::new(SetStatus(msg.into()))
}
pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {
Box::new(ChangeMode(mode))
}
pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {
Box::new(SetSelected(row, col))
}