#![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 super::common::truncate_str; use crate::state::file_history::{FileHistoryResult, FileHistoryState}; use crate::text_width::cursor_cell_offset; // --------------------------------------------------------------------------- // 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 + cursor_cell_offset(&state.path_input, state.path_cursor); 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); } // --------------------------------------------------------------------------- // 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), ""); } }