From cece34a1d456869198f8b488e0772fd97ebc6e2f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 22:44:13 -0700 Subject: [PATCH 1/5] refactor(command): parameterize mode-related commands and effects Make mode-related commands and effects mode-agnostic by passing the target mode as an argument instead of inspecting the current application mode. - `CommitAndAdvance` now accepts `edit_mode` . - `EditOrDrill` now accepts `edit_mode` . - `EnterEditAtCursorCmd` now accepts `target_mode` . - `EnterEditAtCursor` effect now accepts `target_mode` . Update the command registry to parse mode names from arguments and pass them to the corresponding commands. Add tests to verify the new mode-passing behavior. Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf) --- src/command/cmd/commit.rs | 39 +++++++++- src/command/cmd/mode.rs | 149 +++++++++++++++++++++++------------- src/command/cmd/registry.rs | 90 ++++++++++++---------- src/ui/effect.rs | 52 +++++++++++-- 4 files changed, 232 insertions(+), 98 deletions(-) diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs index e4c67f1..706c692 100644 --- a/src/command/cmd/commit.rs +++ b/src/command/cmd/commit.rs @@ -118,6 +118,35 @@ mod tests { assert!(effects.is_empty()); } + /// `CommitAndAdvance` must thread its `edit_mode` through to the + /// trailing `EnterEditAtCursor` effect so the post-commit re-edit lands + /// in the mode the keymap requested. The command never reads ctx.mode. + #[test] + fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut bufs = HashMap::new(); + bufs.insert("edit".to_string(), "42".to_string()); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.buffers = &bufs; + // ctx.mode stays Normal — the command must not look at it. + let key = ctx.cell_key().unwrap(); + let cmd = CommitAndAdvance { + key, + value: "42".to_string(), + advance: super::AdvanceDir::Down, + cursor: super::CursorState::from_ctx(&ctx), + edit_mode: AppMode::records_editing(), + }; + let effects = cmd.execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("EnterEditAtCursor") && dbg.contains("RecordsEditing"), + "Expected trailing EnterEditAtCursor with RecordsEditing target, got: {dbg}" + ); + } + #[test] fn commit_export_produces_export_and_normal_mode() { let m = two_cat_model(); @@ -234,12 +263,18 @@ pub enum AdvanceDir { /// Commit a cell edit, advance the cursor, and re-enter edit mode. /// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right). +/// +/// `edit_mode` is the editing mode to re-enter after advancing. The keymap +/// binding supplies this — the editing-mode keymap passes `editing` and the +/// records-editing keymap passes `records-editing`. The command itself +/// never inspects `ctx.mode`. #[derive(Debug)] pub struct CommitAndAdvance { pub key: CellKey, pub value: String, pub advance: AdvanceDir, pub cursor: CursorState, + pub edit_mode: AppMode, } impl Cmd for CommitAndAdvance { fn name(&self) -> &'static str { @@ -287,7 +322,9 @@ impl Cmd for CommitAndAdvance { } } } - effects.push(Box::new(effect::EnterEditAtCursor)); + effects.push(Box::new(effect::EnterEditAtCursor { + target_mode: self.edit_mode.clone(), + })); effects } } diff --git a/src/command/cmd/mode.rs b/src/command/cmd/mode.rs index 84ed870..1d735e3 100644 --- a/src/command/cmd/mode.rs +++ b/src/command/cmd/mode.rs @@ -10,21 +10,6 @@ mod tests { use crate::command::cmd::test_helpers::*; use crate::model::Model; - #[test] - fn enter_edit_mode_produces_editing_mode() { - let m = two_cat_model(); - let layout = make_layout(&m); - let reg = make_registry(); - let ctx = make_ctx(&m, &layout, ®); - let cmd = EnterEditMode { - initial_value: String::new(), - }; - let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 2); - let dbg = format!("{:?}", effects[1]); - assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); - } - #[test] fn enter_tile_select_with_categories() { let m = two_cat_model(); @@ -98,11 +83,80 @@ mod tests { let layout = make_layout(&m); let reg = make_registry(); let ctx = make_ctx(&m, &layout, ®); - let effects = EditOrDrill.execute(&ctx); + let effects = EditOrDrill { + edit_mode: AppMode::editing(), + } + .execute(&ctx); + assert_eq!(effects.len(), 2); let dbg = effects_debug(&effects); assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}"); } + /// EditOrDrill must trust its `edit_mode` parameter rather than checking + /// `ctx.mode` — the records-normal keymap supplies `records-editing`, + /// but the command itself never inspects the runtime mode. This is the + /// parallel of the (deleted) `enter_edit_mode_produces_editing_mode` + /// test for the records branch. + #[test] + fn edit_or_drill_passes_records_editing_mode_through() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + // Note: ctx.mode is still Normal here — the command must not look at it. + let ctx = make_ctx(&m, &layout, ®); + let effects = EditOrDrill { + edit_mode: AppMode::records_editing(), + } + .execute(&ctx); + assert_eq!(effects.len(), 2); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("RecordsEditing"), + "Expected RecordsEditing mode, got: {dbg}" + ); + } + + /// `EnterEditAtCursorCmd` must hand its `target_mode` straight through + /// to the `EnterEditAtCursor` effect — the keymap (records `o` sequence + /// or commit-and-advance) decides; the command never inspects ctx. + #[test] + fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let effects = EnterEditAtCursorCmd { + target_mode: AppMode::records_editing(), + } + .execute(&ctx); + assert_eq!(effects.len(), 1); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("RecordsEditing"), + "Expected RecordsEditing target_mode, got: {dbg}" + ); + } + + /// The edit branch pre-fills the `edit` buffer with the cell's current + /// display value so the user can modify rather than retype. + #[test] + fn edit_or_drill_pre_fills_edit_buffer_with_display_value() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let mut ctx = make_ctx(&m, &layout, ®); + ctx.display_value = "42".to_string(); + let effects = EditOrDrill { + edit_mode: AppMode::editing(), + } + .execute(&ctx); + let dbg = effects_debug(&effects); + assert!( + dbg.contains("SetBuffer") && dbg.contains("\"edit\"") && dbg.contains("\"42\""), + "Expected SetBuffer(\"edit\", \"42\"), got: {dbg}" + ); + } + #[test] fn enter_search_mode_sets_flag_and_clears_query() { let m = two_cat_model(); @@ -188,36 +242,18 @@ impl Cmd for SaveAndQuit { // ── Editing entry ─────────────────────────────────────────────────────── -/// Enter editing mode with an initial buffer value. -#[derive(Debug)] -pub struct EnterEditMode { - pub initial_value: String, -} -impl Cmd for EnterEditMode { - fn name(&self) -> &'static str { - "enter-edit-mode" - } - fn execute(&self, ctx: &CmdContext) -> Vec> { - let edit_mode = if ctx.mode.is_records() { - AppMode::records_editing() - } else { - AppMode::editing() - }; - vec![ - Box::new(effect::SetBuffer { - name: "edit".to_string(), - value: self.initial_value.clone(), - }), - effect::change_mode(edit_mode), - ] - } -} - /// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell -/// (categories on `Axis::None`, no records mode), drill into it instead of -/// editing. Otherwise enter edit mode with the current displayed value. +/// (categories on `Axis::None` and the cell is not a synthetic records-mode +/// row), drill into it instead of editing. Otherwise pre-fill the edit +/// buffer with the displayed cell value and enter `edit_mode`. +/// +/// `edit_mode` is supplied by the keymap binding — the command itself is +/// mode-agnostic, so the records-normal keymap passes `records-editing` +/// while the normal keymap passes `editing`. #[derive(Debug)] -pub struct EditOrDrill; +pub struct EditOrDrill { + pub edit_mode: AppMode, +} impl Cmd for EditOrDrill { fn name(&self) -> &'static str { "edit-or-drill" @@ -232,7 +268,8 @@ impl Cmd for EditOrDrill { .map(|cat| cat.kind.is_regular()) .unwrap_or(false) }); - // In records mode (synthetic key), always edit directly — no drilling. + // Synthetic records-mode cells are never aggregated — edit directly. + // (This is a layout property, not a mode flag.) let is_synthetic = ctx.synthetic_record_at_cursor().is_some(); let is_aggregated = !is_synthetic && regular_none; if is_aggregated { @@ -241,23 +278,31 @@ impl Cmd for EditOrDrill { }; return DrillIntoCell { key }.execute(ctx); } - EnterEditMode { - initial_value: ctx.display_value.clone(), - } - .execute(ctx) + vec![ + Box::new(effect::SetBuffer { + name: "edit".to_string(), + value: ctx.display_value.clone(), + }), + effect::change_mode(self.edit_mode.clone()), + ] } } /// Thin command wrapper around the `EnterEditAtCursor` effect so it can -/// participate in `Binding::Sequence`. +/// participate in `Binding::Sequence`. `target_mode` is supplied as the +/// command argument by the keymap binding. #[derive(Debug)] -pub struct EnterEditAtCursorCmd; +pub struct EnterEditAtCursorCmd { + pub target_mode: AppMode, +} impl Cmd for EnterEditAtCursorCmd { fn name(&self) -> &'static str { "enter-edit-at-cursor" } fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![Box::new(effect::EnterEditAtCursor)] + vec![Box::new(effect::EnterEditAtCursor { + target_mode: self.target_mode.clone(), + })] } } diff --git a/src/command/cmd/registry.rs b/src/command/cmd/registry.rs index 13a727f..8f92a10 100644 --- a/src/command/cmd/registry.rs +++ b/src/command/cmd/registry.rs @@ -2,6 +2,27 @@ use crate::model::cell::CellKey; use crate::ui::app::AppMode; use crate::ui::effect::Panel; +/// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill` +/// keymap bindings) into an `AppMode`. +fn parse_mode_name(s: &str) -> Result { + match s { + "normal" => Ok(AppMode::Normal), + "help" => Ok(AppMode::Help), + "formula-panel" => Ok(AppMode::FormulaPanel), + "category-panel" => Ok(AppMode::CategoryPanel), + "view-panel" => Ok(AppMode::ViewPanel), + "tile-select" => Ok(AppMode::TileSelect), + "command" => Ok(AppMode::command_mode()), + "category-add" => Ok(AppMode::category_add()), + "editing" => Ok(AppMode::editing()), + "records-normal" => Ok(AppMode::RecordsNormal), + "records-editing" => Ok(AppMode::records_editing()), + "formula-edit" => Ok(AppMode::formula_edit()), + "export-prompt" => Ok(AppMode::export_prompt()), + other => Err(format!("Unknown mode: {other}")), + } +} + use super::cell::*; use super::commit::*; use super::core::*; @@ -266,22 +287,16 @@ pub fn default_registry() -> CmdRegistry { r.register_nullary(|| Box::new(SaveAndQuit)); r.register_nullary(|| Box::new(SaveCmd)); r.register_nullary(|| Box::new(EnterSearchMode)); - r.register( - &EnterEditMode { - initial_value: String::new(), - }, - |args| { - let val = args.first().cloned().unwrap_or_default(); - Ok(Box::new(EnterEditMode { initial_value: val })) - }, - |_args, ctx| { - Ok(Box::new(EnterEditMode { - initial_value: ctx.display_value.clone(), - })) - }, - ); - r.register_nullary(|| Box::new(EditOrDrill)); - r.register_nullary(|| Box::new(EnterEditAtCursorCmd)); + r.register_pure(&NamedCmd("edit-or-drill"), |args| { + require_args("edit-or-drill", args, 1)?; + let edit_mode = parse_mode_name(&args[0])?; + Ok(Box::new(EditOrDrill { edit_mode })) + }); + r.register_pure(&NamedCmd("enter-edit-at-cursor"), |args| { + require_args("enter-edit-at-cursor", args, 1)?; + let target_mode = parse_mode_name(&args[0])?; + Ok(Box::new(EnterEditAtCursorCmd { target_mode })) + }); r.register_nullary(|| Box::new(EnterExportPrompt)); r.register_nullary(|| Box::new(EnterFormulaEdit)); r.register_nullary(|| Box::new(EnterTileSelect)); @@ -310,23 +325,7 @@ pub fn default_registry() -> CmdRegistry { ); r.register_pure(&NamedCmd("enter-mode"), |args| { require_args("enter-mode", args, 1)?; - let mode = match args[0].as_str() { - "normal" => AppMode::Normal, - "help" => AppMode::Help, - "formula-panel" => AppMode::FormulaPanel, - "category-panel" => AppMode::CategoryPanel, - "view-panel" => AppMode::ViewPanel, - "tile-select" => AppMode::TileSelect, - "command" => AppMode::command_mode(), - "category-add" => AppMode::category_add(), - "editing" => AppMode::editing(), - "records-normal" => AppMode::RecordsNormal, - "records-editing" => AppMode::records_editing(), - "formula-edit" => AppMode::formula_edit(), - "export-prompt" => AppMode::export_prompt(), - other => return Err(format!("Unknown mode: {other}")), - }; - Ok(Box::new(EnterMode(mode))) + Ok(Box::new(EnterMode(parse_mode_name(&args[0])?))) }); // ── Search ─────────────────────────────────────────────────────────── @@ -522,25 +521,33 @@ pub fn default_registry() -> CmdRegistry { r.register_nullary(|| Box::new(CommandModeBackspace)); // ── Commit ─────────────────────────────────────────────────────────── + // commit-cell-edit / commit-and-advance-right take a mode-name arg + // (e.g. "editing" or "records-editing") as args[0]. The keymap supplies + // it; the command never inspects ctx.mode. r.register( &CommitAndAdvance { key: CellKey::new(vec![]), value: String::new(), advance: AdvanceDir::Down, cursor: CursorState::default(), + edit_mode: AppMode::editing(), }, |args| { - if args.len() < 2 { - return Err("commit-cell-edit requires a value and coords".into()); + if args.len() < 3 { + return Err("commit-cell-edit requires a mode, value, and coords".into()); } + let edit_mode = parse_mode_name(&args[0])?; Ok(Box::new(CommitAndAdvance { - key: parse_cell_key_from_args(&args[1..]), - value: args[0].clone(), + key: parse_cell_key_from_args(&args[2..]), + value: args[1].clone(), advance: AdvanceDir::Down, cursor: CursorState::default(), + edit_mode, })) }, - |_args, ctx| { + |args, ctx| { + require_args("commit-cell-edit", args, 1)?; + let edit_mode = parse_mode_name(&args[0])?; let value = read_buffer(ctx, "edit"); let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; Ok(Box::new(CommitAndAdvance { @@ -548,6 +555,7 @@ pub fn default_registry() -> CmdRegistry { value, advance: AdvanceDir::Down, cursor: CursorState::from_ctx(ctx), + edit_mode, })) }, ); @@ -557,9 +565,12 @@ pub fn default_registry() -> CmdRegistry { value: String::new(), advance: AdvanceDir::Right, cursor: CursorState::default(), + edit_mode: AppMode::editing(), }, |_| Err("commit-and-advance-right requires context".into()), - |_args, ctx| { + |args, ctx| { + require_args("commit-and-advance-right", args, 1)?; + let edit_mode = parse_mode_name(&args[0])?; let value = read_buffer(ctx, "edit"); let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; Ok(Box::new(CommitAndAdvance { @@ -567,6 +578,7 @@ pub fn default_registry() -> CmdRegistry { value, advance: AdvanceDir::Right, cursor: CursorState::from_ctx(ctx), + edit_mode, })) }, ); diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 872abde..346e081 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -123,10 +123,19 @@ impl Effect for RemoveFormula { /// Re-enter edit mode by reading the cell value at the current cursor. /// Used after commit+advance to continue data entry. +/// +/// `target_mode` is supplied by the caller (keymap binding via +/// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode` +/// field). The effect itself never inspects `app.mode` — the mode is decided +/// statically by whoever invoked us. #[derive(Debug)] -pub struct EnterEditAtCursor; +pub struct EnterEditAtCursor { + pub target_mode: AppMode, +} impl Effect for EnterEditAtCursor { fn apply(&self, app: &mut App) { + // Layout may be stale relative to prior effects in this batch (e.g. + // AddRecordRow added a row); rebuild before reading display_value. app.rebuild_layout(); let ctx = app.cmd_context( crossterm::event::KeyCode::Null, @@ -135,11 +144,7 @@ impl Effect for EnterEditAtCursor { let value = ctx.display_value.clone(); drop(ctx); app.buffers.insert("edit".to_string(), value); - app.mode = if app.mode.is_records() { - AppMode::records_editing() - } else { - AppMode::editing() - }; + app.mode = self.target_mode.clone(); } } @@ -1284,6 +1289,41 @@ mod tests { assert_eq!(app.mode, AppMode::Help); } + /// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever + /// `app.mode` happens to be when applied. Previous implementation + /// branched on `app.mode.is_records()` — the parameterized version + /// trusts the caller (keymap or composing command). + #[test] + fn enter_edit_at_cursor_uses_target_mode_not_app_mode() { + let mut app = test_app(); + // App starts in Normal mode — but caller has decided we want + // RecordsEditing (e.g. records-mode `o` sequence). + assert_eq!(app.mode, AppMode::Normal); + EnterEditAtCursor { + target_mode: AppMode::records_editing(), + } + .apply(&mut app); + assert!( + matches!(app.mode, AppMode::RecordsEditing { .. }), + "Expected RecordsEditing, got {:?}", + app.mode + ); + + // Same effect with editing target — should land in plain Editing + // even if app.mode was something else. + let mut app2 = test_app(); + app2.mode = AppMode::RecordsNormal; + EnterEditAtCursor { + target_mode: AppMode::editing(), + } + .apply(&mut app2); + assert!( + matches!(app2.mode, AppMode::Editing { .. }), + "Expected Editing, got {:?}", + app2.mode + ); + } + /// SetBuffer with empty value clears the buffer (used by clear-buffer command /// in keymap sequences after commit). #[test] From 30383f203ec7c75bd6e6c91ab800b1b8aa9ddc66 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 22:44:13 -0700 Subject: [PATCH 2/5] refactor(keymap): pass mode arguments in keybindings Update keybindings for normal, records-normal, editing, and records-editing modes to pass the appropriate mode names as arguments to the parameterized commands. This ensures that the correct mode is entered when using commands like `edit-or-drill` , `enter-edit-at-cursor` , `commit-cell-edit` , and `commit-and-advance-right` . Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf) --- src/command/keymap.rs | 60 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/command/keymap.rs b/src/command/keymap.rs index a0276e3..92d5870 100644 --- a/src/command/keymap.rs +++ b/src/command/keymap.rs @@ -431,9 +431,21 @@ impl KeymapSet { ); normal.bind(KeyCode::Tab, none, "cycle-panel-focus"); - // Editing entry — i/a drill into aggregated cells, else edit - normal.bind(KeyCode::Char('i'), none, "edit-or-drill"); - normal.bind(KeyCode::Char('a'), none, "edit-or-drill"); + // Editing entry — i/a drill into aggregated cells, else edit. + // The mode arg controls which editing mode is entered; records-normal + // overrides these to "records-editing" via its own bindings. + normal.bind_args( + KeyCode::Char('i'), + none, + "edit-or-drill", + vec!["editing".into()], + ); + normal.bind_args( + KeyCode::Char('a'), + none, + "edit-or-drill", + vec!["editing".into()], + ); normal.bind(KeyCode::Enter, none, "enter-advance"); normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt"); @@ -488,10 +500,27 @@ impl KeymapSet { // ── Records normal mode (inherits from normal) ──────────────────── let mut rn = Keymap::with_parent(normal); + // Override i/a so the edit branch produces records-editing mode + // instead of inheriting the normal-mode "editing" arg. + rn.bind_args( + KeyCode::Char('i'), + none, + "edit-or-drill", + vec!["records-editing".into()], + ); + rn.bind_args( + KeyCode::Char('a'), + none, + "edit-or-drill", + vec!["records-editing".into()], + ); rn.bind_seq( KeyCode::Char('o'), none, - vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])], + vec![ + ("add-record-row", vec![]), + ("enter-edit-at-cursor", vec!["records-editing".into()]), + ], ); set.insert(ModeKey::RecordsNormal, Arc::new(rn)); @@ -736,6 +765,8 @@ impl KeymapSet { set.insert(ModeKey::TileSelect, Arc::new(ts)); // ── Editing mode ───────────────────────────────────────────────── + // commit-* takes the target edit-mode arg so the command stays + // mode-agnostic; records-editing overrides Enter/Tab below. let mut ed = Keymap::new(); ed.bind_seq( KeyCode::Esc, @@ -749,7 +780,7 @@ impl KeymapSet { KeyCode::Enter, none, vec![ - ("commit-cell-edit", vec![]), + ("commit-cell-edit", vec!["editing".into()]), ("clear-buffer", vec!["edit".into()]), ], ); @@ -757,7 +788,7 @@ impl KeymapSet { KeyCode::Tab, none, vec![ - ("commit-and-advance-right", vec![]), + ("commit-and-advance-right", vec!["editing".into()]), ("clear-buffer", vec!["edit".into()]), ], ); @@ -767,6 +798,7 @@ impl KeymapSet { set.insert(ModeKey::Editing, ed.clone()); // ── Records editing mode (inherits from editing) ────────────────── + // Override Enter/Tab so the post-commit re-enter targets records-editing. let mut re = Keymap::with_parent(ed); re.bind_seq( KeyCode::Esc, @@ -776,6 +808,22 @@ impl KeymapSet { ("enter-mode", vec!["records-normal".into()]), ], ); + re.bind_seq( + KeyCode::Enter, + none, + vec![ + ("commit-cell-edit", vec!["records-editing".into()]), + ("clear-buffer", vec!["edit".into()]), + ], + ); + re.bind_seq( + KeyCode::Tab, + none, + vec![ + ("commit-and-advance-right", vec!["records-editing".into()]), + ("clear-buffer", vec!["edit".into()]), + ], + ); set.insert(ModeKey::RecordsEditing, Arc::new(re)); // ── Formula edit ───────────────────────────────────────────────── From d20eb75a0ba2bae5df4103acc38df4e545668dd6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 22:44:47 -0700 Subject: [PATCH 3/5] feat: roadmap from beads --- roadmap.org | 9170 ++++++++++++++++++++++++++++++++++++++++ scripts/gen_roadmap.py | 179 + 2 files changed, 9349 insertions(+) create mode 100644 roadmap.org create mode 100755 scripts/gen_roadmap.py diff --git a/roadmap.org b/roadmap.org new file mode 100644 index 0000000..4a7524b --- /dev/null +++ b/roadmap.org @@ -0,0 +1,9170 @@ +#+TITLE: Improvise Roadmap +#+AUTHOR: Edward Langley +#+TODO: TODO DOING WAIT | DONE +#+STARTUP: overview +#+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug +#+PROPERTY: COOKIE_DATA todo recursive + +Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-trees: each root is a goal that nothing else depends on; its children are the deps blocking it. Diamonds in the DAG are duplicated so each tree stands alone. + +* DOING 'o' (add-record-row) broken in fresh data models (standalone) :standalone:P2:bug:@cursor_f4c497bb: + :PROPERTIES: + :ID: improvise-s0h + :TYPE: bug + :PRIORITY: P2 + :STATUS: in_progress + :ASSIGNEE: cursor-f4c497bb + :OWNER: el-github@elangley.org + :CREATED_BY: spot + :CREATED: 2026-04-09 22:18:58 + :UPDATED: 2026-04-14 08:07:41 + :KIND: standalone + :END: +** Details +*** Description + Pressing 'o' in records mode to add a new record row doesn't work correctly with fresh data models. The keybinding exists (add-record-row + enter-edit-at-cursor sequence) but the behavior is broken. Needs investigation to reproduce and fix. + +* TODO Virtual views _Records and _Drill should not be persisted to .improv files (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-60z + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Edward Langley + :CREATED: 2026-04-15 11:13:29 + :UPDATED: 2026-04-15 11:13:29 + :KIND: standalone + :END: + +* TODO Epic: Browser frontend via synchronized Redux-style stores (epic) [0/68] :epic:P2:feature: + :PROPERTIES: + :ID: improvise-6jk + :TYPE: feature + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: spot + :CREATED: 2026-04-14 07:21:21 + :UPDATED: 2026-04-14 07:21:21 + :KIND: epic + :END: +** Details +*** Description + Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps. +*** Design + Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness. +*** Notes + Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side. + +** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: + :PROPERTIES: + :ID: improvise-vb4 + :TYPE: feature + :PRIORITY: P2 + :STATUS: in_progress + :ASSIGNEE: Edward Langley + :OWNER: el-github@elangley.org + :CREATED_BY: spot + :CREATED: 2026-04-14 07:21:59 + :UPDATED: 2026-04-15 10:24:54 + :KIND: standalone + :END: +*** Details +**** Description + Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. +**** Design + pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. +**** Acceptance Criteria + (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. +**** Notes + Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. + +** TODO Browser frontend MVP: end-to-end working demo (epic) [0/23] :epic:P2:feature: + :PROPERTIES: + :ID: improvise-1ey + :TYPE: feature + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: spot + :CREATED: 2026-04-14 07:24:44 + :UPDATED: 2026-04-14 07:24:44 + :KIND: epic + :END: +*** Details +**** Description + Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value. +**** Design + HTML shell: minimal index.html with a
for the grid and a