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:
@@ -61,9 +61,173 @@ fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>,
|
||||
use crate::app::Modal;
|
||||
match modal {
|
||||
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
|
||||
/// hint at the bottom. Sized at min(60 cols, area.width-4)
|
||||
/// 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 display = app.project_name.as_deref().unwrap_or("(no project)");
|
||||
let line = Line::from(vec![
|
||||
Span::styled("Project: ", label_style),
|
||||
Span::styled(display.to_string(), value_style),
|
||||
]);
|
||||
let mut spans: Vec<Span<'_>> = vec![Span::styled("Project: ", label_style)];
|
||||
if app.project_is_temp {
|
||||
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);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user