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,134 @@
#![allow(dead_code)] // Phase 2.5: consumed by render_screen dispatch
//! Bootstrap screen view.
//!
//! Shown when the database has no entity data. Guides users to run
//! a sync to populate the database.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::bootstrap::BootstrapState;
// Colors (Flexoki palette).
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
/// Render the bootstrap screen.
///
/// Centers a message in the content area, guiding the user to start a sync.
/// When a sync is in progress, shows a "syncing" message instead.
pub fn render_bootstrap(frame: &mut Frame<'_>, state: &BootstrapState, area: Rect) {
if area.width < 10 || area.height < 5 {
return;
}
let center_y = area.y + area.height / 2;
let max_x = area.x.saturating_add(area.width);
// Title.
let title = "No data found";
let title_x = area.x + area.width.saturating_sub(title.len() as u16) / 2;
frame.print_text_clipped(
title_x,
center_y.saturating_sub(2),
title,
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
if state.sync_started {
// Sync in progress.
let msg = "Syncing data from GitLab...";
let msg_x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
frame.print_text_clipped(
msg_x,
center_y,
msg,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
} else {
// Prompt user to start sync.
let msg = "Run sync to get started.";
let msg_x = area.x + area.width.saturating_sub(msg.len() as u16) / 2;
frame.print_text_clipped(
msg_x,
center_y,
msg,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
let hint = "Press 'g' then 's' to start sync, or 'q' to quit.";
let hint_x = area.x + area.width.saturating_sub(hint.len() as u16) / 2;
frame.print_text_clipped(
hint_x,
center_y + 2,
hint,
Cell {
fg: MUTED,
..Cell::default()
},
max_x,
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
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
}};
}
#[test]
fn test_render_bootstrap_no_panic() {
with_frame!(80, 24, |frame| {
let state = BootstrapState::default();
render_bootstrap(&mut frame, &state, Rect::new(0, 1, 80, 22));
});
}
#[test]
fn test_render_bootstrap_sync_started() {
with_frame!(80, 24, |frame| {
let state = BootstrapState {
sync_started: true,
..Default::default()
};
render_bootstrap(&mut frame, &state, Rect::new(0, 1, 80, 22));
});
}
#[test]
fn test_render_bootstrap_tiny_area_noop() {
with_frame!(8, 3, |frame| {
let state = BootstrapState::default();
render_bootstrap(&mut frame, &state, Rect::new(0, 0, 8, 3));
// Should not panic — early return for tiny areas.
});
}
}

View File

@@ -0,0 +1,389 @@
//! Command palette overlay — modal fuzzy-match command picker.
//!
//! Renders a centered modal with a query input at the top and a scrollable
//! list of matching commands below. Keybinding hints are right-aligned.
use ftui::core::geometry::Rect;
use ftui::render::cell::Cell;
use ftui::render::drawing::{BorderChars, Draw};
use ftui::render::frame::Frame;
use crate::state::command_palette::CommandPaletteState;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
fn text_cell_width(text: &str) -> u16 {
text.chars().count().min(u16::MAX as usize) as u16
}
fn cursor_cell_offset(query: &str, cursor: usize) -> u16 {
let mut idx = cursor.min(query.len());
while idx > 0 && !query.is_char_boundary(idx) {
idx -= 1;
}
text_cell_width(&query[..idx])
}
// ---------------------------------------------------------------------------
// render_command_palette
// ---------------------------------------------------------------------------
/// Render the command palette overlay centered on the screen.
///
/// Only renders if `state.is_open()`. The modal is 60% width, 50% height,
/// capped at 60x20.
pub fn render_command_palette(frame: &mut Frame<'_>, state: &CommandPaletteState, area: Rect) {
if !state.is_open() {
return;
}
if area.height < 5 || area.width < 20 {
return;
}
// Modal dimensions: 60% of screen, capped.
let modal_width = (area.width * 3 / 5).clamp(30, 60);
let modal_height = (area.height / 2).clamp(6, 20);
let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_rect = Rect::new(modal_x, modal_y, modal_width, modal_height);
// Clear background.
let bg_cell = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
for y in modal_rect.y..modal_rect.bottom() {
for x in modal_rect.x..modal_rect.right() {
frame.buffer.set(x, y, bg_cell);
}
}
// Border.
let border_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
frame.draw_border(modal_rect, BorderChars::ROUNDED, border_cell);
// Title.
let title = " Command Palette ";
let title_x = modal_x + (modal_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(title_x, modal_y, title, title_cell, modal_rect.right());
// Inner content area (inside border).
let inner = Rect::new(
modal_x + 2,
modal_y + 1,
modal_width.saturating_sub(4),
modal_height.saturating_sub(2),
);
if inner.width < 4 || inner.height < 2 {
return;
}
// --- Query input line ---
let query_y = inner.y;
let prompt = "> ";
let prompt_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
let query_start =
frame.print_text_clipped(inner.x, query_y, prompt, prompt_cell, inner.right());
let query_display = if state.query.is_empty() {
"Type to filter..."
} else {
&state.query
};
let query_fg = if state.query.is_empty() {
TEXT_MUTED
} else {
TEXT
};
let q_cell = Cell {
fg: query_fg,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(query_start, query_y, query_display, q_cell, inner.right());
// Cursor indicator (if query focused and not showing placeholder).
if !state.query.is_empty() {
let cursor_x = query_start.saturating_add(cursor_cell_offset(&state.query, state.cursor));
if cursor_x < inner.right() {
let cursor_cell = Cell {
fg: BG_SURFACE,
bg: TEXT,
..Cell::default()
};
// Draw cursor block. If at end of text, draw a space.
let cursor_char = state
.query
.get(state.cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(
cursor_x,
query_y,
&cursor_char.to_string(),
cursor_cell,
inner.right(),
);
}
}
// --- Separator ---
let sep_y = query_y + 1;
if sep_y >= inner.bottom() {
return;
}
let sep_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
let sep_line = "".repeat(inner.width as usize);
frame.print_text_clipped(inner.x, sep_y, &sep_line, sep_cell, inner.right());
// --- Results list ---
let list_y = sep_y + 1;
let list_height = inner.bottom().saturating_sub(list_y) as usize;
if list_height == 0 {
return;
}
if state.filtered.is_empty() {
let msg = if state.query.is_empty() {
"No commands available"
} else {
"No matching commands"
};
let msg_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(inner.x, list_y, msg, msg_cell, inner.right());
return;
}
// Scroll so the selected item is always visible.
let scroll_offset = if state.selected_index >= list_height {
state.selected_index - list_height + 1
} else {
0
};
let normal_cell = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
let selected_cell = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
let key_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let key_selected_cell = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
for (i, entry) in state
.filtered
.iter()
.skip(scroll_offset)
.enumerate()
.take(list_height)
{
let y = list_y + i as u16;
let is_selected = i + scroll_offset == state.selected_index;
let (label_style, kb_style) = if is_selected {
(selected_cell, key_selected_cell)
} else {
(normal_cell, key_cell)
};
// Fill row background for selected item.
if is_selected {
for x in inner.x..inner.right() {
frame.buffer.set(x, y, selected_cell);
}
}
// Label (left-aligned).
frame.print_text_clipped(inner.x, y, entry.label, label_style, inner.right());
// Keybinding (right-aligned).
if let Some(ref kb) = entry.keybinding {
let kb_width = text_cell_width(kb);
let kb_x = inner.right().saturating_sub(kb_width);
if kb_x > inner.x + text_cell_width(entry.label).saturating_add(1) {
frame.print_text_clipped(kb_x, y, kb, kb_style, inner.right());
}
}
}
// Scroll indicator.
if state.filtered.len() > list_height {
let indicator = format!(
" {}/{} ",
(scroll_offset + list_height).min(state.filtered.len()),
state.filtered.len()
);
let ind_x = modal_rect
.right()
.saturating_sub(indicator.len() as u16 + 1);
let ind_y = modal_rect.bottom().saturating_sub(1);
let ind_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, modal_rect.right());
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use crate::state::command_palette::CommandPaletteState;
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
}};
}
#[test]
fn test_render_palette_closed_is_noop() {
with_frame!(80, 24, |frame| {
let state = CommandPaletteState::default();
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
// No content rendered when palette is closed.
});
}
#[test]
fn test_render_palette_open_no_panic() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
// Should have rendered content in center area.
let has_content = (25..55u16).any(|x| {
(8..16u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(has_content, "Expected palette overlay in center area");
});
}
#[test]
fn test_render_palette_with_query() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.insert_char('q', &registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_palette_unicode_cursor_uses_char_offset() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.insert_char('é', &registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
let area = Rect::new(0, 0, 80, 24);
let modal_width = (area.width * 3 / 5).clamp(30, 60);
let modal_height = (area.height / 2).clamp(6, 20);
let modal_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let modal_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let inner = Rect::new(
modal_x + 2,
modal_y + 1,
modal_width.saturating_sub(4),
modal_height.saturating_sub(2),
);
// Prompt "> " is two cells; one unicode scalar should place cursor at +1.
let query_y = inner.y;
let cursor_x = inner.x + 3;
let cell = frame
.buffer
.get(cursor_x, query_y)
.expect("cursor position must be in bounds");
assert_eq!(cell.bg, TEXT);
});
}
#[test]
fn test_render_palette_with_selection() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
state.select_next();
state.select_next();
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_palette_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
render_command_palette(&mut frame, &state, Rect::new(0, 0, 15, 4));
});
}
#[test]
fn test_render_palette_no_results() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
let mut state = CommandPaletteState::default();
state.open(&registry, &Screen::Dashboard);
for c in "zzzzzz".chars() {
state.insert_char(c, &registry, &Screen::Dashboard);
}
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
}

View File

@@ -181,13 +181,16 @@ pub fn render_cross_refs(
// Spacing
x = frame.print_text_clipped(x, y, " ", badge_style, max_x);
// Entity prefix + label
// Entity prefix + label — derive sigil from entity kind, not ref kind.
let prefix = match cr.kind {
CrossRefKind::ClosingMr | CrossRefKind::MentionedIn => {
format!("!{} ", cr.entity_key.iid)
}
CrossRefKind::RelatedIssue => {
format!("#{} ", cr.entity_key.iid)
CrossRefKind::ClosingMr => format!("!{} ", cr.entity_key.iid),
CrossRefKind::RelatedIssue => format!("#{} ", cr.entity_key.iid),
CrossRefKind::MentionedIn => {
let sigil = match cr.entity_key.kind {
crate::message::EntityKind::MergeRequest => "!",
crate::message::EntityKind::Issue => "#",
};
format!("{sigil}{} ", cr.entity_key.iid)
}
};

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), "");
}
}

View File

@@ -295,7 +295,7 @@ fn render_metadata_row(
if !meta.labels.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let labels_text = meta.labels.join(", ");
let _ = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
cx = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
}
if !meta.assignees.is_empty() {

View File

@@ -6,28 +6,43 @@
//! It composes the layout: breadcrumb bar, screen content area, status
//! bar, and optional overlays (help, error toast).
pub mod bootstrap;
pub mod command_palette;
pub mod common;
pub mod dashboard;
pub mod file_history;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
pub mod search;
pub mod timeline;
pub mod trace;
pub mod who;
use ftui::layout::{Constraint, Flex};
use ftui::render::cell::PackedRgba;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::app::LoreApp;
use crate::message::Screen;
use bootstrap::render_bootstrap;
use command_palette::render_command_palette;
use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
use dashboard::render_dashboard;
use file_history::render_file_history;
use issue_detail::render_issue_detail;
use issue_list::render_issue_list;
use mr_detail::render_mr_detail;
use mr_list::render_mr_list;
use search::render_search;
use timeline::render_timeline;
use trace::render_trace;
use who::render_who;
// ---------------------------------------------------------------------------
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
@@ -41,6 +56,41 @@ const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
fn render_sync_placeholder(frame: &mut Frame<'_>, area: ftui::core::geometry::Rect) {
if area.width < 10 || area.height < 5 {
return;
}
let max_x = area.right();
let center_y = area.y + area.height / 2;
let title = "Sync";
let title_x = area.x + area.width.saturating_sub(title.len() as u16) / 2;
frame.print_text_clipped(
title_x,
center_y.saturating_sub(1),
title,
Cell {
fg: ACCENT,
..Cell::default()
},
max_x,
);
let body = "Run `lore sync` in another terminal.";
let body_x = area.x + area.width.saturating_sub(body.len() as u16) / 2;
frame.print_text_clipped(
body_x,
center_y + 1,
body,
Cell {
fg: TEXT_MUTED,
..Cell::default()
},
max_x,
);
}
// ---------------------------------------------------------------------------
// render_screen
// ---------------------------------------------------------------------------
@@ -91,7 +141,11 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
// Per-screen content dispatch (other screens wired in later phases).
if screen == &Screen::Dashboard {
if screen == &Screen::Bootstrap {
render_bootstrap(frame, &app.state.bootstrap, content_area);
} else if screen == &Screen::Sync {
render_sync_placeholder(frame, content_area);
} else if screen == &Screen::Dashboard {
render_dashboard(frame, &app.state.dashboard, content_area);
} else if screen == &Screen::IssueList {
render_issue_list(frame, &app.state.issue_list, content_area);
@@ -101,6 +155,16 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
render_issue_detail(frame, &app.state.issue_detail, content_area, &*app.clock);
} else if matches!(screen, Screen::MrDetail(_)) {
render_mr_detail(frame, &app.state.mr_detail, content_area, &*app.clock);
} else if screen == &Screen::Search {
render_search(frame, &app.state.search, content_area);
} else if screen == &Screen::Timeline {
render_timeline(frame, &app.state.timeline, content_area, &*app.clock);
} else if screen == &Screen::Who {
render_who(frame, &app.state.who, content_area);
} else if screen == &Screen::FileHistory {
render_file_history(frame, &app.state.file_history, content_area);
} else if screen == &Screen::Trace {
render_trace(frame, &app.state.trace, content_area);
}
// --- Status bar ---
@@ -122,6 +186,9 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
}
// Command palette overlay.
render_command_palette(frame, &app.state.command_palette, bounds);
// Help overlay.
if app.state.show_help {
render_help_overlay(
@@ -199,4 +266,21 @@ mod tests {
render_screen(&mut frame, &app);
});
}
#[test]
fn test_render_screen_sync_has_content() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.navigation.push(Screen::Sync);
render_screen(&mut frame, &app);
let has_content = (20..60u16).any(|x| {
(8..16u16).any(|y| frame.buffer.get(x, y).is_some_and(|cell| !cell.is_empty()))
});
assert!(
has_content,
"Expected sync placeholder content in center area"
);
});
}
}

View File

@@ -203,16 +203,20 @@ fn render_metadata_row(
/// Render tab bar: `[Overview] [Files (3)] [Discussions (2)]`.
fn render_tab_bar(frame: &mut Frame<'_>, state: &MrDetailState, x: u16, y: u16, max_x: u16) -> u16 {
// Use metadata counts before async data loads to avoid showing 0.
let disc_count = if state.discussions_loaded {
state.discussions.len()
} else {
state.metadata.as_ref().map_or(0, |m| m.discussion_count)
};
let tabs = [
(MrTab::Overview, "Overview".to_string()),
(
MrTab::Files,
format!("Files ({})", state.file_changes.len()),
),
(
MrTab::Discussions,
format!("Discussions ({})", state.discussions.len()),
),
(MrTab::Discussions, format!("Discussions ({disc_count})")),
];
let mut cx = x;

View File

@@ -0,0 +1,492 @@
#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch
//! Search screen view — query bar, mode indicator, and results list.
//!
//! Layout:
//! ```text
//! +--[ FTS ]--- Search ──────────────────────+
//! | > query text here_ |
//! +───────────────────────────────────────────+
//! | #42 Fix login bug group/proj |
//! | !99 Add retry logic group/proj |
//! | #10 Update docs other/repo |
//! +───────────────────────────────────────────+
//! | Tab: mode /: focus j/k: nav Enter: go |
//! +───────────────────────────────────────────+
//! ```
use ftui::core::geometry::Rect;
use ftui::render::cell::Cell;
use ftui::render::drawing::Draw;
/// Count display-width columns for a string (char count, not byte count).
fn text_cell_width(text: &str) -> u16 {
text.chars().count().min(u16::MAX as usize) as u16
}
/// Convert a byte-offset cursor position to a display-column offset.
fn cursor_cell_offset(query: &str, cursor: usize) -> u16 {
let mut idx = cursor.min(query.len());
while idx > 0 && !query.is_char_boundary(idx) {
idx -= 1;
}
text_cell_width(&query[..idx])
}
use ftui::render::frame::Frame;
use crate::message::EntityKind;
use crate::state::search::SearchState;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
// ---------------------------------------------------------------------------
// render_search
// ---------------------------------------------------------------------------
/// Render the search screen.
///
/// Composes: mode indicator + query bar (row 0), separator (row 1),
/// results list (fill), and a hint bar at the bottom.
pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
if area.height < 4 || area.width < 20 {
return;
}
let mut y = area.y;
let max_x = area.right();
// -- Mode indicator + query bar ------------------------------------------
y = render_query_bar(frame, state, area.x, y, area.width, max_x);
// -- Separator -----------------------------------------------------------
if y >= area.bottom() {
return;
}
let sep_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
let sep_line = "".repeat(area.width as usize);
frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x);
y += 1;
// -- No-index warning ----------------------------------------------------
if !state.capabilities.has_any_index() {
if y >= area.bottom() {
return;
}
let warn_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(area.x + 1, y, "No search indexes found.", warn_cell, max_x);
y += 1;
if y < area.bottom() {
let hint_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(
area.x + 1,
y,
"Run: lore generate-docs && lore embed",
hint_cell,
max_x,
);
}
return;
}
// -- Results list --------------------------------------------------------
let bottom_hint_row = area.bottom().saturating_sub(1);
let list_bottom = bottom_hint_row;
let list_height = list_bottom.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
if state.results.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
render_result_list(frame, state, area.x, y, area.width, list_height);
}
// -- Bottom hint bar -----------------------------------------------------
if bottom_hint_row < area.bottom() {
render_hint_bar(frame, state, area.x, bottom_hint_row, max_x);
}
}
// ---------------------------------------------------------------------------
// Query bar
// ---------------------------------------------------------------------------
/// Render the mode badge and query input. Returns the next y position.
fn render_query_bar(
frame: &mut Frame<'_>,
state: &SearchState,
x: u16,
y: u16,
width: u16,
max_x: u16,
) -> u16 {
// Mode badge: [ FTS ] or [ Hybrid ] or [ Vec ]
let mode_label = format!("[ {} ]", state.mode.label());
let mode_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
let after_mode = frame.print_text_clipped(x, y, &mode_label, mode_cell, max_x);
// Space separator.
let after_sep = frame.print_text_clipped(
after_mode,
y,
" ",
Cell {
bg: BG_SURFACE,
..Cell::default()
},
max_x,
);
// Prompt.
let prompt = "> ";
let prompt_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
let after_prompt = frame.print_text_clipped(after_sep, y, prompt, prompt_cell, max_x);
// Query text (or placeholder).
let (display_text, text_fg) = if state.query.is_empty() {
("Type to search...", TEXT_MUTED)
} else {
(state.query.as_str(), TEXT)
};
let text_cell = Cell {
fg: text_fg,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x);
// Cursor (only when focused and has query text).
if state.query_focused && !state.query.is_empty() {
let cursor_x = after_prompt + cursor_cell_offset(&state.query, state.cursor);
if cursor_x < max_x {
let cursor_cell = Cell {
fg: BG_SURFACE,
bg: TEXT,
..Cell::default()
};
let cursor_char = state
.query
.get(state.cursor..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x);
}
}
// Loading indicator (right-aligned).
if state.loading {
let loading_text = " searching... ";
let loading_x = (x + width).saturating_sub(loading_text.len() as u16);
let loading_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(loading_x, y, loading_text, loading_cell, max_x);
}
y + 1
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
/// Show a message when there are no results.
fn render_empty_state(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) {
let msg = if state.query.is_empty() {
"Enter a search query above"
} else {
"No results found"
};
let cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(x, y, msg, cell, max_x);
}
// ---------------------------------------------------------------------------
// Result list
// ---------------------------------------------------------------------------
/// Render the scrollable list of search results.
fn render_result_list(
frame: &mut Frame<'_>,
state: &SearchState,
x: u16,
start_y: u16,
width: u16,
list_height: usize,
) {
let max_x = x + width;
// Scroll so selected item is always visible.
let scroll_offset = if state.selected_index >= list_height {
state.selected_index - list_height + 1
} else {
0
};
let normal = Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
};
let selected = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let muted_selected = Cell {
fg: BG_SURFACE,
bg: ACCENT,
..Cell::default()
};
for (i, result) in state
.results
.iter()
.skip(scroll_offset)
.enumerate()
.take(list_height)
{
let y = start_y + i as u16;
let is_selected = i + scroll_offset == state.selected_index;
let (label_style, detail_style) = if is_selected {
(selected, muted_selected)
} else {
(normal, muted)
};
// Fill row background for selected item.
if is_selected {
for col in x..max_x {
frame.buffer.set(col, y, selected);
}
}
// Entity prefix: # for issues, ! for MRs.
let prefix = match result.key.kind {
EntityKind::Issue => "#",
EntityKind::MergeRequest => "!",
};
let iid_str = format!("{}{}", prefix, result.key.iid);
let after_iid = frame.print_text_clipped(x + 1, y, &iid_str, label_style, max_x);
// Title.
let after_title =
frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x);
// Project path (right-aligned).
let path_width = result.project_path.len() as u16 + 2;
let path_x = max_x.saturating_sub(path_width);
if path_x > after_title + 1 {
frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x);
}
}
// Scroll indicator (overlaid on last visible row when results overflow).
if state.results.len() > list_height && list_height > 0 {
let indicator = format!(
" {}/{} ",
(scroll_offset + list_height).min(state.results.len()),
state.results.len()
);
let ind_x = max_x.saturating_sub(indicator.len() as u16);
let ind_y = start_y + list_height.saturating_sub(1) as u16;
let ind_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x);
}
}
// ---------------------------------------------------------------------------
// Hint bar
// ---------------------------------------------------------------------------
/// Render keybinding hints at the bottom of the search screen.
fn render_hint_bar(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) {
let hint_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let hints = if state.query_focused {
"Tab: mode Esc: blur Enter: search"
} else {
"Tab: mode /: focus j/k: nav Enter: open"
};
frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::{EntityKey, SearchResult};
use crate::state::search::{SearchCapabilities, SearchState};
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 fts_caps() -> SearchCapabilities {
SearchCapabilities {
has_fts: true,
has_embeddings: false,
embedding_coverage_pct: 0.0,
}
}
fn sample_results(count: usize) -> Vec<SearchResult> {
(0..count)
.map(|i| SearchResult {
key: EntityKey::issue(1, (i + 1) as i64),
title: format!("Result {}", i + 1),
score: 1.0 - (i as f64 * 0.1),
snippet: "matched text".into(),
project_path: "group/project".into(),
})
.collect()
}
#[test]
fn test_render_search_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = SearchState::default();
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_with_capabilities_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_with_results_no_panic() {
with_frame!(100, 30, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(5);
render_search(&mut frame, &state, Rect::new(0, 0, 100, 30));
});
}
#[test]
fn test_render_search_with_query_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.insert_char('h');
state.insert_char('e');
state.insert_char('l');
state.insert_char('l');
state.insert_char('o');
state.results = sample_results(3);
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_with_selection_no_panic() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(10);
state.select_next();
state.select_next();
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_tiny_terminal_noop() {
with_frame!(15, 3, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
render_search(&mut frame, &state, Rect::new(0, 0, 15, 3));
});
}
#[test]
fn test_render_search_no_indexes_warning() {
with_frame!(80, 24, |frame| {
let state = SearchState::default();
// capabilities are default (no indexes)
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
// Should show "No search indexes found" without panicking.
});
}
#[test]
fn test_render_search_loading_indicator() {
with_frame!(80, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.loading = true;
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
});
}
#[test]
fn test_render_search_scrollable_results() {
with_frame!(80, 10, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(20);
// Select item near the bottom to trigger scrolling.
for _ in 0..15 {
state.select_next();
}
render_search(&mut frame, &state, Rect::new(0, 0, 80, 10));
});
}
}

View File

@@ -0,0 +1,449 @@
#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch
//! Timeline screen view — chronological event stream with color-coded types.
//!
//! Layout:
//! ```text
//! +─── Timeline ──────────────────────────────+
//! | 3h ago #42 Created: Fix login bug |
//! | 2h ago #42 State changed to closed |
//! | 1h ago !99 Label added: backend |
//! | 30m ago !99 Merged |
//! +───────────────────────────────────────────+
//! | j/k: nav Enter: open q: back |
//! +───────────────────────────────────────────+
//! ```
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::message::TimelineEventKind;
use crate::state::timeline::TimelineState;
use crate::view::common::discussion_tree::format_relative_time;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
// ---------------------------------------------------------------------------
// Colors for event kinds (Flexoki palette)
// ---------------------------------------------------------------------------
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // Created
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // StateChanged
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // Closed (via StateChanged)
const PURPLE: PackedRgba = PackedRgba::rgb(0x8B, 0x7E, 0xC8); // Merged
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // Label
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg (dark)
/// Map event kind to its display color.
fn event_color(kind: TimelineEventKind, detail: Option<&str>) -> PackedRgba {
match kind {
TimelineEventKind::Created => GREEN,
TimelineEventKind::StateChanged => {
if detail == Some("closed") {
RED
} else {
YELLOW
}
}
TimelineEventKind::LabelAdded | TimelineEventKind::LabelRemoved => CYAN,
TimelineEventKind::MilestoneSet | TimelineEventKind::MilestoneRemoved => ACCENT,
TimelineEventKind::Merged => PURPLE,
}
}
// ---------------------------------------------------------------------------
// render_timeline
// ---------------------------------------------------------------------------
/// Render the timeline screen.
///
/// Composes: scope header (row 0), separator (row 1),
/// event list (fill), and a hint bar at the bottom.
pub fn render_timeline(
frame: &mut Frame<'_>,
state: &TimelineState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 4 || area.width < 20 {
return;
}
let mut y = area.y;
let max_x = area.right();
// -- Scope header --
let scope_label = match &state.scope {
crate::state::timeline::TimelineScope::All => "All events".to_string(),
crate::state::timeline::TimelineScope::Entity(key) => {
let sigil = match key.kind {
crate::message::EntityKind::Issue => "#",
crate::message::EntityKind::MergeRequest => "!",
};
format!("Entity {sigil}{}", key.iid)
}
crate::state::timeline::TimelineScope::Author(name) => format!("Author: {name}"),
};
let header = format!("Timeline: {scope_label}");
let header_cell = Cell {
fg: ACCENT,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(area.x, y, &header, header_cell, max_x);
y += 1;
// -- Separator --
if y >= area.bottom() {
return;
}
let sep_cell = Cell {
fg: BORDER,
bg: BG_SURFACE,
..Cell::default()
};
let sep_line = "".repeat(area.width as usize);
frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x);
y += 1;
// -- Event list --
let bottom_hint_row = area.bottom().saturating_sub(1);
let list_height = bottom_hint_row.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
if state.events.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
render_event_list(frame, state, area.x, y, area.width, list_height, clock);
}
// -- Hint bar --
if bottom_hint_row < area.bottom() {
render_hint_bar(frame, area.x, bottom_hint_row, max_x);
}
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
fn render_empty_state(frame: &mut Frame<'_>, state: &TimelineState, x: u16, y: u16, max_x: u16) {
let msg = if state.loading {
"Loading timeline..."
} else {
"No timeline events found"
};
let cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(x, y, msg, cell, max_x);
}
// ---------------------------------------------------------------------------
// Event list
// ---------------------------------------------------------------------------
/// Render the scrollable list of timeline events.
fn render_event_list(
frame: &mut Frame<'_>,
state: &TimelineState,
x: u16,
start_y: u16,
width: u16,
list_height: usize,
clock: &dyn Clock,
) {
let max_x = x + width;
// Scroll so selected item is always visible.
let scroll_offset = if state.selected_index >= list_height {
state.selected_index - list_height + 1
} else {
0
};
let selected_cell = Cell {
fg: SELECTED_FG,
bg: ACCENT,
..Cell::default()
};
for (i, event) in state
.events
.iter()
.skip(scroll_offset)
.enumerate()
.take(list_height)
{
let y = start_y + i as u16;
let is_selected = i + scroll_offset == state.selected_index;
let kind_color = event_color(event.event_kind, event.detail.as_deref());
// Fill row background for selected item.
if is_selected {
for col in x..max_x {
frame.buffer.set(col, y, selected_cell);
}
}
let mut cx = x + 1;
// Timestamp gutter (right-aligned in ~10 chars).
let time_str = format_relative_time(event.timestamp_ms, clock);
let time_width = 10u16;
let time_x = cx + time_width.saturating_sub(time_str.len() as u16);
let time_cell = if is_selected {
selected_cell
} else {
Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
}
};
frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_width);
cx += time_width + 1;
// Entity prefix: #42 or !99
let prefix = match event.entity_key.kind {
crate::message::EntityKind::Issue => "#",
crate::message::EntityKind::MergeRequest => "!",
};
let entity_str = format!("{prefix}{}", event.entity_key.iid);
let entity_cell = if is_selected {
selected_cell
} else {
Cell {
fg: kind_color,
bg: BG_SURFACE,
..Cell::default()
}
};
let after_entity = frame.print_text_clipped(cx, y, &entity_str, entity_cell, max_x);
cx = after_entity + 1;
// Event kind badge.
let badge = event.event_kind.label();
let badge_cell = if is_selected {
selected_cell
} else {
Cell {
fg: kind_color,
bg: BG_SURFACE,
..Cell::default()
}
};
let after_badge = frame.print_text_clipped(cx, y, badge, badge_cell, max_x);
cx = after_badge + 1;
// Summary text.
let summary_cell = if is_selected {
selected_cell
} else {
Cell {
fg: TEXT,
bg: BG_SURFACE,
..Cell::default()
}
};
frame.print_text_clipped(cx, y, &event.summary, summary_cell, max_x);
// Actor (right-aligned) if there's room.
if let Some(ref actor) = event.actor {
let actor_str = format!(" {actor} ");
let actor_width = actor_str.len() as u16;
let actor_x = max_x.saturating_sub(actor_width);
if actor_x > cx + 5 {
let actor_cell = if is_selected {
selected_cell
} else {
Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
}
};
frame.print_text_clipped(actor_x, y, &actor_str, actor_cell, max_x);
}
}
}
// Scroll indicator (overlaid on last visible row when events overflow).
if state.events.len() > list_height && list_height > 0 {
let indicator = format!(
" {}/{} ",
(scroll_offset + list_height).min(state.events.len()),
state.events.len()
);
let ind_x = max_x.saturating_sub(indicator.len() as u16);
let ind_y = start_y + list_height.saturating_sub(1) as u16;
let ind_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x);
}
}
// ---------------------------------------------------------------------------
// Hint bar
// ---------------------------------------------------------------------------
fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
let hint_cell = Cell {
fg: TEXT_MUTED,
bg: BG_SURFACE,
..Cell::default()
};
let hints = "j/k: nav Enter: open q: back";
frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::{EntityKey, TimelineEvent, TimelineEventKind};
use crate::state::timeline::TimelineState;
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 sample_event(timestamp_ms: i64, iid: i64, kind: TimelineEventKind) -> TimelineEvent {
TimelineEvent {
timestamp_ms,
entity_key: EntityKey::issue(1, iid),
event_kind: kind,
summary: format!("Event for #{iid}"),
detail: None,
actor: Some("alice".into()),
project_path: "group/project".into(),
}
}
fn test_clock() -> FakeClock {
FakeClock::from_ms(1_700_000_100_000)
}
#[test]
fn test_render_timeline_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = TimelineState::default();
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_timeline_with_events_no_panic() {
with_frame!(100, 30, |frame| {
let state = TimelineState {
events: vec![
sample_event(1_700_000_000_000, 1, TimelineEventKind::Created),
sample_event(1_700_000_050_000, 2, TimelineEventKind::StateChanged),
sample_event(1_700_000_080_000, 3, TimelineEventKind::Merged),
],
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 100, 30), &clock);
});
}
#[test]
fn test_render_timeline_with_selection_no_panic() {
with_frame!(80, 24, |frame| {
let state = TimelineState {
events: vec![
sample_event(1_700_000_000_000, 1, TimelineEventKind::Created),
sample_event(1_700_000_050_000, 2, TimelineEventKind::LabelAdded),
],
selected_index: 1,
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_timeline_tiny_terminal_noop() {
with_frame!(15, 3, |frame| {
let state = TimelineState::default();
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 15, 3), &clock);
});
}
#[test]
fn test_render_timeline_loading_state() {
with_frame!(80, 24, |frame| {
let state = TimelineState {
loading: true,
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_timeline_scrollable_events() {
with_frame!(80, 10, |frame| {
let state = TimelineState {
events: (0..20)
.map(|i| {
sample_event(
1_700_000_000_000 + i * 10_000,
i + 1,
TimelineEventKind::Created,
)
})
.collect(),
selected_index: 15,
..TimelineState::default()
};
let clock = test_clock();
render_timeline(&mut frame, &state, Rect::new(0, 0, 80, 10), &clock);
});
}
#[test]
fn test_event_color_created_is_green() {
assert_eq!(event_color(TimelineEventKind::Created, None), GREEN);
}
#[test]
fn test_event_color_closed_is_red() {
assert_eq!(
event_color(TimelineEventKind::StateChanged, Some("closed")),
RED
);
}
#[test]
fn test_event_color_merged_is_purple() {
assert_eq!(event_color(TimelineEventKind::Merged, None), PURPLE);
}
}

View File

@@ -0,0 +1,627 @@
#![allow(dead_code)]
//! Trace view — file → MR → issue chain drill-down.
//!
//! Layout:
//! ```text
//! +-----------------------------------+
//! | Path: [src/main.rs_] [R] [D] | <- path input + toggles
//! | Renames: old.rs -> new.rs | <- shown when renames followed
//! | 3 trace chains | <- summary
//! +-----------------------------------+
//! | > M !42 Fix auth @alice modified | <- collapsed chain (selected)
//! | O !39 Refactor @bob renamed | <- collapsed chain
//! | M !35 Init @carol added | <- expanded chain header
//! | #12 Bug: login broken (close) | <- linked issue
//! | @dave: "This path needs..." | <- discussion snippet
//! +-----------------------------------+
//! | Enter:expand r:renames d:disc | <- hint bar
//! +-----------------------------------+
//! ```
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::trace::TraceState;
use lore::core::trace::TraceResult;
// ---------------------------------------------------------------------------
// 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 PURPLE: PackedRgba = PackedRgba::rgb(0x8B, 0x7E, 0xC8); // purple
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x31); // bg-3
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/// Render the Trace screen.
pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core::geometry::Rect) {
if area.width < 10 || area.height < 3 {
return;
}
let x = area.x;
let max_x = area.right();
let width = area.width;
let mut y = area.y;
// --- Path input ---
render_path_input(frame, state, x, y, width);
y += 1;
if area.height < 5 {
return;
}
// --- Toggle indicators ---
render_toggle_indicators(frame, state, x, y, width);
y += 1;
// --- Loading ---
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 result.renames_followed && result.resolved_paths.len() > 1 {
render_rename_chain(frame, &result.resolved_paths, x, y, max_x);
y += 1;
}
// --- Summary ---
render_summary(frame, result, x, y, max_x);
y += 1;
if result.trace_chains.is_empty() {
render_no_results(frame, x, y, max_x);
return;
}
// Reserve hint bar.
let hint_y = area.bottom().saturating_sub(1);
let list_height = hint_y.saturating_sub(y) as usize;
if list_height == 0 {
return;
}
// --- Chain list ---
render_chain_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: &TraceState, 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);
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.
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),
bg: TEXT,
..Cell::default()
};
let ch = state
.path_input
.chars()
.nth(state.path_cursor)
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &ch.to_string(), cursor_cell, max_x);
}
}
}
fn render_toggle_indicators(frame: &mut Frame<'_>, state: &TraceState, 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 disc_tag = if state.include_discussions {
"[disc:on]"
} else {
"[disc:off]"
};
let renames_style = if state.follow_renames {
on_style
} else {
off_style
};
let disc_style = if state.include_discussions {
on_style
} else {
off_style
};
let after_r = frame.print_text_clipped(x + 1, y, renames_tag, renames_style, max_x);
frame.print_text_clipped(after_r + 1, y, disc_tag, disc_style, max_x);
}
fn render_rename_chain(frame: &mut Frame<'_>, paths: &[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);
// For long chains, show first 2 + "..." + last.
let chain_str = if paths.len() > 5 {
let first_two = paths[..2].join(" -> ");
let last = &paths[paths.len() - 1];
format!("{first_two} -> ... ({} more) -> {last}", paths.len() - 3)
} else {
paths.join(" -> ")
};
frame.print_text_clipped(after_label, y, &chain_str, chain_style, max_x);
}
fn render_summary(frame: &mut Frame<'_>, result: &TraceResult, x: u16, y: u16, max_x: u16) {
let summary = format!(
"{} trace chain{}",
result.total_chains,
if result.total_chains == 1 { "" } else { "s" },
);
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
}
fn render_chain_list(
frame: &mut Frame<'_>,
result: &TraceResult,
state: &TraceState,
x: u16,
start_y: u16,
width: u16,
height: usize,
) {
let max_x = x + width;
let mut row = 0;
for (chain_idx, chain) in result.trace_chains.iter().enumerate() {
if row >= height {
break;
}
let y = start_y + row as u16;
let selected = chain_idx == state.selected_chain_index;
let expanded = state.expanded_chains.contains(&chain_idx);
// 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);
}
}
let sel_bg = if selected { SELECTION_BG } else { BG_SURFACE };
// Expand indicator.
let expand_icon = if expanded { "v " } else { "> " };
let prefix = if selected { expand_icon } else { " " };
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);
// State icon.
let (icon, icon_color) = match chain.mr_state.as_str() {
"merged" => ("M", PURPLE),
"opened" => ("O", GREEN),
"closed" => ("C", RED),
_ => ("?", TEXT_MUTED),
};
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!(" !{}", chain.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.
let title = truncate_str(&chain.mr_title, 30);
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(&chain.mr_author, 12),
chain.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);
row += 1;
// Expanded content: linked issues + discussions.
if expanded {
// Issues.
for issue in &chain.issues {
if row >= height {
break;
}
let iy = start_y + row as u16;
let issue_icon = match issue.state.as_str() {
"opened" => "O",
"closed" => "C",
_ => "?",
};
let issue_icon_color = match issue.state.as_str() {
"opened" => GREEN,
"closed" => RED,
_ => TEXT_MUTED,
};
let indent_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let after_indent = frame.print_text_clipped(
x + 4,
iy,
issue_icon,
Cell {
fg: issue_icon_color,
..Cell::default()
},
max_x,
);
let issue_ref = format!(" #{} ", issue.iid);
let issue_ref_style = Cell {
fg: YELLOW,
..Cell::default()
};
let after_ref =
frame.print_text_clipped(after_indent, iy, &issue_ref, issue_ref_style, max_x);
let issue_title = truncate_str(&issue.title, 40);
let _ = indent_style; // suppress unused
frame.print_text_clipped(
after_ref,
iy,
&issue_title,
Cell {
fg: TEXT,
..Cell::default()
},
max_x,
);
row += 1;
}
// Discussions.
for disc in &chain.discussions {
if row >= height {
break;
}
let dy = start_y + row as u16;
let author = format!("@{}: ", truncate_str(&disc.author_username, 12));
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let after_author =
frame.print_text_clipped(x + 4, dy, &author, author_style, max_x);
let snippet = truncate_str(&disc.body, 60);
let snippet_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
frame.print_text_clipped(after_author, dy, &snippet, snippet_style, max_x);
row += 1;
}
}
}
}
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, "Tracing file provenance...", 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 trace.",
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 trace chains found.", style, max_x);
frame.print_text_clipped(
x + 1,
y + 1,
"Hint: Run 'lore sync' to fetch 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()
};
for col in x..max_x {
frame.buffer.set(col, y, style);
}
let hints = "/:path Enter:expand r:renames d:discussions 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 std::collections::HashSet;
use super::*;
use crate::state::trace::TraceState;
use ftui::render::grapheme_pool::GraphemePool;
use lore::core::trace::{TraceChain, TraceResult};
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,
}
}
fn sample_chain(iid: i64, title: &str, state: &str) -> TraceChain {
TraceChain {
mr_iid: iid,
mr_title: title.into(),
mr_state: state.into(),
mr_author: "alice".into(),
change_type: "modified".into(),
merged_at_iso: None,
updated_at_iso: "2024-01-01".into(),
web_url: None,
issues: vec![],
discussions: vec![],
}
}
#[test]
fn test_render_empty_no_panic() {
with_frame!(80, 24, |frame| {
let state = TraceState::default();
render_trace(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_tiny_terminal_noop() {
with_frame!(5, 2, |frame| {
let state = TraceState::default();
render_trace(&mut frame, &state, test_area(5, 2));
});
}
#[test]
fn test_render_loading() {
with_frame!(80, 24, |frame| {
let state = TraceState {
loading: true,
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(80, 24));
});
}
#[test]
fn test_render_with_chains() {
with_frame!(100, 30, |frame| {
let state = TraceState {
result: Some(TraceResult {
path: "src/main.rs".into(),
resolved_paths: vec!["src/main.rs".into()],
renames_followed: false,
trace_chains: vec![
sample_chain(42, "Fix auth flow", "merged"),
sample_chain(39, "Refactor modules", "opened"),
],
total_chains: 2,
}),
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(100, 30));
});
}
#[test]
fn test_render_expanded_chain() {
with_frame!(100, 30, |frame| {
let state = TraceState {
expanded_chains: HashSet::from([0]),
result: Some(TraceResult {
path: "src/main.rs".into(),
resolved_paths: vec!["src/main.rs".into()],
renames_followed: false,
trace_chains: vec![TraceChain {
mr_iid: 42,
mr_title: "Fix auth".into(),
mr_state: "merged".into(),
mr_author: "alice".into(),
change_type: "modified".into(),
merged_at_iso: None,
updated_at_iso: "2024-01-01".into(),
web_url: None,
issues: vec![lore::core::trace::TraceIssue {
iid: 12,
title: "Login broken".into(),
state: "closed".into(),
reference_type: "closes".into(),
web_url: None,
}],
discussions: vec![lore::core::trace::TraceDiscussion {
discussion_id: "abc".into(),
mr_iid: 42,
author_username: "bob".into(),
body: "This path needs review".into(),
path: "src/main.rs".into(),
created_at_iso: "2024-01-01".into(),
}],
}],
total_chains: 1,
}),
..TraceState::default()
};
render_trace(&mut frame, &state, test_area(100, 30));
});
}
#[test]
fn test_render_with_rename_chain() {
with_frame!(80, 24, |frame| {
let state = TraceState {
result: Some(TraceResult {
path: "src/old.rs".into(),
resolved_paths: vec!["src/old.rs".into(), "src/new.rs".into()],
renames_followed: true,
trace_chains: vec![],
total_chains: 0,
}),
..TraceState::default()
};
render_trace(&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), "");
}
}

File diff suppressed because it is too large Load Diff