Iteration 4b: save / save as / new / load with project switching
Adds the rest of the track-2 lifecycle commands (ADR-0015 §11) and the project-switching machinery they need at runtime. Temp vs named distinction: replaced the fragile naming heuristic with an explicit `[temp]` marker in the directory pattern (`<YYYYMMDD>-[temp]-<word>-<word>-<word>`). validate_user_name already rejects brackets, so user-typed names can never collide with a temp marker. The status bar shows `[TEMP] <Display Name>` for temp projects; the prettifier strips both the date and the marker so display names are clean. save / save as: temp project's `save` opens a path-entry modal (acts as save as); named project's `save` reports "already auto-saved; use `save as`". `save as` always prompts. Relative names resolve under <data-root>/projects/; absolute paths used as-is. Copy excludes the per-process lock file; everything else (.db, yaml, csvs, history.log) is copied. new: closes current project, creates a fresh auto-named temp, switches. load: opens a picker. List sub-mode shows projects in the active data root, sorted newest-first by project.yaml mtime; arrow keys navigate, Enter loads, `b` switches to a path-entry sub-mode for projects elsewhere, Esc cancels. Empty data root jumps straight to path entry. Runtime: `Session` holds Option<Project> + Option<Database> so project switches can drop old (releasing lock + stopping worker) before opening new -- required for the "load my own current project" case. `perform_switch` handles Load / SaveAs / NewTemp uniformly. Tests: 332 passing (270 lib + 9 + 5 + 6 + 16 new + 9 + 17), 0 failing, 0 skipped. Clippy clean.
This commit is contained in:
+349
-13
@@ -106,6 +106,10 @@ pub struct App {
|
||||
/// during very-early startup before the runtime has opened a
|
||||
/// project; otherwise always populated.
|
||||
pub project_name: Option<String>,
|
||||
/// Whether the open project is auto-named temporary or
|
||||
/// user-named permanent. Drives the `[TEMP]` prefix in the
|
||||
/// status bar and the `save` command's behaviour.
|
||||
pub project_is_temp: bool,
|
||||
/// Set when a fatal persistence failure has occurred
|
||||
/// (ADR-0015 §8). The runtime reads this after the event
|
||||
/// loop exits and prints it to stderr post-teardown so the
|
||||
@@ -130,6 +134,13 @@ pub enum Modal {
|
||||
/// `rebuild` confirmation. Shows a summary of what would
|
||||
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
|
||||
RebuildConfirm(RebuildConfirmModal),
|
||||
/// One-line text prompt used by `save` / `save as` for
|
||||
/// the target name/path.
|
||||
PathEntry(PathEntryModal),
|
||||
/// Load picker. Shows a list of projects in the active
|
||||
/// data root; `b` switches to a path-entry sub-mode for
|
||||
/// projects outside the data root (ADR-0015 §7).
|
||||
LoadPicker(LoadPickerModal),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -139,6 +150,53 @@ pub struct RebuildConfirmModal {
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PathEntryModal {
|
||||
pub title: String,
|
||||
pub prompt: String,
|
||||
pub input: String,
|
||||
/// Byte offset of the insertion point inside `input`.
|
||||
pub cursor: usize,
|
||||
pub purpose: PathEntryPurpose,
|
||||
}
|
||||
|
||||
/// What the runtime should do with the path the user typed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathEntryPurpose {
|
||||
/// Save the current project to the typed name/path.
|
||||
/// Relative names resolve against `<data-root>/projects/`.
|
||||
SaveAs,
|
||||
/// Load the project at the typed path. Used by the load
|
||||
/// picker's `b` (browse) sub-mode.
|
||||
LoadByPath,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoadPickerModal {
|
||||
pub entries: Vec<LoadPickerEntry>,
|
||||
pub selected: usize,
|
||||
/// Sub-mode: list-of-recents (default) or path-entry
|
||||
/// (after `b`).
|
||||
pub sub_mode: LoadPickerSubMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoadPickerEntry {
|
||||
pub display_name: String,
|
||||
pub modified: String,
|
||||
pub path: std::path::PathBuf,
|
||||
pub is_temp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LoadPickerSubMode {
|
||||
List,
|
||||
/// Switched to via `b`. Same input/cursor surface as
|
||||
/// `PathEntryModal`; kept inline so the picker can flip
|
||||
/// back to List with `Esc`.
|
||||
PathEntry { input: String, cursor: usize },
|
||||
}
|
||||
|
||||
const PAGE_SCROLL_LINES: usize = 5;
|
||||
|
||||
const HISTORY_CAPACITY: usize = 1000;
|
||||
@@ -167,6 +225,7 @@ impl App {
|
||||
last_output_visible: 0,
|
||||
last_output_total_wrapped: 0,
|
||||
project_name: None,
|
||||
project_is_temp: false,
|
||||
fatal_message: None,
|
||||
modal: None,
|
||||
}
|
||||
@@ -269,6 +328,43 @@ impl App {
|
||||
self.note_error(format!("rebuild failed: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::LoadPickerReady { entries } => {
|
||||
if entries.is_empty() {
|
||||
// Empty data root: jump straight to path-entry
|
||||
// mode so the user can still browse to a
|
||||
// project elsewhere.
|
||||
self.modal = Some(Modal::LoadPicker(LoadPickerModal {
|
||||
entries,
|
||||
selected: 0,
|
||||
sub_mode: LoadPickerSubMode::PathEntry {
|
||||
input: String::new(),
|
||||
cursor: 0,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
self.modal = Some(Modal::LoadPicker(LoadPickerModal {
|
||||
entries,
|
||||
selected: 0,
|
||||
sub_mode: LoadPickerSubMode::List,
|
||||
}));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ProjectSwitched {
|
||||
display_name,
|
||||
is_temp,
|
||||
} => {
|
||||
self.note_system(format!("[ok] now editing: {display_name}"));
|
||||
self.project_name = Some(display_name);
|
||||
self.project_is_temp = is_temp;
|
||||
self.tables.clear();
|
||||
self.current_table = None;
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ProjectSwitchFailed { error } => {
|
||||
self.note_error(format!("project switch failed: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,12 +593,26 @@ impl App {
|
||||
}
|
||||
|
||||
// Canonical app-level commands recognised in both modes.
|
||||
// Track-2's full lifecycle command set (`save`, `load`,
|
||||
// `new`, `export`, `import`) lands across Iterations 4
|
||||
// and 5; this iteration adds `rebuild`.
|
||||
// Track-2's full lifecycle command set lands across
|
||||
// Iterations 4 (rebuild, save, save as, new, load) and
|
||||
// 5 (export, import).
|
||||
match effective_input.as_str() {
|
||||
"quit" | "q" => return vec![Action::Quit],
|
||||
"rebuild" => return vec![Action::PrepareRebuild],
|
||||
"save" => {
|
||||
return self.handle_save_command(false);
|
||||
}
|
||||
"save as" => {
|
||||
return self.handle_save_command(true);
|
||||
}
|
||||
"new" => {
|
||||
return vec![Action::NewProject {
|
||||
source: "new".to_string(),
|
||||
}];
|
||||
}
|
||||
"load" => {
|
||||
return vec![Action::OpenLoadPicker];
|
||||
}
|
||||
other if other.starts_with("mode") => {
|
||||
self.handle_mode_command(other);
|
||||
return Vec::new();
|
||||
@@ -660,6 +770,30 @@ impl App {
|
||||
));
|
||||
}
|
||||
|
||||
/// Dispatch for the `save` and `save as` commands.
|
||||
///
|
||||
/// `save` on a temp project is identical to `save as`
|
||||
/// (prompts for a target). `save` on a named project is a
|
||||
/// no-op with a friendly hint, since auto-save guarantees
|
||||
/// the named project is already persistent (ADR-0015 §11).
|
||||
fn handle_save_command(&mut self, force_save_as: bool) -> Vec<Action> {
|
||||
if !force_save_as && !self.project_is_temp {
|
||||
self.note_system(
|
||||
"already auto-saved; use `save as` to copy to a different location",
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
let title = if force_save_as { "Save as" } else { "Save" };
|
||||
self.modal = Some(Modal::PathEntry(PathEntryModal {
|
||||
title: title.to_string(),
|
||||
prompt: "Name (under data dir/projects) or absolute path:".to_string(),
|
||||
input: String::new(),
|
||||
cursor: 0,
|
||||
purpose: PathEntryPurpose::SaveAs,
|
||||
}));
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Route a keypress through whichever modal is active.
|
||||
///
|
||||
/// Each modal owns its own tiny state machine. On
|
||||
@@ -667,23 +801,225 @@ impl App {
|
||||
/// for the runtime to enact. On dismissal it simply
|
||||
/// closes itself.
|
||||
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||
let Some(modal) = self.modal.as_ref() else {
|
||||
let Some(modal) = self.modal.clone() else {
|
||||
return Vec::new();
|
||||
};
|
||||
match modal {
|
||||
Modal::RebuildConfirm(_) => match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.modal = None;
|
||||
vec![Action::Rebuild {
|
||||
source: "rebuild".to_string(),
|
||||
}]
|
||||
Modal::RebuildConfirm(_) => self.handle_rebuild_confirm_key(key),
|
||||
Modal::PathEntry(state) => self.handle_path_entry_key(key, state),
|
||||
Modal::LoadPicker(state) => self.handle_load_picker_key(key, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_rebuild_confirm_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
self.modal = None;
|
||||
vec![Action::Rebuild {
|
||||
source: "rebuild".to_string(),
|
||||
}]
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||
self.modal = None;
|
||||
self.note_system("rebuild cancelled");
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_path_entry_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
mut state: PathEntryModal,
|
||||
) -> Vec<Action> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.modal = None;
|
||||
self.note_system(format!("{} cancelled", state.title.to_lowercase()));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let target = state.input.trim().to_string();
|
||||
if target.is_empty() {
|
||||
self.note_error("path entry: empty name");
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
return Vec::new();
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||
self.modal = None;
|
||||
match state.purpose {
|
||||
PathEntryPurpose::SaveAs => vec![Action::SaveAs {
|
||||
target,
|
||||
source: "save as".to_string(),
|
||||
}],
|
||||
PathEntryPurpose::LoadByPath => vec![Action::LoadProject {
|
||||
path: std::path::PathBuf::from(target),
|
||||
source: "load".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
state.input.insert(state.cursor, c);
|
||||
state.cursor += c.len_utf8();
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.cursor > 0 {
|
||||
let before = state.input[..state.cursor].chars().next_back();
|
||||
if let Some(c) = before {
|
||||
let new_cursor = state.cursor - c.len_utf8();
|
||||
state.input.drain(new_cursor..state.cursor);
|
||||
state.cursor = new_cursor;
|
||||
}
|
||||
}
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if state.cursor > 0
|
||||
&& let Some(c) = state.input[..state.cursor].chars().next_back()
|
||||
{
|
||||
state.cursor -= c.len_utf8();
|
||||
}
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if state.cursor < state.input.len()
|
||||
&& let Some(c) = state.input[state.cursor..].chars().next()
|
||||
{
|
||||
state.cursor += c.len_utf8();
|
||||
}
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Home => {
|
||||
state.cursor = 0;
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::End => {
|
||||
state.cursor = state.input.len();
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
_ => {
|
||||
self.modal = Some(Modal::PathEntry(state));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_load_picker_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
mut state: LoadPickerModal,
|
||||
) -> Vec<Action> {
|
||||
match &mut state.sub_mode {
|
||||
LoadPickerSubMode::List => match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.modal = None;
|
||||
self.note_system("rebuild cancelled");
|
||||
self.note_system("load cancelled");
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if state.selected > 0 {
|
||||
state.selected -= 1;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if state.selected + 1 < state.entries.len() {
|
||||
state.selected += 1;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(entry) = state.entries.get(state.selected).cloned() {
|
||||
self.modal = None;
|
||||
return vec![Action::LoadProject {
|
||||
path: entry.path,
|
||||
source: "load".to_string(),
|
||||
}];
|
||||
}
|
||||
self.note_error("nothing to load");
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||
state.sub_mode = LoadPickerSubMode::PathEntry {
|
||||
input: String::new(),
|
||||
cursor: 0,
|
||||
};
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
_ => {
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
LoadPickerSubMode::PathEntry { input, cursor } => match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.sub_mode = LoadPickerSubMode::List;
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let target = input.trim().to_string();
|
||||
if target.is_empty() {
|
||||
self.note_error("path entry: empty path");
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
return Vec::new();
|
||||
}
|
||||
self.modal = None;
|
||||
vec![Action::LoadProject {
|
||||
path: std::path::PathBuf::from(target),
|
||||
source: "load".to_string(),
|
||||
}]
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
input.insert(*cursor, c);
|
||||
*cursor += c.len_utf8();
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if *cursor > 0
|
||||
&& let Some(c) = input[..*cursor].chars().next_back()
|
||||
{
|
||||
let new_cursor = *cursor - c.len_utf8();
|
||||
input.drain(new_cursor..*cursor);
|
||||
*cursor = new_cursor;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if *cursor > 0
|
||||
&& let Some(c) = input[..*cursor].chars().next_back()
|
||||
{
|
||||
*cursor -= c.len_utf8();
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if *cursor < input.len()
|
||||
&& let Some(c) = input[*cursor..].chars().next()
|
||||
{
|
||||
*cursor += c.len_utf8();
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
_ => {
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user