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
+259 -14
View File
@@ -32,7 +32,10 @@ use crate::db::{
};
use crate::dsl::Command;
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::ui;
@@ -42,11 +45,18 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
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")?;
let db_path = project.db_path();
let display_name = project.display_name().to_string();
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());
// Capture whether the .db file existed BEFORE we open it —
// 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(
&mut terminal,
args.theme,
database,
Session {
project: Some(project),
database: Some(database),
data_root,
},
display_name,
project_path,
project_is_temp,
)
.await;
if let Err(e) = teardown_terminal(&mut terminal) {
// Teardown failures should not mask the primary error.
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
// banner visible above the shell prompt by writing to
@@ -100,24 +111,57 @@ pub async fn run(args: Args) -> Result<()> {
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(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
database: Database,
mut session: Session,
project_display_name: String,
project_path: std::path::PathBuf,
project_is_temp: bool,
) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone());
let mut app = App::new();
app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp;
// Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will
// show their tables on launch without changes here.
seed_initial_tables(&database, &event_tx).await;
seed_initial_tables(session.database(), &event_tx).await;
terminal
.draw(|f| ui::render(&mut app, &theme, f))
@@ -134,19 +178,67 @@ async fn run_loop(
should_quit = true;
}
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 => {
spawn_prepare_rebuild(project_path.clone(), event_tx.clone());
spawn_prepare_rebuild(
session.project().path().to_path_buf(),
event_tx.clone(),
);
}
Action::Rebuild { source } => {
spawn_rebuild(
database.clone(),
project_path.clone(),
session.database().clone(),
session.project().path().to_path_buf(),
event_tx.clone(),
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
@@ -163,6 +255,159 @@ async fn run_loop(
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>) {
match database.list_tables().await {
Ok(tables) => {