feat(tui): Phase 4 completion + Phase 5 session/lock/text-width

Phase 4 (bd-1df9) — all 5 acceptance criteria met:
- Sync screen with delta ledger (bd-2x2h, bd-y095)
- Doctor screen with health checks (bd-2iqk)
- Stats screen with document counts (bd-2iqk)
- CLI integration: lore tui subcommand (bd-26lp)
- CLI integration: lore sync --tui flag (bd-3l56)

Phase 5 (bd-3h00) — session persistence + instance lock + text width:
- text_width.rs: Unicode-aware measurement, truncation, padding (16 tests)
- instance_lock.rs: Advisory PID lock with stale recovery (6 tests)
- session.rs: Atomic write + CRC32 checksum + quarantine (9 tests)

Closes: bd-26lp, bd-3h00, bd-3l56, bd-1df9, bd-y095
This commit is contained in:
teernisse
2026-02-18 23:40:30 -05:00
parent 418417b0f4
commit 146eb61623
45 changed files with 5216 additions and 207 deletions

View File

@@ -219,6 +219,8 @@ impl LoreApp {
"go_who" => self.navigate_to(Screen::Who),
"go_file_history" => self.navigate_to(Screen::FileHistory),
"go_trace" => self.navigate_to(Screen::Trace),
"go_doctor" => self.navigate_to(Screen::Doctor),
"go_stats" => self.navigate_to(Screen::Stats),
"go_sync" => {
if screen == &Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
@@ -235,6 +237,19 @@ impl LoreApp {
self.navigation.jump_forward();
Cmd::none()
}
"toggle_scope" => {
if self.state.scope_picker.visible {
self.state.scope_picker.close();
Cmd::none()
} else {
// Fetch projects and open picker asynchronously.
Cmd::task(move || {
// The actual DB query runs in the task; for now, open
// immediately with cached projects if available.
Msg::ScopeProjectsLoaded { projects: vec![] }
})
}
}
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
// Screen-specific actions — delegated in future phases.
Cmd::none()
@@ -431,14 +446,37 @@ impl LoreApp {
Cmd::none()
}
// --- Sync lifecycle (Bootstrap auto-transition) ---
// --- Sync lifecycle ---
Msg::SyncStarted => {
self.state.sync.start();
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
}
Cmd::none()
}
Msg::SyncCompleted { .. } => {
Msg::SyncProgress {
stage,
current,
total,
} => {
self.state.sync.update_progress(&stage, current, total);
Cmd::none()
}
Msg::SyncProgressBatch { stage, batch_size } => {
self.state.sync.update_batch(&stage, batch_size);
Cmd::none()
}
Msg::SyncLogLine(line) => {
self.state.sync.add_log_line(line);
Cmd::none()
}
Msg::SyncBackpressureDrop => {
// Silently drop — the coalescer already handles throttling.
Cmd::none()
}
Msg::SyncCompleted { elapsed_ms } => {
self.state.sync.complete(elapsed_ms);
// If we came from Bootstrap, replace nav history with Dashboard.
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = false;
@@ -456,6 +494,18 @@ impl LoreApp {
}
Cmd::none()
}
Msg::SyncCancelled => {
self.state.sync.cancel();
Cmd::none()
}
Msg::SyncFailed(err) => {
self.state.sync.fail(err);
Cmd::none()
}
Msg::SyncStreamStats { bytes, items } => {
self.state.sync.update_stream_stats(bytes, items);
Cmd::none()
}
// --- Who screen ---
Msg::WhoResultLoaded { generation, result } => {
@@ -511,6 +561,56 @@ impl LoreApp {
Cmd::none()
}
// --- Doctor ---
Msg::DoctorLoaded { checks } => {
self.state.doctor.apply_checks(checks);
self.state.set_loading(Screen::Doctor, LoadState::Idle);
Cmd::none()
}
// --- Stats ---
Msg::StatsLoaded { data } => {
self.state.stats.apply_data(data);
self.state.set_loading(Screen::Stats, LoadState::Idle);
Cmd::none()
}
// --- Timeline ---
Msg::TimelineLoaded { generation, events } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Timeline), generation)
{
self.state.timeline.apply_results(generation, events);
self.state.set_loading(Screen::Timeline, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Timeline), generation);
}
Cmd::none()
}
// --- Search ---
Msg::SearchExecuted { generation, results } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Search), generation)
{
self.state.search.apply_results(generation, results);
self.state.set_loading(Screen::Search, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Search), generation);
}
Cmd::none()
}
// --- Scope ---
Msg::ScopeProjectsLoaded { projects } => {
self.state
.scope_picker
.open(projects, &self.state.global_scope);
Cmd::none()
}
// All other message variants: no-op for now.
// Future phases will fill these in as screens are implemented.
_ => Cmd::none(),