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:
claude@clouddev1
2026-05-08 06:23:46 +00:00
parent ba93d3c7d8
commit f2198275f0
9 changed files with 1376 additions and 44 deletions
+349 -13
View File
@@ -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(),
},
}
}