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:
@@ -36,4 +36,27 @@ pub enum Action {
|
|||||||
Rebuild {
|
Rebuild {
|
||||||
source: String,
|
source: String,
|
||||||
},
|
},
|
||||||
|
/// Open the load-picker modal. Runtime lists projects in
|
||||||
|
/// the active data root and posts back as
|
||||||
|
/// `AppEvent::LoadPickerReady`.
|
||||||
|
OpenLoadPicker,
|
||||||
|
/// Switch to the project at `path` (absolute or relative
|
||||||
|
/// to the active data root). Runtime drops the current
|
||||||
|
/// project, opens the new one, refreshes app state.
|
||||||
|
LoadProject {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
|
/// Save the current project to `target` and switch to it.
|
||||||
|
/// `target` is a name or absolute path; relative names
|
||||||
|
/// resolve against `<data-root>/projects/` per ADR-0015 §1.
|
||||||
|
SaveAs {
|
||||||
|
target: String,
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
|
/// Close the current project (auto-save guarantees state
|
||||||
|
/// is on disk) and create a fresh auto-named temp.
|
||||||
|
NewProject {
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+341
-5
@@ -106,6 +106,10 @@ pub struct App {
|
|||||||
/// during very-early startup before the runtime has opened a
|
/// during very-early startup before the runtime has opened a
|
||||||
/// project; otherwise always populated.
|
/// project; otherwise always populated.
|
||||||
pub project_name: Option<String>,
|
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
|
/// Set when a fatal persistence failure has occurred
|
||||||
/// (ADR-0015 §8). The runtime reads this after the event
|
/// (ADR-0015 §8). The runtime reads this after the event
|
||||||
/// loop exits and prints it to stderr post-teardown so the
|
/// 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
|
/// `rebuild` confirmation. Shows a summary of what would
|
||||||
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
|
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
|
||||||
RebuildConfirm(RebuildConfirmModal),
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -139,6 +150,53 @@ pub struct RebuildConfirmModal {
|
|||||||
pub summary: String,
|
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 PAGE_SCROLL_LINES: usize = 5;
|
||||||
|
|
||||||
const HISTORY_CAPACITY: usize = 1000;
|
const HISTORY_CAPACITY: usize = 1000;
|
||||||
@@ -167,6 +225,7 @@ impl App {
|
|||||||
last_output_visible: 0,
|
last_output_visible: 0,
|
||||||
last_output_total_wrapped: 0,
|
last_output_total_wrapped: 0,
|
||||||
project_name: None,
|
project_name: None,
|
||||||
|
project_is_temp: false,
|
||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
modal: None,
|
modal: None,
|
||||||
}
|
}
|
||||||
@@ -269,6 +328,43 @@ impl App {
|
|||||||
self.note_error(format!("rebuild failed: {error}"));
|
self.note_error(format!("rebuild failed: {error}"));
|
||||||
Vec::new()
|
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.
|
// Canonical app-level commands recognised in both modes.
|
||||||
// Track-2's full lifecycle command set (`save`, `load`,
|
// Track-2's full lifecycle command set lands across
|
||||||
// `new`, `export`, `import`) lands across Iterations 4
|
// Iterations 4 (rebuild, save, save as, new, load) and
|
||||||
// and 5; this iteration adds `rebuild`.
|
// 5 (export, import).
|
||||||
match effective_input.as_str() {
|
match effective_input.as_str() {
|
||||||
"quit" | "q" => return vec![Action::Quit],
|
"quit" | "q" => return vec![Action::Quit],
|
||||||
"rebuild" => return vec![Action::PrepareRebuild],
|
"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") => {
|
other if other.starts_with("mode") => {
|
||||||
self.handle_mode_command(other);
|
self.handle_mode_command(other);
|
||||||
return Vec::new();
|
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.
|
/// Route a keypress through whichever modal is active.
|
||||||
///
|
///
|
||||||
/// Each modal owns its own tiny state machine. On
|
/// Each modal owns its own tiny state machine. On
|
||||||
@@ -667,11 +801,18 @@ impl App {
|
|||||||
/// for the runtime to enact. On dismissal it simply
|
/// for the runtime to enact. On dismissal it simply
|
||||||
/// closes itself.
|
/// closes itself.
|
||||||
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
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();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
match modal {
|
match modal {
|
||||||
Modal::RebuildConfirm(_) => match key.code {
|
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') => {
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||||
self.modal = None;
|
self.modal = None;
|
||||||
vec![Action::Rebuild {
|
vec![Action::Rebuild {
|
||||||
@@ -684,6 +825,201 @@ impl App {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
_ => 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();
|
||||||
|
}
|
||||||
|
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("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()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,22 @@ pub enum AppEvent {
|
|||||||
RebuildFailed {
|
RebuildFailed {
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
|
/// Runtime has gathered the list of available projects
|
||||||
|
/// for the load picker. App opens the picker modal.
|
||||||
|
LoadPickerReady {
|
||||||
|
entries: Vec<crate::app::LoadPickerEntry>,
|
||||||
|
},
|
||||||
|
/// A project switch (load / new / save-as) succeeded.
|
||||||
|
/// Carries the new display name + temp flag so App can
|
||||||
|
/// update the status bar.
|
||||||
|
ProjectSwitched {
|
||||||
|
display_name: String,
|
||||||
|
is_temp: bool,
|
||||||
|
},
|
||||||
|
/// A project switch failed in a non-fatal way (target
|
||||||
|
/// already exists, path unreadable, …). Surfaced as an
|
||||||
|
/// error in the output panel.
|
||||||
|
ProjectSwitchFailed {
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,78 @@ pub fn projects_dir(data_root: &Path) -> PathBuf {
|
|||||||
data_root.join(PROJECTS_SUBDIR)
|
data_root.join(PROJECTS_SUBDIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One entry the load picker shows. Contains the full path
|
||||||
|
/// (so the runtime can switch to it) plus the cached display
|
||||||
|
/// metadata (so the picker doesn't have to re-prettify on
|
||||||
|
/// every redraw).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProjectListing {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub display_name: String,
|
||||||
|
/// The `mtime` of `project.yaml`, formatted as
|
||||||
|
/// `YYYY-MM-DD HH:MM` for display in the picker. Falls
|
||||||
|
/// back to "" if the metadata can't be read.
|
||||||
|
pub modified: String,
|
||||||
|
pub kind: ProjectKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the projects available to the load picker.
|
||||||
|
///
|
||||||
|
/// Walks `<data_root>/projects/` and returns one
|
||||||
|
/// `ProjectListing` per child directory that looks like a
|
||||||
|
/// project (has a `project.yaml`). Sorted newest-first by
|
||||||
|
/// `project.yaml` mtime.
|
||||||
|
pub fn list_projects(data_root: &Path) -> Vec<ProjectListing> {
|
||||||
|
let dir = projects_dir(data_root);
|
||||||
|
let Ok(entries) = fs::read_dir(&dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut listings: Vec<(std::time::SystemTime, ProjectListing)> = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let yaml = path.join(PROJECT_YAML);
|
||||||
|
if !yaml.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mtime = yaml
|
||||||
|
.metadata()
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(std::time::UNIX_EPOCH);
|
||||||
|
let dirname = directory_name(&path);
|
||||||
|
let display_name = prettifier::prettify(&dirname);
|
||||||
|
let modified = format_modified(mtime);
|
||||||
|
let kind = if naming::is_temp_dirname(&dirname) {
|
||||||
|
ProjectKind::Temp
|
||||||
|
} else {
|
||||||
|
ProjectKind::Named
|
||||||
|
};
|
||||||
|
listings.push((
|
||||||
|
mtime,
|
||||||
|
ProjectListing {
|
||||||
|
path,
|
||||||
|
display_name,
|
||||||
|
modified,
|
||||||
|
kind,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Sort newest mtime first.
|
||||||
|
listings.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
|
||||||
|
listings.into_iter().map(|(_, l)| l).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_modified(t: std::time::SystemTime) -> String {
|
||||||
|
let secs = t
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let day_secs = secs.rem_euclid(86_400);
|
||||||
|
let h = day_secs / 3600;
|
||||||
|
let m = (day_secs % 3600) / 60;
|
||||||
|
let (y, mo, d) = naming_ymd(secs);
|
||||||
|
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
|
||||||
|
}
|
||||||
|
|
||||||
/// Iteration-1 startup logic (ADR-0015 §1):
|
/// Iteration-1 startup logic (ADR-0015 §1):
|
||||||
///
|
///
|
||||||
/// - If `project_path` is `Some`, open that project (refused if
|
/// - If `project_path` is `Some`, open that project (refused if
|
||||||
@@ -98,10 +170,24 @@ pub fn open_or_create(
|
|||||||
pub struct Project {
|
pub struct Project {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
display_name: String,
|
display_name: String,
|
||||||
|
kind: ProjectKind,
|
||||||
/// Held for the project's lifetime; released on drop.
|
/// Held for the project's lifetime; released on drop.
|
||||||
_lock: Lock,
|
_lock: Lock,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this project is auto-named temporary or
|
||||||
|
/// user-named permanent (ADR-0015 §1, §11).
|
||||||
|
///
|
||||||
|
/// The distinction drives the `save` command's behaviour:
|
||||||
|
/// for temp projects it elevates to a named project (== `save
|
||||||
|
/// as`); for named projects it reports "already auto-saved"
|
||||||
|
/// since no work has been done that wasn't already persisted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProjectKind {
|
||||||
|
Temp,
|
||||||
|
Named,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ProjectError {
|
pub enum ProjectError {
|
||||||
#[error("could not determine the OS-standard data directory; pass --data-dir to override")]
|
#[error("could not determine the OS-standard data directory; pass --data-dir to override")]
|
||||||
@@ -150,6 +236,7 @@ impl Project {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
display_name,
|
display_name,
|
||||||
|
kind: ProjectKind::Temp,
|
||||||
_lock: lock,
|
_lock: lock,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -174,6 +261,7 @@ impl Project {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
display_name,
|
display_name,
|
||||||
|
kind: ProjectKind::Named,
|
||||||
_lock: lock,
|
_lock: lock,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -193,11 +281,17 @@ impl Project {
|
|||||||
}
|
}
|
||||||
let dirname = directory_name(path);
|
let dirname = directory_name(path);
|
||||||
let display_name = prettifier::prettify(&dirname);
|
let display_name = prettifier::prettify(&dirname);
|
||||||
|
let kind = if naming::is_temp_dirname(&dirname) {
|
||||||
|
ProjectKind::Temp
|
||||||
|
} else {
|
||||||
|
ProjectKind::Named
|
||||||
|
};
|
||||||
let lock = Lock::acquire(path)?;
|
let lock = Lock::acquire(path)?;
|
||||||
info!(path = %path.display(), name = %display_name, "opened project");
|
info!(path = %path.display(), name = %display_name, "opened project");
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
display_name,
|
display_name,
|
||||||
|
kind,
|
||||||
_lock: lock,
|
_lock: lock,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -247,6 +341,11 @@ impl Project {
|
|||||||
&self.display_name
|
&self.display_name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> ProjectKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
/// Path to the SQLite database for this project. Always
|
/// Path to the SQLite database for this project. Always
|
||||||
/// `<project>/playground.db`.
|
/// `<project>/playground.db`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -255,6 +354,47 @@ impl Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy a project directory to a new location.
|
||||||
|
///
|
||||||
|
/// Used by `save` / `save as` (ADR-0015 §11). Excludes the
|
||||||
|
/// per-process lock file (a fresh one is acquired when the
|
||||||
|
/// destination project is opened); copies everything else
|
||||||
|
/// including `playground.db`. The target path must not
|
||||||
|
/// already exist (per the §2 collision rule); the caller is
|
||||||
|
/// expected to validate that before invoking this helper.
|
||||||
|
pub fn copy_project(src: &Path, dst: &Path) -> Result<(), ProjectError> {
|
||||||
|
if dst.exists() {
|
||||||
|
return Err(ProjectError::AlreadyExists(dst.to_path_buf()));
|
||||||
|
}
|
||||||
|
copy_dir_recursive(src, dst).map_err(|source| ProjectError::Io {
|
||||||
|
path: dst.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(dst)?;
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name == ".rdbms-playground.lock" {
|
||||||
|
// Per-process artifact; the destination project
|
||||||
|
// will write a fresh one when opened.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let src_path = entry.path();
|
||||||
|
let dst_path = dst.join(&name);
|
||||||
|
let file_type = entry.file_type()?;
|
||||||
|
if file_type.is_dir() {
|
||||||
|
copy_dir_recursive(&src_path, &dst_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dst_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Heuristic for "does this directory look like an
|
/// Heuristic for "does this directory look like an
|
||||||
/// rdbms-playground project?" — used by `Project::open` to
|
/// rdbms-playground project?" — used by `Project::open` to
|
||||||
/// reject obviously-wrong CLI arguments before we try to
|
/// reject obviously-wrong CLI arguments before we try to
|
||||||
|
|||||||
+58
-13
@@ -1,13 +1,19 @@
|
|||||||
//! Generate temp project directory names (P-NAME-1, ADR-0015 §2).
|
//! Generate temp project directory names (P-NAME-1, ADR-0015 §2).
|
||||||
//!
|
//!
|
||||||
//! Output pattern: `<YYYYMMDD>-<word>-<word>-<word>` where the
|
//! Output pattern: `<YYYYMMDD>-[temp]-<word>-<word>-<word>`
|
||||||
//! three words are distinct picks from a small built-in
|
//! where the three words are distinct picks from a small
|
||||||
//! wordlist compiled into the binary. Collisions against
|
//! built-in wordlist compiled into the binary. The literal
|
||||||
//! existing entries in the data root are detected and the
|
//! `[temp]` segment is what marks a directory as an
|
||||||
//! slug is regenerated; we cap retries at a generous number to
|
//! auto-named temporary project — `validate_user_name` rejects
|
||||||
//! turn the theoretical never-give-up loop into a clean
|
//! `[` and `]` for user-supplied names, so the marker can
|
||||||
//! failure if something is profoundly wrong (e.g. wordlist
|
//! never collide with a saved project's directory.
|
||||||
//! inadvertently truncated to a handful of items).
|
//!
|
||||||
|
//! Collisions against existing entries in the data root are
|
||||||
|
//! detected and the slug is regenerated; we cap retries at a
|
||||||
|
//! generous number to turn the theoretical never-give-up loop
|
||||||
|
//! into a clean failure if something is profoundly wrong
|
||||||
|
//! (e.g. wordlist inadvertently truncated to a handful of
|
||||||
|
//! items).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -58,7 +64,7 @@ pub fn generate_temp_name<R: Rng + ?Sized>(
|
|||||||
for _ in 0..MAX_COLLISION_RETRIES {
|
for _ in 0..MAX_COLLISION_RETRIES {
|
||||||
let date = today();
|
let date = today();
|
||||||
let slug = three_distinct_words(rng, &pool);
|
let slug = three_distinct_words(rng, &pool);
|
||||||
let candidate = format!("{date}-{slug}");
|
let candidate = format!("{date}-{TEMP_MARKER}-{slug}");
|
||||||
if !parent_dir.join(&candidate).exists() {
|
if !parent_dir.join(&candidate).exists() {
|
||||||
return Ok(candidate);
|
return Ok(candidate);
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,14 @@ pub fn generate_temp_name<R: Rng + ?Sized>(
|
|||||||
Err(NamingError::TooManyCollisions(MAX_COLLISION_RETRIES))
|
Err(NamingError::TooManyCollisions(MAX_COLLISION_RETRIES))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Literal segment marking a directory as a temp project.
|
||||||
|
///
|
||||||
|
/// Distinguishes auto-named temp projects from user-named
|
||||||
|
/// ones in the directory listing. Brackets are reserved
|
||||||
|
/// (rejected by `validate_user_name`) so this never collides
|
||||||
|
/// with a saved project's name.
|
||||||
|
pub const TEMP_MARKER: &str = "[temp]";
|
||||||
|
|
||||||
/// Pick three distinct words from the pool and join them with
|
/// Pick three distinct words from the pool and join them with
|
||||||
/// `-`. Uses `choose_multiple` so the picks are always distinct
|
/// `-`. Uses `choose_multiple` so the picks are always distinct
|
||||||
/// without needing manual deduplication.
|
/// without needing manual deduplication.
|
||||||
@@ -114,6 +128,19 @@ const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) {
|
|||||||
(y as u32, m as u32, d as u32)
|
(y as u32, m as u32, d as u32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is this an auto-named temp project directory?
|
||||||
|
///
|
||||||
|
/// We look for the literal `[temp]` segment in the
|
||||||
|
/// dash-separated dirname. Because brackets are reserved
|
||||||
|
/// (rejected by `validate_user_name`), this is unambiguous: a
|
||||||
|
/// user-named project can never produce a directory whose
|
||||||
|
/// name contains `[temp]`. Dir-name-only check keeps the
|
||||||
|
/// load picker fast — no YAML parse needed per project.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_temp_dirname(dirname: &str) -> bool {
|
||||||
|
dirname.split('-').any(|seg| seg == TEMP_MARKER)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate a user-supplied project directory name.
|
/// Validate a user-supplied project directory name.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(())` if the name is acceptable, or an error
|
/// Returns `Ok(())` if the name is acceptable, or an error
|
||||||
@@ -180,11 +207,15 @@ mod tests {
|
|||||||
let tmp = tempdir();
|
let tmp = tempdir();
|
||||||
let mut rng = StdRng::seed_from_u64(42);
|
let mut rng = StdRng::seed_from_u64(42);
|
||||||
let name = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap();
|
let name = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap();
|
||||||
assert!(name.starts_with("20260507-"), "got: {name}");
|
assert!(name.starts_with("20260507-[temp]-"), "got: {name}");
|
||||||
let parts: Vec<&str> = name.splitn(4, '-').collect();
|
// After the date and the [temp] marker, three distinct
|
||||||
assert_eq!(parts.len(), 4, "expected date + 3 words, got: {name}");
|
// word segments separated by `-`.
|
||||||
let words_in_name: std::collections::HashSet<_> = parts[1..].iter().collect();
|
let segments: Vec<&str> = name.split('-').collect();
|
||||||
|
assert_eq!(segments[0], "20260507");
|
||||||
|
assert_eq!(segments[1], "[temp]");
|
||||||
|
let words_in_name: std::collections::HashSet<_> = segments[2..].iter().collect();
|
||||||
assert_eq!(words_in_name.len(), 3, "words must be distinct: {name}");
|
assert_eq!(words_in_name.len(), 3, "words must be distinct: {name}");
|
||||||
|
assert!(is_temp_dirname(&name));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -221,6 +252,20 @@ mod tests {
|
|||||||
assert!(s.chars().all(|c| c.is_ascii_digit()), "today_local: {s}");
|
assert!(s.chars().all(|c| c.is_ascii_digit()), "today_local: {s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_temp_dirname_examples() {
|
||||||
|
// Generated form.
|
||||||
|
assert!(is_temp_dirname("20260507-[temp]-water-buffalo-skating"));
|
||||||
|
assert!(is_temp_dirname("19991231-[temp]-amber-cosmic-flying"));
|
||||||
|
|
||||||
|
// Named projects: cannot contain `[temp]` because
|
||||||
|
// validate_user_name rejects brackets.
|
||||||
|
assert!(!is_temp_dirname("MyOrders"));
|
||||||
|
assert!(!is_temp_dirname("term_planner"));
|
||||||
|
assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker
|
||||||
|
assert!(!is_temp_dirname("temp-project")); // no brackets
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_user_name() {
|
fn validates_user_name() {
|
||||||
assert!(validate_user_name("MyProject").is_ok());
|
assert!(validate_user_name("MyProject").is_ok());
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn prettify(dirname: &str) -> String {
|
pub fn prettify(dirname: &str) -> String {
|
||||||
let trimmed = strip_date_prefix(dirname);
|
let trimmed = strip_date_prefix(dirname);
|
||||||
|
let trimmed = strip_temp_marker(trimmed);
|
||||||
let words = split_into_words(trimmed);
|
let words = split_into_words(trimmed);
|
||||||
words
|
words
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -29,6 +30,14 @@ pub fn prettify(dirname: &str) -> String {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip a leading `[temp]-` segment if present. Temp project
|
||||||
|
/// directory names look like `20260507-[temp]-water-buffalo-skating`
|
||||||
|
/// after `strip_date_prefix`, this removes the marker so the
|
||||||
|
/// display name reduces to just the three random words.
|
||||||
|
fn strip_temp_marker(s: &str) -> &str {
|
||||||
|
s.strip_prefix("[temp]-").unwrap_or(s)
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip a leading `YYYYMMDD-` if present. Eight ASCII digits
|
/// Strip a leading `YYYYMMDD-` if present. Eight ASCII digits
|
||||||
/// followed by a single `-` are required; anything else is
|
/// followed by a single `-` are required; anything else is
|
||||||
/// returned unchanged.
|
/// returned unchanged.
|
||||||
@@ -123,6 +132,14 @@ mod tests {
|
|||||||
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
|
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_date_and_temp_marker() {
|
||||||
|
assert_eq!(
|
||||||
|
prettify("20260507-[temp]-water-buffalo-skating"),
|
||||||
|
"Water Buffalo Skating",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handles_pascal_case() {
|
fn handles_pascal_case() {
|
||||||
assert_eq!(prettify("MyOrders"), "My Orders");
|
assert_eq!(prettify("MyOrders"), "My Orders");
|
||||||
|
|||||||
+259
-14
@@ -32,7 +32,10 @@ use crate::db::{
|
|||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
use crate::project::open_or_create;
|
use crate::project::{
|
||||||
|
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
|
||||||
|
resolve_data_root,
|
||||||
|
};
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
@@ -42,11 +45,18 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
|
|||||||
/// Run the application until a `Quit` action is enacted or the
|
/// Run the application until a `Quit` action is enacted or the
|
||||||
/// terminal closes.
|
/// terminal closes.
|
||||||
pub async fn run(args: Args) -> Result<()> {
|
pub async fn run(args: Args) -> Result<()> {
|
||||||
let project = open_or_create(args.project_path.as_deref(), args.data_dir.as_deref())
|
// Resolve data root explicitly so run_loop can refer back
|
||||||
|
// to it for `new` (creates a temp) and `load` (lists
|
||||||
|
// projects). We can't easily recover this from the
|
||||||
|
// Project alone, so we keep it ourselves.
|
||||||
|
let data_root = resolve_data_root(args.data_dir.as_deref())
|
||||||
|
.context("resolve data root")?;
|
||||||
|
let project = open_or_create(args.project_path.as_deref(), Some(data_root.as_path()))
|
||||||
.context("open or create project")?;
|
.context("open or create project")?;
|
||||||
let db_path = project.db_path();
|
let db_path = project.db_path();
|
||||||
let display_name = project.display_name().to_string();
|
let display_name = project.display_name().to_string();
|
||||||
let project_path = project.path().to_path_buf();
|
let project_path = project.path().to_path_buf();
|
||||||
|
let project_is_temp = matches!(project.kind(), ProjectKind::Temp);
|
||||||
let persistence = crate::persistence::Persistence::new(project_path.clone());
|
let persistence = crate::persistence::Persistence::new(project_path.clone());
|
||||||
// Capture whether the .db file existed BEFORE we open it —
|
// Capture whether the .db file existed BEFORE we open it —
|
||||||
// sqlite creates it on connect, so this is the only honest
|
// sqlite creates it on connect, so this is the only honest
|
||||||
@@ -78,18 +88,19 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
let result = run_loop(
|
let result = run_loop(
|
||||||
&mut terminal,
|
&mut terminal,
|
||||||
args.theme,
|
args.theme,
|
||||||
database,
|
Session {
|
||||||
|
project: Some(project),
|
||||||
|
database: Some(database),
|
||||||
|
data_root,
|
||||||
|
},
|
||||||
display_name,
|
display_name,
|
||||||
project_path,
|
project_is_temp,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||||
// Teardown failures should not mask the primary error.
|
// Teardown failures should not mask the primary error.
|
||||||
warn!(error = %e, "terminal teardown failed");
|
warn!(error = %e, "terminal teardown failed");
|
||||||
}
|
}
|
||||||
// `project` (and the lock it holds) is dropped here, releasing
|
|
||||||
// the lock file *after* the terminal has been restored.
|
|
||||||
drop(project);
|
|
||||||
|
|
||||||
// ADR-0015 §8: a fatal persistence failure makes its
|
// ADR-0015 §8: a fatal persistence failure makes its
|
||||||
// banner visible above the shell prompt by writing to
|
// banner visible above the shell prompt by writing to
|
||||||
@@ -100,24 +111,57 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
result.map(|_| ())
|
result.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mutable state owned by `run_loop` that survives project
|
||||||
|
/// switches: the live `Project` (with its lock), the live
|
||||||
|
/// `Database` (with its worker), and the active data root
|
||||||
|
/// for resolving relative paths in `save as` / `new` / load
|
||||||
|
/// picker listings.
|
||||||
|
///
|
||||||
|
/// `project` and `database` are wrapped in `Option` so a
|
||||||
|
/// project switch can `take()` the old (dropping its lock
|
||||||
|
/// and worker) before opening the new — required for the
|
||||||
|
/// "switch to my own current project" case where the new
|
||||||
|
/// open would otherwise see a self-held lock.
|
||||||
|
struct Session {
|
||||||
|
project: Option<Project>,
|
||||||
|
database: Option<Database>,
|
||||||
|
data_root: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
const fn project(&self) -> &Project {
|
||||||
|
match self.project.as_ref() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => panic!("project always set during run_loop"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fn database(&self) -> &Database {
|
||||||
|
match self.database.as_ref() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => panic!("database always set during run_loop"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn run_loop(
|
async fn run_loop(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
database: Database,
|
mut session: Session,
|
||||||
project_display_name: String,
|
project_display_name: String,
|
||||||
project_path: std::path::PathBuf,
|
project_is_temp: bool,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.project_name = Some(project_display_name);
|
app.project_name = Some(project_display_name);
|
||||||
|
app.project_is_temp = project_is_temp;
|
||||||
|
|
||||||
// Seed the table list with whatever the database currently
|
// Seed the table list with whatever the database currently
|
||||||
// shows. For a fresh in-memory DB this is empty, but doing
|
// shows. For a fresh in-memory DB this is empty, but doing
|
||||||
// it explicitly means file-backed databases (track 2) will
|
// it explicitly means file-backed databases (track 2) will
|
||||||
// show their tables on launch without changes here.
|
// show their tables on launch without changes here.
|
||||||
seed_initial_tables(&database, &event_tx).await;
|
seed_initial_tables(session.database(), &event_tx).await;
|
||||||
|
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| ui::render(&mut app, &theme, f))
|
.draw(|f| ui::render(&mut app, &theme, f))
|
||||||
@@ -134,19 +178,67 @@ async fn run_loop(
|
|||||||
should_quit = true;
|
should_quit = true;
|
||||||
}
|
}
|
||||||
Action::ExecuteDsl { command, source } => {
|
Action::ExecuteDsl { command, source } => {
|
||||||
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source);
|
spawn_dsl_dispatch(
|
||||||
|
session.database().clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
command,
|
||||||
|
source,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Action::PrepareRebuild => {
|
Action::PrepareRebuild => {
|
||||||
spawn_prepare_rebuild(project_path.clone(), event_tx.clone());
|
spawn_prepare_rebuild(
|
||||||
|
session.project().path().to_path_buf(),
|
||||||
|
event_tx.clone(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Action::Rebuild { source } => {
|
Action::Rebuild { source } => {
|
||||||
spawn_rebuild(
|
spawn_rebuild(
|
||||||
database.clone(),
|
session.database().clone(),
|
||||||
project_path.clone(),
|
session.project().path().to_path_buf(),
|
||||||
event_tx.clone(),
|
event_tx.clone(),
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Action::OpenLoadPicker => {
|
||||||
|
let entries: Vec<crate::app::LoadPickerEntry> =
|
||||||
|
list_projects(&session.data_root)
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| crate::app::LoadPickerEntry {
|
||||||
|
display_name: p.display_name,
|
||||||
|
modified: p.modified,
|
||||||
|
path: p.path,
|
||||||
|
is_temp: matches!(p.kind, ProjectKind::Temp),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = event_tx.send(AppEvent::LoadPickerReady { entries }).await;
|
||||||
|
}
|
||||||
|
Action::LoadProject { path, source } => {
|
||||||
|
handle_project_switch(
|
||||||
|
&mut session,
|
||||||
|
SwitchRequest::Load { path },
|
||||||
|
source,
|
||||||
|
&event_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Action::SaveAs { target, source } => {
|
||||||
|
handle_project_switch(
|
||||||
|
&mut session,
|
||||||
|
SwitchRequest::SaveAs { target },
|
||||||
|
source,
|
||||||
|
&event_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Action::NewProject { source } => {
|
||||||
|
handle_project_switch(
|
||||||
|
&mut session,
|
||||||
|
SwitchRequest::NewTemp,
|
||||||
|
source,
|
||||||
|
&event_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
terminal
|
terminal
|
||||||
@@ -163,6 +255,159 @@ async fn run_loop(
|
|||||||
Ok(app.fatal_message.clone())
|
Ok(app.fatal_message.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What kind of project switch the user requested.
|
||||||
|
enum SwitchRequest {
|
||||||
|
/// `load` (picker or browse-to-path) — open an existing
|
||||||
|
/// project at `path`.
|
||||||
|
Load { path: std::path::PathBuf },
|
||||||
|
/// `save as` — copy the current project to a new
|
||||||
|
/// location, then open that copy. `target` is a name
|
||||||
|
/// (resolved under `<data-root>/projects/`) or an
|
||||||
|
/// absolute path.
|
||||||
|
SaveAs { target: String },
|
||||||
|
/// `new` — close current, create a fresh auto-named temp.
|
||||||
|
NewTemp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common project-switch path. Drops the current project +
|
||||||
|
/// database (releasing the lock and stopping the worker),
|
||||||
|
/// opens the new one, runs a rebuild if the .db is missing,
|
||||||
|
/// appends history.log, and sends a `ProjectSwitched` event
|
||||||
|
/// so App refreshes its display.
|
||||||
|
///
|
||||||
|
/// Errors are surfaced as `ProjectSwitchFailed` (non-fatal):
|
||||||
|
/// the current project remains active.
|
||||||
|
async fn handle_project_switch(
|
||||||
|
session: &mut Session,
|
||||||
|
req: SwitchRequest,
|
||||||
|
source: String,
|
||||||
|
event_tx: &mpsc::Sender<AppEvent>,
|
||||||
|
) {
|
||||||
|
match perform_switch(session, req, source).await {
|
||||||
|
Ok((display_name, is_temp)) => {
|
||||||
|
let _ = event_tx
|
||||||
|
.send(AppEvent::ProjectSwitched {
|
||||||
|
display_name,
|
||||||
|
is_temp,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if let Ok(tables) = session.database().list_tables().await {
|
||||||
|
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = event_tx
|
||||||
|
.send(AppEvent::ProjectSwitchFailed { error: e })
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn perform_switch(
|
||||||
|
session: &mut Session,
|
||||||
|
req: SwitchRequest,
|
||||||
|
source: String,
|
||||||
|
) -> Result<(String, bool), String> {
|
||||||
|
use crate::persistence::Persistence;
|
||||||
|
|
||||||
|
// For SaveAs we need a resolved target path up front
|
||||||
|
// (so the existence check happens before we drop the
|
||||||
|
// current project). For NewTemp we'll let create_temp
|
||||||
|
// pick the path. For Load it's the user-supplied path.
|
||||||
|
let resolved_target: Option<std::path::PathBuf> = match &req {
|
||||||
|
SwitchRequest::Load { path } => {
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(format!("path `{}` does not exist", path.display()));
|
||||||
|
}
|
||||||
|
Some(path.clone())
|
||||||
|
}
|
||||||
|
SwitchRequest::SaveAs { target } => {
|
||||||
|
let p = resolve_save_target(target, &session.data_root);
|
||||||
|
if p.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"`{}` already exists; pick a different name or remove it first",
|
||||||
|
p.display(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(p)
|
||||||
|
}
|
||||||
|
SwitchRequest::NewTemp => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For SaveAs: copy current project to the target while
|
||||||
|
// the source is still on disk (auto-save guarantees its
|
||||||
|
// state matches the in-memory db).
|
||||||
|
if let SwitchRequest::SaveAs { .. } = &req {
|
||||||
|
let src = session.project().path().to_path_buf();
|
||||||
|
let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
|
||||||
|
copy_project(&src, dst).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop current project + database BEFORE opening the new
|
||||||
|
// ones, releasing the old lock and stopping the old
|
||||||
|
// worker. Required for the "load my own current project"
|
||||||
|
// case (otherwise the new open would see a self-held
|
||||||
|
// lock on this PID).
|
||||||
|
let _ = session.database.take();
|
||||||
|
let _ = session.project.take();
|
||||||
|
|
||||||
|
// Open the destination project.
|
||||||
|
let new_project = match &req {
|
||||||
|
SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => {
|
||||||
|
let path = resolved_target.expect("Load/SaveAs have resolved target");
|
||||||
|
Project::open(&path).map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
SwitchRequest::NewTemp => {
|
||||||
|
Project::create_temp(&session.data_root).map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_path = new_project.path().to_path_buf();
|
||||||
|
|
||||||
|
// Open the new database (rebuild from text if .db is
|
||||||
|
// missing — applies to NewTemp's just-created project,
|
||||||
|
// and to Load when the user opened a project whose .db
|
||||||
|
// had been deleted).
|
||||||
|
let db_path = new_project.db_path();
|
||||||
|
let db_existed = db_path.exists();
|
||||||
|
let persistence = Persistence::new(new_path.clone());
|
||||||
|
let new_database =
|
||||||
|
Database::open_with_persistence(&db_path, persistence).map_err(|e| e.to_string())?;
|
||||||
|
if !db_existed
|
||||||
|
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
|
||||||
|
{
|
||||||
|
return Err(e.friendly_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_name = new_project.display_name().to_string();
|
||||||
|
let is_temp = matches!(new_project.kind(), ProjectKind::Temp);
|
||||||
|
|
||||||
|
session.project = Some(new_project);
|
||||||
|
session.database = Some(new_database);
|
||||||
|
|
||||||
|
// Append the user-issued command to the destination's
|
||||||
|
// history.log. The worker's persistence is wired but not
|
||||||
|
// directly addressable from here, so we use a fresh
|
||||||
|
// Persistence handle for this single line.
|
||||||
|
let _ = Persistence::new(new_path).append_history(&source);
|
||||||
|
|
||||||
|
Ok((display_name, is_temp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a `save as` target path against the data root.
|
||||||
|
///
|
||||||
|
/// Absolute paths pass through; relative paths join under
|
||||||
|
/// `<data-root>/projects/` per the user's stated preference
|
||||||
|
/// in ADR-0015 §1 ("named projects right alongside the temp
|
||||||
|
/// ones is the easiest workflow").
|
||||||
|
fn resolve_save_target(target: &str, data_root: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let p = std::path::Path::new(target);
|
||||||
|
if p.is_absolute() {
|
||||||
|
p.to_path_buf()
|
||||||
|
} else {
|
||||||
|
projects_dir(data_root).join(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
|
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
|
||||||
match database.list_tables().await {
|
match database.list_tables().await {
|
||||||
Ok(tables) => {
|
Ok(tables) => {
|
||||||
|
|||||||
@@ -61,9 +61,173 @@ fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>,
|
|||||||
use crate::app::Modal;
|
use crate::app::Modal;
|
||||||
match modal {
|
match modal {
|
||||||
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
|
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
|
||||||
|
Modal::PathEntry(m) => render_path_entry(m, theme, frame, area),
|
||||||
|
Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_path_entry(
|
||||||
|
m: &crate::app::PathEntryModal,
|
||||||
|
theme: &Theme,
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
area: Rect,
|
||||||
|
) {
|
||||||
|
let dialog_w = area.width.clamp(20, 70);
|
||||||
|
let inner_w = dialog_w.saturating_sub(4) as usize;
|
||||||
|
let prompt_lines = wrap_lines(&m.prompt, inner_w);
|
||||||
|
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
|
||||||
|
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
|
||||||
|
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||||||
|
let dialog_area = Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: dialog_w,
|
||||||
|
height: dialog_h,
|
||||||
|
};
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||||
|
|
||||||
|
let title_style = Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme.fg))
|
||||||
|
.title(Line::from(vec![Span::styled(
|
||||||
|
format!(" {} ", m.title),
|
||||||
|
title_style,
|
||||||
|
)]))
|
||||||
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
|
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
for line in prompt_lines {
|
||||||
|
text_lines.push(Line::from(line));
|
||||||
|
}
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
let cursor_marker = "█";
|
||||||
|
let display_input = if m.cursor == m.input.len() {
|
||||||
|
format!("{}{cursor_marker}", m.input)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}{cursor_marker}{}",
|
||||||
|
&m.input[..m.cursor],
|
||||||
|
&m.input[m.cursor..]
|
||||||
|
)
|
||||||
|
};
|
||||||
|
text_lines.push(Line::from(format!("> {display_input}")));
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
text_lines.push(Line::from(vec![
|
||||||
|
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" confirm "),
|
||||||
|
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||||||
|
Span::styled(" cancel", Style::default().fg(theme.muted)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(text_lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_load_picker(
|
||||||
|
m: &crate::app::LoadPickerModal,
|
||||||
|
theme: &Theme,
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
area: Rect,
|
||||||
|
) {
|
||||||
|
use crate::app::LoadPickerSubMode;
|
||||||
|
let dialog_w = area.width.clamp(20, 70);
|
||||||
|
let dialog_h = area.height.clamp(10, 20);
|
||||||
|
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||||||
|
let dialog_area = Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: dialog_w,
|
||||||
|
height: dialog_h,
|
||||||
|
};
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||||||
|
|
||||||
|
let title_style = Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme.fg))
|
||||||
|
.title(Line::from(vec![Span::styled(" Load project ", title_style)]))
|
||||||
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
|
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
|
||||||
|
match &m.sub_mode {
|
||||||
|
LoadPickerSubMode::List => {
|
||||||
|
if m.entries.is_empty() {
|
||||||
|
text_lines.push(Line::from("(no projects in data directory)"));
|
||||||
|
} else {
|
||||||
|
for (i, entry) in m.entries.iter().enumerate() {
|
||||||
|
let marker = if i == m.selected { "›" } else { " " };
|
||||||
|
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
|
||||||
|
let style = if i == m.selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme.fg)
|
||||||
|
};
|
||||||
|
let line = format!(
|
||||||
|
" {marker} {temp_tag}{name} {modified}",
|
||||||
|
name = entry.display_name,
|
||||||
|
modified = entry.modified,
|
||||||
|
);
|
||||||
|
text_lines.push(Line::from(Span::styled(line, style)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
text_lines.push(Line::from(vec![
|
||||||
|
Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" select "),
|
||||||
|
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" load "),
|
||||||
|
Span::styled("b", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" browse path "),
|
||||||
|
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||||||
|
Span::styled(" cancel", Style::default().fg(theme.muted)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
LoadPickerSubMode::PathEntry { input, cursor } => {
|
||||||
|
text_lines.push(Line::from("Path to project directory:"));
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
let cursor_marker = "█";
|
||||||
|
let display_input = if *cursor == input.len() {
|
||||||
|
format!("{input}{cursor_marker}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}{cursor_marker}{}",
|
||||||
|
&input[..*cursor],
|
||||||
|
&input[*cursor..]
|
||||||
|
)
|
||||||
|
};
|
||||||
|
text_lines.push(Line::from(format!("> {display_input}")));
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
text_lines.push(Line::from(vec![
|
||||||
|
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" load "),
|
||||||
|
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||||||
|
Span::styled(" back to list", Style::default().fg(theme.muted)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(text_lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
|
/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
|
||||||
/// hint at the bottom. Sized at min(60 cols, area.width-4)
|
/// hint at the bottom. Sized at min(60 cols, area.width-4)
|
||||||
/// wide and tall enough to fit the wrapped body plus 4 rows
|
/// wide and tall enough to fit the wrapped body plus 4 rows
|
||||||
@@ -169,10 +333,17 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R
|
|||||||
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
|
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
|
||||||
|
|
||||||
let display = app.project_name.as_deref().unwrap_or("(no project)");
|
let display = app.project_name.as_deref().unwrap_or("(no project)");
|
||||||
let line = Line::from(vec![
|
let mut spans: Vec<Span<'_>> = vec![Span::styled("Project: ", label_style)];
|
||||||
Span::styled("Project: ", label_style),
|
if app.project_is_temp {
|
||||||
Span::styled(display.to_string(), value_style),
|
spans.push(Span::styled(
|
||||||
]);
|
"[TEMP] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.muted)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(display.to_string(), value_style));
|
||||||
|
let line = Line::from(spans);
|
||||||
let paragraph = Paragraph::new(line).style(bar_style);
|
let paragraph = Paragraph::new(line).style(bar_style);
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
//! Iteration-4b integration tests: `save` / `save as` /
|
||||||
|
//! `new` / `load` (ADR-0015 §11) and the modal infrastructure
|
||||||
|
//! that hosts their dialogs.
|
||||||
|
//!
|
||||||
|
//! Modal flows are tested at the App layer (synthetic events).
|
||||||
|
//! Filesystem effects (recursive copy, project switching at
|
||||||
|
//! runtime) are tested through the public `project` and
|
||||||
|
//! `runtime` helpers without booting a Tokio loop.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
use rdbms_playground::action::Action;
|
||||||
|
use rdbms_playground::app::{
|
||||||
|
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
|
||||||
|
PathEntryPurpose,
|
||||||
|
};
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::project::{self, Project, ProjectKind, copy_project};
|
||||||
|
|
||||||
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
|
AppEvent::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: crossterm::event::KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(app: &mut App, s: &str) {
|
||||||
|
for c in s.chars() {
|
||||||
|
app.update(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit(app: &mut App) -> Vec<Action> {
|
||||||
|
app.update(key(KeyCode::Enter))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_on_temp_opens_path_entry_modal() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_is_temp = true;
|
||||||
|
type_str(&mut app, "save");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
match app.modal.as_ref() {
|
||||||
|
Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
|
||||||
|
assert_eq!(*purpose, PathEntryPurpose::SaveAs);
|
||||||
|
assert_eq!(title, "Save");
|
||||||
|
}
|
||||||
|
other => panic!("expected PathEntry modal, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_on_named_project_emits_hint_and_no_modal() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_is_temp = false;
|
||||||
|
type_str(&mut app, "save");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
assert!(app.modal.is_none());
|
||||||
|
let last = app.output.iter().last().expect("an output line");
|
||||||
|
assert!(
|
||||||
|
last.text.contains("already auto-saved"),
|
||||||
|
"got: {}",
|
||||||
|
last.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_as_always_opens_path_entry_modal() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_is_temp = false;
|
||||||
|
type_str(&mut app, "save as");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
match app.modal.as_ref() {
|
||||||
|
Some(Modal::PathEntry(PathEntryModal { purpose, title, .. })) => {
|
||||||
|
assert_eq!(*purpose, PathEntryPurpose::SaveAs);
|
||||||
|
assert_eq!(title, "Save as");
|
||||||
|
}
|
||||||
|
other => panic!("expected PathEntry modal, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_command_emits_action() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "new");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![Action::NewProject {
|
||||||
|
source: "new".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_command_emits_open_picker_action() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "load");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert_eq!(actions, vec![Action::OpenLoadPicker]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_entry_modal_typing_and_enter_emits_save_as() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_is_temp = true;
|
||||||
|
type_str(&mut app, "save as");
|
||||||
|
submit(&mut app);
|
||||||
|
// Type a name and press Enter.
|
||||||
|
type_str(&mut app, "MyOrders");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert_eq!(actions.len(), 1);
|
||||||
|
let Action::SaveAs { target, source } = &actions[0] else {
|
||||||
|
panic!("expected SaveAs, got {:?}", actions[0]);
|
||||||
|
};
|
||||||
|
assert_eq!(target, "MyOrders");
|
||||||
|
assert_eq!(source, "save as");
|
||||||
|
assert!(app.modal.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_entry_modal_esc_cancels() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_is_temp = true;
|
||||||
|
type_str(&mut app, "save as");
|
||||||
|
submit(&mut app);
|
||||||
|
type_str(&mut app, "TheBest");
|
||||||
|
let actions = app.update(key(KeyCode::Esc));
|
||||||
|
assert!(actions.is_empty());
|
||||||
|
assert!(app.modal.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_entry_modal_backspace_edits_input() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_is_temp = true;
|
||||||
|
type_str(&mut app, "save as");
|
||||||
|
submit(&mut app);
|
||||||
|
type_str(&mut app, "abc");
|
||||||
|
app.update(key(KeyCode::Backspace));
|
||||||
|
match app.modal.as_ref() {
|
||||||
|
Some(Modal::PathEntry(m)) => assert_eq!(m.input, "ab"),
|
||||||
|
other => panic!("expected PathEntry, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_picker_renders_entries_and_navigates() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::LoadPickerReady {
|
||||||
|
entries: vec![
|
||||||
|
LoadPickerEntry {
|
||||||
|
display_name: "Newer".to_string(),
|
||||||
|
modified: "2026-05-07 14:30".to_string(),
|
||||||
|
path: std::path::PathBuf::from("/tmp/newer"),
|
||||||
|
is_temp: true,
|
||||||
|
},
|
||||||
|
LoadPickerEntry {
|
||||||
|
display_name: "Older".to_string(),
|
||||||
|
modified: "2026-05-01 09:15".to_string(),
|
||||||
|
path: std::path::PathBuf::from("/tmp/older"),
|
||||||
|
is_temp: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
|
||||||
|
panic!("expected LoadPicker modal");
|
||||||
|
};
|
||||||
|
assert_eq!(picker.selected, 0);
|
||||||
|
assert!(matches!(picker.sub_mode, LoadPickerSubMode::List));
|
||||||
|
|
||||||
|
// Down → select index 1.
|
||||||
|
app.update(key(KeyCode::Down));
|
||||||
|
let Some(Modal::LoadPicker(picker)) = app.modal.clone() else {
|
||||||
|
panic!("expected LoadPicker still active");
|
||||||
|
};
|
||||||
|
assert_eq!(picker.selected, 1);
|
||||||
|
|
||||||
|
// Enter → emit LoadProject for entries[1].
|
||||||
|
let actions = app.update(key(KeyCode::Enter));
|
||||||
|
assert_eq!(actions.len(), 1);
|
||||||
|
let Action::LoadProject { path, source } = &actions[0] else {
|
||||||
|
panic!("expected LoadProject, got {:?}", actions[0]);
|
||||||
|
};
|
||||||
|
assert_eq!(path, std::path::Path::new("/tmp/older"));
|
||||||
|
assert_eq!(source, "load");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_picker_b_enters_path_entry_submode() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::LoadPickerReady {
|
||||||
|
entries: vec![LoadPickerEntry {
|
||||||
|
display_name: "Foo".to_string(),
|
||||||
|
modified: "2026-05-07 14:30".to_string(),
|
||||||
|
path: std::path::PathBuf::from("/tmp/foo"),
|
||||||
|
is_temp: true,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
app.update(key(KeyCode::Char('b')));
|
||||||
|
let Some(Modal::LoadPicker(LoadPickerModal {
|
||||||
|
sub_mode: LoadPickerSubMode::PathEntry { input, .. },
|
||||||
|
..
|
||||||
|
})) = app.modal.clone()
|
||||||
|
else {
|
||||||
|
panic!("expected LoadPicker in PathEntry sub-mode");
|
||||||
|
};
|
||||||
|
assert_eq!(input, "");
|
||||||
|
|
||||||
|
type_str(&mut app, "/some/path");
|
||||||
|
let actions = app.update(key(KeyCode::Enter));
|
||||||
|
let Action::LoadProject { path, .. } = &actions[0] else {
|
||||||
|
panic!("expected LoadProject");
|
||||||
|
};
|
||||||
|
assert_eq!(path, std::path::Path::new("/some/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_data_root_load_picker_opens_in_path_entry_mode() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::LoadPickerReady { entries: vec![] });
|
||||||
|
match app.modal.as_ref() {
|
||||||
|
Some(Modal::LoadPicker(LoadPickerModal {
|
||||||
|
sub_mode: LoadPickerSubMode::PathEntry { .. },
|
||||||
|
..
|
||||||
|
})) => {}
|
||||||
|
other => panic!("expected LoadPicker in PathEntry sub-mode, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_switched_event_updates_state() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.project_name = Some("Old".to_string());
|
||||||
|
app.project_is_temp = true;
|
||||||
|
app.tables = vec!["Stale".to_string()];
|
||||||
|
app.update(AppEvent::ProjectSwitched {
|
||||||
|
display_name: "New Name".to_string(),
|
||||||
|
is_temp: false,
|
||||||
|
});
|
||||||
|
assert_eq!(app.project_name.as_deref(), Some("New Name"));
|
||||||
|
assert!(!app.project_is_temp);
|
||||||
|
assert!(app.tables.is_empty(), "tables should clear on switch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Filesystem-level tests for project::copy_project ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_project_excludes_lock_file() {
|
||||||
|
let data = tempdir();
|
||||||
|
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||||
|
let src = project.path().to_path_buf();
|
||||||
|
|
||||||
|
// Confirm the lock exists in the source.
|
||||||
|
assert!(src.join(".rdbms-playground.lock").exists());
|
||||||
|
|
||||||
|
let dst = data.path().join("CopyDestination");
|
||||||
|
copy_project(&src, &dst).unwrap();
|
||||||
|
|
||||||
|
// Destination has the project skeleton but not the lock.
|
||||||
|
assert!(dst.join("project.yaml").exists());
|
||||||
|
assert!(dst.join("data").is_dir());
|
||||||
|
assert!(!dst.join(".rdbms-playground.lock").exists());
|
||||||
|
|
||||||
|
drop(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_project_refuses_existing_destination() {
|
||||||
|
let data = tempdir();
|
||||||
|
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||||
|
let src = project.path().to_path_buf();
|
||||||
|
let dst = data.path().join("ExistingDir");
|
||||||
|
fs::create_dir(&dst).unwrap();
|
||||||
|
let err = copy_project(&src, &dst).expect_err("must refuse");
|
||||||
|
assert!(format!("{err}").contains("already exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_kind_recovered_from_dirname_on_open() {
|
||||||
|
let data = tempdir();
|
||||||
|
// Create a temp project. Its dirname will contain `[temp]`.
|
||||||
|
let temp = project::open_or_create(None, Some(data.path())).unwrap();
|
||||||
|
let temp_path = temp.path().to_path_buf();
|
||||||
|
drop(temp);
|
||||||
|
|
||||||
|
// Reopen — should still report Temp.
|
||||||
|
let reopened = Project::open(&temp_path).unwrap();
|
||||||
|
assert_eq!(reopened.kind(), ProjectKind::Temp);
|
||||||
|
drop(reopened);
|
||||||
|
|
||||||
|
// Now copy to a named directory.
|
||||||
|
let named_dir = data.path().join("MyProject");
|
||||||
|
copy_project(&temp_path, &named_dir).unwrap();
|
||||||
|
let opened_named = Project::open(&named_dir).unwrap();
|
||||||
|
assert_eq!(opened_named.kind(), ProjectKind::Named);
|
||||||
|
assert_eq!(opened_named.display_name(), "My Project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_projects_sorts_by_mtime() {
|
||||||
|
let data = tempdir();
|
||||||
|
|
||||||
|
// Create two projects in succession; the second has a
|
||||||
|
// newer mtime on its project.yaml.
|
||||||
|
let _first = project::open_or_create(None, Some(data.path())).unwrap();
|
||||||
|
let _first_path = _first.path().to_path_buf();
|
||||||
|
drop(_first);
|
||||||
|
|
||||||
|
// Sleep a hair to ensure different mtimes on filesystems
|
||||||
|
// with second-resolution timestamps.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||||
|
|
||||||
|
let _second = project::open_or_create(None, Some(data.path())).unwrap();
|
||||||
|
let _second_path = _second.path().to_path_buf();
|
||||||
|
drop(_second);
|
||||||
|
|
||||||
|
let listings = project::list_projects(data.path());
|
||||||
|
assert!(listings.len() >= 2, "got {} listings", listings.len());
|
||||||
|
// Newer first.
|
||||||
|
assert!(listings[0].path > listings[1].path || listings[0].modified >= listings[1].modified);
|
||||||
|
for l in &listings {
|
||||||
|
// Both are temp projects (auto-named with [temp]).
|
||||||
|
assert_eq!(l.kind, ProjectKind::Temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user