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:
134
crates/lore-tui/src/view/bootstrap.rs
Normal file
134
crates/lore-tui/src/view/bootstrap.rs
Normal 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.
|
||||
});
|
||||
}
|
||||
}
|
||||
389
crates/lore-tui/src/view/command_palette.rs
Normal file
389
crates/lore-tui/src/view/command_palette.rs
Normal 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(®istry, &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(®istry, &Screen::Dashboard);
|
||||
state.insert_char('q', ®istry, &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(®istry, &Screen::Dashboard);
|
||||
state.insert_char('é', ®istry, &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(®istry, &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(®istry, &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(®istry, &Screen::Dashboard);
|
||||
for c in "zzzzzz".chars() {
|
||||
state.insert_char(c, ®istry, &Screen::Dashboard);
|
||||
}
|
||||
render_command_palette(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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), "");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
492
crates/lore-tui/src/view/search.rs
Normal file
492
crates/lore-tui/src/view/search.rs
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
449
crates/lore-tui/src/view/timeline.rs
Normal file
449
crates/lore-tui/src/view/timeline.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
627
crates/lore-tui/src/view/trace.rs
Normal file
627
crates/lore-tui/src/view/trace.rs
Normal 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), "");
|
||||
}
|
||||
}
|
||||
1049
crates/lore-tui/src/view/who.rs
Normal file
1049
crates/lore-tui/src/view/who.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user