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:
578
crates/lore-tui/src/view/file_history.rs
Normal file
578
crates/lore-tui/src/view/file_history.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user