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

@@ -0,0 +1,578 @@
#![allow(dead_code)]
//! File History view — renders per-file MR timeline with rename chains.
//!
//! Layout:
//! ```text
//! +-----------------------------------+
//! | Path: [src/lib.rs_] [R] [M] [D] | <- path input + option toggles
//! | Rename chain: a.rs -> b.rs -> ... | <- shown when renames followed
//! | 5 merge requests across 2 paths | <- summary line
//! +-----------------------------------+
//! | > !42 Fix auth @alice modified ... | <- MR list (selected = >)
//! | !39 Refactor @bob renamed ... |
//! | @carol: "This looks off..." | <- inline discussion (if toggled)
//! +-----------------------------------+
//! | r:renames m:merged d:discussions | <- hint bar
//! +-----------------------------------+
//! ```
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x31); // bg-3
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the File History screen.
pub fn render_file_history(
frame: &mut Frame<'_>,
state: &FileHistoryState,
area: ftui::core::geometry::Rect,
) {
if area.width < 10 || area.height < 3 {
return; // Terminal too small.
}
let x = area.x;
let max_x = area.right();
let width = area.width;
let mut y = area.y;
// --- Path input bar ---
render_path_input(frame, state, x, y, width);
y += 1;
if area.height < 5 {
return;
}
// --- Option toggles indicator ---
render_toggle_indicators(frame, state, x, y, width);
y += 1;
// --- Loading indicator ---
if state.loading {
render_loading(frame, x, y, max_x);
return;
}
let Some(result) = &state.result else {
render_empty_state(frame, x, y, max_x);
return;
};
// --- Rename chain (if followed) ---
if result.renames_followed && result.rename_chain.len() > 1 {
render_rename_chain(frame, &result.rename_chain, x, y, max_x);
y += 1;
}
// --- Summary line ---
render_summary(frame, result, x, y, max_x);
y += 1;
if result.merge_requests.is_empty() {
render_no_results(frame, x, y, max_x);
return;
}
// Reserve 1 row for hint bar at the bottom.
let hint_y = area.bottom().saturating_sub(1);
let list_height = hint_y.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
// --- MR list ---
render_mr_list(frame, result, state, x, y, width, list_height);
// --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x);
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
fn render_path_input(frame: &mut Frame<'_>, state: &FileHistoryState, x: u16, y: u16, width: u16) {
let max_x = x + width;
let label = "Path: ";
let label_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let after_label = frame.print_text_clipped(x, y, label, label_style, max_x);
// Input text.
let input_style = Cell {
fg: if state.path_focused { TEXT } else { TEXT_MUTED },
..Cell::default()
};
let display_text = if state.path_input.is_empty() && !state.path_focused {
"type a file path..."
} else {
&state.path_input
};
frame.print_text_clipped(after_label, y, display_text, input_style, max_x);
// Cursor indicator.
if state.path_focused {
let cursor_x = after_label + state.path_cursor as u16;
if cursor_x < max_x {
let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg
bg: TEXT,
..Cell::default()
};
let ch = state
.path_input
.get(state.path_cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &ch.to_string(), cursor_cell, max_x);
}
}
}
fn render_toggle_indicators(
frame: &mut Frame<'_>,
state: &FileHistoryState,
x: u16,
y: u16,
width: u16,
) {
let max_x = x + width;
let on_style = Cell {
fg: GREEN,
..Cell::default()
};
let off_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let renames_tag = if state.follow_renames {
"[renames:on]"
} else {
"[renames:off]"
};
let merged_tag = if state.merged_only {
"[merged:on]"
} else {
"[merged:off]"
};
let disc_tag = if state.show_discussions {
"[disc:on]"
} else {
"[disc:off]"
};
let renames_style = if state.follow_renames {
on_style
} else {
off_style
};
let merged_style = if state.merged_only {
on_style
} else {
off_style
};
let disc_style = if state.show_discussions {
on_style
} else {
off_style
};
let after_r = frame.print_text_clipped(x + 1, y, renames_tag, renames_style, max_x);
let after_m = frame.print_text_clipped(after_r + 1, y, merged_tag, merged_style, max_x);
frame.print_text_clipped(after_m + 1, y, disc_tag, disc_style, max_x);
}
fn render_rename_chain(frame: &mut Frame<'_>, chain: &[String], x: u16, y: u16, max_x: u16) {
let label_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let chain_style = Cell {
fg: CYAN,
..Cell::default()
};
let after_label = frame.print_text_clipped(x + 1, y, "Renames: ", label_style, max_x);
let chain_str = chain.join(" -> ");
frame.print_text_clipped(after_label, y, &chain_str, chain_style, max_x);
}
fn render_summary(frame: &mut Frame<'_>, result: &FileHistoryResult, x: u16, y: u16, max_x: u16) {
let summary = if result.paths_searched > 1 {
format!(
"{} merge request{} across {} paths",
result.total_mrs,
if result.total_mrs == 1 { "" } else { "s" },
result.paths_searched,
)
} else {
format!(
"{} merge request{}",
result.total_mrs,
if result.total_mrs == 1 { "" } else { "s" },
)
};
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
}
fn render_mr_list(
frame: &mut Frame<'_>,
result: &FileHistoryResult,
state: &FileHistoryState,
x: u16,
start_y: u16,
width: u16,
height: usize,
) {
let max_x = x + width;
let offset = state.scroll_offset as usize;
for (i, mr) in result
.merge_requests
.iter()
.skip(offset)
.enumerate()
.take(height)
{
let y = start_y + i as u16;
let row_idx = offset + i;
let selected = row_idx == state.selected_mr_index;
// Selection background.
if selected {
let bg_cell = Cell {
bg: SELECTION_BG,
..Cell::default()
};
for col in x..max_x {
frame.buffer.set(col, y, bg_cell);
}
}
// State icon.
let (icon, icon_color) = match mr.state.as_str() {
"merged" => ("M", GREEN),
"opened" => ("O", YELLOW),
"closed" => ("C", RED),
_ => ("?", TEXT_MUTED),
};
let prefix = if selected { "> " } else { " " };
let sel_bg = if selected { SELECTION_BG } else { BG_SURFACE };
let prefix_style = Cell {
fg: ACCENT,
bg: sel_bg,
..Cell::default()
};
let after_prefix = frame.print_text_clipped(x, y, prefix, prefix_style, max_x);
let icon_style = Cell {
fg: icon_color,
bg: sel_bg,
..Cell::default()
};
let after_icon = frame.print_text_clipped(after_prefix, y, icon, icon_style, max_x);
// !iid
let iid_str = format!(" !{}", mr.iid);
let ref_style = Cell {
fg: ACCENT,
bg: sel_bg,
..Cell::default()
};
let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title (truncated).
let title = truncate_str(&mr.title, 35);
let title_style = Cell {
fg: TEXT,
bg: sel_bg,
..Cell::default()
};
let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x);
// @author + change_type
let meta = format!(
"@{} {}",
truncate_str(&mr.author_username, 12),
mr.change_type
);
let meta_style = Cell {
fg: TEXT_MUTED,
bg: sel_bg,
..Cell::default()
};
frame.print_text_clipped(after_title + 1, y, &meta, meta_style, max_x);
}
// Inline discussion snippets (rendered beneath MRs when toggled on).
// For simplicity, discussions are shown as a separate block after the MR list
// in this initial implementation. Full inline rendering (grouped by MR) is
// a follow-up enhancement.
if state.show_discussions && !result.discussions.is_empty() {
let disc_start_y = start_y + result.merge_requests.len().min(height) as u16;
let remaining = height.saturating_sub(result.merge_requests.len().min(height));
render_discussions(frame, result, x, disc_start_y, max_x, remaining);
}
}
fn render_discussions(
frame: &mut Frame<'_>,
result: &FileHistoryResult,
x: u16,
start_y: u16,
max_x: u16,
max_rows: usize,
) {
if max_rows == 0 {
return;
}
let sep_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, start_y, "-- discussions --", sep_style, max_x);
let disc_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
for (i, disc) in result
.discussions
.iter()
.enumerate()
.take(max_rows.saturating_sub(1))
{
let y = start_y + 1 + i as u16;
let after_author = frame.print_text_clipped(
x + 2,
y,
&format!("@{}: ", disc.author_username),
author_style,
max_x,
);
let snippet = truncate_str(&disc.body_snippet, 60);
frame.print_text_clipped(after_author, y, &snippet, disc_style, max_x);
}
}
fn render_loading(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: ACCENT,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, "Loading file history...", style, max_x);
}
fn render_empty_state(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(
x + 1,
y,
"Enter a file path and press Enter to search.",
style,
max_x,
);
}
fn render_no_results(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, "No MRs found for this file.", style, max_x);
frame.print_text_clipped(
x + 1,
y + 1,
"Hint: Ensure 'lore sync' has fetched MR file changes.",
style,
max_x,
);
}
fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let style = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
// Fill background.
for col in x..max_x {
frame.buffer.set(col, y, style);
}
let hints = "/:path r:renames m:merged d:discussions Enter:open MR q:back";
frame.print_text_clipped(x + 1, y, hints, style, max_x);
}
/// Truncate a string to at most `max_chars` display characters.
fn truncate_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}")
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::state::file_history::{FileHistoryMr, FileHistoryResult, FileHistoryState};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn test_area(w: u16, h: u16) -> ftui::core::geometry::Rect {
ftui::core::geometry::Rect {
x: 0,
y: 0,
width: w,
height: h,
}
}
#[test]
fn test_render_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = FileHistoryState::default();
render_file_history(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_tiny_terminal_noop() {
with_frame!(5, 2, |frame| {
let state = FileHistoryState::default();
render_file_history(&mut frame, &state, test_area(5, 2));
});
}
#[test]
fn test_render_loading() {
with_frame!(80, 24, |frame| {
let state = FileHistoryState {
loading: true,
..FileHistoryState::default()
};
render_file_history(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_with_results() {
with_frame!(100, 30, |frame| {
let state = FileHistoryState {
result: Some(FileHistoryResult {
path: "src/lib.rs".into(),
rename_chain: vec!["src/lib.rs".into()],
renames_followed: false,
merge_requests: vec![
FileHistoryMr {
iid: 42,
title: "Fix authentication flow".into(),
state: "merged".into(),
author_username: "alice".into(),
change_type: "modified".into(),
merged_at_ms: Some(1_700_000_000_000),
updated_at_ms: 1_700_000_000_000,
merge_commit_sha: Some("abc123".into()),
},
FileHistoryMr {
iid: 39,
title: "Refactor module structure".into(),
state: "opened".into(),
author_username: "bob".into(),
change_type: "renamed".into(),
merged_at_ms: None,
updated_at_ms: 1_699_000_000_000,
merge_commit_sha: None,
},
],
discussions: vec![],
total_mrs: 2,
paths_searched: 1,
}),
..FileHistoryState::default()
};
render_file_history(&mut frame, &state, test_area(100, 30));
});
}
#[test]
fn test_render_with_rename_chain() {
with_frame!(80, 24, |frame| {
let state = FileHistoryState {
result: Some(FileHistoryResult {
path: "src/old.rs".into(),
rename_chain: vec!["src/old.rs".into(), "src/new.rs".into()],
renames_followed: true,
merge_requests: vec![],
discussions: vec![],
total_mrs: 0,
paths_searched: 2,
}),
..FileHistoryState::default()
};
render_file_history(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello world", 5), "hell…");
assert_eq!(truncate_str("", 5), "");
}
}