Add EnterEditAtCursor effect to re-enter edit mode after commit. Used by CommitCellEdit to continue data entry after advancing cursor. Reads the cell value at the new cursor position and starts editing mode with that value pre-filled. Also adds TogglePruneEmpty, ToggleCatExpand, RemoveItem, and RemoveCategory effects to effect.rs for the new commands. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
883 lines
27 KiB
Rust
883 lines
27 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);
|
|
}
|
|
}
|
|
|
|
/// Re-enter edit mode by reading the cell value at the current cursor.
|
|
/// Used after commit+advance to continue data entry.
|
|
#[derive(Debug)]
|
|
pub struct EnterEditAtCursor;
|
|
impl Effect for EnterEditAtCursor {
|
|
fn apply(&self, app: &mut App) {
|
|
let ctx = app.cmd_context(crossterm::event::KeyCode::Null, crossterm::event::KeyModifiers::NONE);
|
|
let value = if let Some(v) = &ctx.records_value {
|
|
v.clone()
|
|
} else {
|
|
ctx.cell_key
|
|
.as_ref()
|
|
.and_then(|k| ctx.model.get_cell(k).cloned())
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_default()
|
|
};
|
|
drop(ctx);
|
|
app.buffers.insert("edit".to_string(), value);
|
|
app.mode = AppMode::Editing {
|
|
buffer: String::new(),
|
|
};
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct TogglePruneEmpty;
|
|
impl Effect for TogglePruneEmpty {
|
|
fn apply(&self, app: &mut App) {
|
|
let v = app.model.active_view_mut();
|
|
v.prune_empty = !v.prune_empty;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ToggleCatExpand(pub String);
|
|
impl Effect for ToggleCatExpand {
|
|
fn apply(&self, app: &mut App) {
|
|
if !app.expanded_cats.remove(&self.0) {
|
|
app.expanded_cats.insert(self.0.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RemoveItem {
|
|
pub category: String,
|
|
pub item: String,
|
|
}
|
|
impl Effect for RemoveItem {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.remove_item(&self.category, &self.item);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct RemoveCategory(pub String);
|
|
impl Effect for RemoveCategory {
|
|
fn apply(&self, app: &mut App) {
|
|
app.model.remove_category(&self.0);
|
|
}
|
|
}
|
|
|
|
// ── 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;
|
|
};
|
|
if drill.pending_edits.is_empty() {
|
|
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))
|
|
}
|