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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user