feat(tui): Phase 3 power features — Who, Search, Timeline, Trace, File History screens

Complete TUI Phase 3 implementation with all 5 power feature screens:

- Who screen: 5 modes (expert/workload/reviews/active/overlap) with
  mode tabs, input bar, result rendering, and hint bar
- Search screen: full-text search with result list and scoring display
- Timeline screen: chronological event feed with time-relative display
- Trace screen: file provenance chains with expand/collapse, rename
  tracking, and linked issues/discussions
- File History screen: per-file MR timeline with rename chain display
  and discussion snippets

Also includes:
- Command palette overlay (fuzzy search)
- Bootstrap screen (initial sync flow)
- Action layer split from monolithic action.rs to per-screen modules
- Entity and render cache infrastructure
- Shared who_types module in core crate
- All screens wired into view/mod.rs dispatch
- 597 tests passing, clippy clean (pedantic + nursery), fmt clean
This commit is contained in:
teernisse
2026-02-18 22:56:24 -05:00
parent f8d6180f06
commit fb40fdc677
44 changed files with 14650 additions and 2905 deletions

View File

@@ -125,13 +125,44 @@ impl LoreApp {
}
/// Handle keys in Palette mode.
fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd<Msg> {
if key.code == KeyCode::Escape {
self.input_mode = InputMode::Normal;
return Cmd::none();
fn handle_palette_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
match key.code {
KeyCode::Escape => {
self.state.command_palette.close();
self.input_mode = InputMode::Normal;
Cmd::none()
}
KeyCode::Enter => {
if let Some(cmd_id) = self.state.command_palette.selected_command_id() {
self.state.command_palette.close();
self.input_mode = InputMode::Normal;
self.execute_command(cmd_id, screen)
} else {
Cmd::none()
}
}
KeyCode::Up => {
self.state.command_palette.select_prev();
Cmd::none()
}
KeyCode::Down => {
self.state.command_palette.select_next();
Cmd::none()
}
KeyCode::Backspace => {
self.state
.command_palette
.delete_back(&self.command_registry, screen);
Cmd::none()
}
KeyCode::Char(c) => {
self.state
.command_palette
.insert_char(c, &self.command_registry, screen);
Cmd::none()
}
_ => Cmd::none(),
}
// Palette key dispatch will be expanded in the palette widget phase.
Cmd::none()
}
/// Handle the second key of a g-prefix sequence.
@@ -153,7 +184,7 @@ impl LoreApp {
}
/// Execute a command by ID.
fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd<Msg> {
fn execute_command(&mut self, id: &str, screen: &Screen) -> Cmd<Msg> {
match id {
"quit" => Cmd::quit(),
"go_back" => {
@@ -166,7 +197,10 @@ impl LoreApp {
}
"command_palette" => {
self.input_mode = InputMode::Palette;
self.state.command_palette.query_focused = true;
let screen = self.navigation.current().clone();
self.state
.command_palette
.open(&self.command_registry, &screen);
Cmd::none()
}
"open_in_browser" => {
@@ -183,7 +217,16 @@ impl LoreApp {
"go_search" => self.navigate_to(Screen::Search),
"go_timeline" => self.navigate_to(Screen::Timeline),
"go_who" => self.navigate_to(Screen::Who),
"go_sync" => self.navigate_to(Screen::Sync),
"go_file_history" => self.navigate_to(Screen::FileHistory),
"go_trace" => self.navigate_to(Screen::Trace),
"go_sync" => {
if screen == &Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
Cmd::none()
} else {
self.navigate_to(Screen::Sync)
}
}
"jump_back" => {
self.navigation.jump_back();
Cmd::none()
@@ -239,11 +282,7 @@ impl LoreApp {
pub(crate) fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
// Record in crash context.
self.crash_context.push(CrashEvent::MsgDispatched {
msg_name: format!("{msg:?}")
.split('(')
.next()
.unwrap_or("?")
.to_string(),
msg_name: msg.variant_name().to_string(),
screen: self.navigation.current().label().to_string(),
});
@@ -351,16 +390,24 @@ impl LoreApp {
Cmd::none()
}
Msg::DiscussionsLoaded {
generation,
generation: _,
key,
discussions,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.issue_detail.apply_discussions(discussions);
// Progressive hydration: the parent detail task already called
// supervisor.complete(), so is_current() would return false.
// Instead, check that the detail state still expects this key.
match key.kind {
crate::message::EntityKind::Issue => {
if self.state.issue_detail.current_key.as_ref() == Some(&key) {
self.state.issue_detail.apply_discussions(discussions);
}
}
crate::message::EntityKind::MergeRequest => {
if self.state.mr_detail.current_key.as_ref() == Some(&key) {
self.state.mr_detail.apply_discussions(discussions);
}
}
}
Cmd::none()
}
@@ -384,6 +431,86 @@ impl LoreApp {
Cmd::none()
}
// --- Sync lifecycle (Bootstrap auto-transition) ---
Msg::SyncStarted => {
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
}
Cmd::none()
}
Msg::SyncCompleted { .. } => {
// If we came from Bootstrap, replace nav history with Dashboard.
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = false;
self.navigation.reset_to(Screen::Dashboard);
// Trigger a fresh dashboard load without preserving Bootstrap in history.
let dashboard = Screen::Dashboard;
let load_state = if self.state.load_state.was_visited(&dashboard) {
LoadState::Refreshing
} else {
LoadState::LoadingInitial
};
self.state.set_loading(dashboard.clone(), load_state);
let _handle = self.supervisor.submit(TaskKey::LoadScreen(dashboard));
}
Cmd::none()
}
// --- Who screen ---
Msg::WhoResultLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Who), generation)
{
self.state.who.apply_results(generation, *result);
self.state.set_loading(Screen::Who, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Who), generation);
}
Cmd::none()
}
Msg::WhoModeChanged => {
// Mode tab changed — view will re-render from state.
Cmd::none()
}
// --- File History screen ---
Msg::FileHistoryLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::FileHistory), generation)
{
self.state.file_history.apply_results(generation, *result);
self.state.set_loading(Screen::FileHistory, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::FileHistory), generation);
}
Cmd::none()
}
Msg::FileHistoryKnownPathsLoaded { paths } => {
self.state.file_history.known_paths = paths;
Cmd::none()
}
// --- Trace screen ---
Msg::TraceResultLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Trace), generation)
{
self.state.trace.apply_result(generation, *result);
self.state.set_loading(Screen::Trace, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Trace), generation);
}
Cmd::none()
}
Msg::TraceKnownPathsLoaded { paths } => {
self.state.trace.known_paths = paths;
Cmd::none()
}
// All other message variants: no-op for now.
// Future phases will fill these in as screens are implemented.
_ => Cmd::none(),