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:
+259
-14
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user