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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user