feat(tui): Phase 2 Issue List + MR List screens
Implement state, action, and view layers for both list screens: - Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation - MR List: mirrors Issue pattern with draft/reviewer/target branch filters - Migration 027: covering indexes for TUI list screen queries - Updated Msg types to use typed Page structs instead of raw Vec<Row> - 303 tests passing, clippy clean Beads: bd-3ei1, bd-2kr0, bd-3pm2
This commit is contained in:
676
crates/lore-tui/src/view/common/entity_table.rs
Normal file
676
crates/lore-tui/src/view/common/entity_table.rs
Normal file
@@ -0,0 +1,676 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
|
||||
|
||||
//! Generic entity table widget for list screens.
|
||||
//!
|
||||
//! `EntityTable<R>` renders rows with sortable, responsive columns.
|
||||
//! Columns hide gracefully when the terminal is too narrow, using
|
||||
//! priority-based visibility.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Describes a single table column.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColumnDef {
|
||||
/// Display name shown in the header.
|
||||
pub name: &'static str,
|
||||
/// Minimum width in characters. Column is hidden if it can't meet this.
|
||||
pub min_width: u16,
|
||||
/// Flex weight for distributing extra space.
|
||||
pub flex_weight: u16,
|
||||
/// Visibility priority (0 = always shown, higher = hidden first).
|
||||
pub priority: u8,
|
||||
/// Text alignment within the column.
|
||||
pub align: Align,
|
||||
}
|
||||
|
||||
/// Text alignment within a column.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TableRow trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trait for types that can be rendered as a table row.
|
||||
pub trait TableRow {
|
||||
/// Return the cell text for each column, in column order.
|
||||
fn cells(&self, col_count: usize) -> Vec<String>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityTable state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rendering state for the entity table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EntityTableState {
|
||||
/// Index of the selected row (0-based, within the full data set).
|
||||
pub selected: usize,
|
||||
/// Scroll offset (first visible row index).
|
||||
pub scroll_offset: usize,
|
||||
/// Index of the column used for sorting.
|
||||
pub sort_column: usize,
|
||||
/// Sort direction.
|
||||
pub sort_ascending: bool,
|
||||
}
|
||||
|
||||
impl Default for EntityTableState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
sort_column: 0,
|
||||
sort_ascending: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityTableState {
|
||||
/// Move selection down by 1.
|
||||
pub fn select_next(&mut self, total_rows: usize) {
|
||||
if total_rows == 0 {
|
||||
return;
|
||||
}
|
||||
self.selected = (self.selected + 1).min(total_rows - 1);
|
||||
}
|
||||
|
||||
/// Move selection up by 1.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Page down (move by `page_size` rows).
|
||||
pub fn page_down(&mut self, total_rows: usize, page_size: usize) {
|
||||
if total_rows == 0 {
|
||||
return;
|
||||
}
|
||||
self.selected = (self.selected + page_size).min(total_rows - 1);
|
||||
}
|
||||
|
||||
/// Page up.
|
||||
pub fn page_up(&mut self, page_size: usize) {
|
||||
self.selected = self.selected.saturating_sub(page_size);
|
||||
}
|
||||
|
||||
/// Jump to top.
|
||||
pub fn select_first(&mut self) {
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
/// Jump to bottom.
|
||||
pub fn select_last(&mut self, total_rows: usize) {
|
||||
if total_rows > 0 {
|
||||
self.selected = total_rows - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle sort column forward (wraps around).
|
||||
pub fn cycle_sort(&mut self, col_count: usize) {
|
||||
if col_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.sort_column = (self.sort_column + 1) % col_count;
|
||||
}
|
||||
|
||||
/// Toggle sort direction on current column.
|
||||
pub fn toggle_sort_direction(&mut self) {
|
||||
self.sort_ascending = !self.sort_ascending;
|
||||
}
|
||||
|
||||
/// Ensure scroll offset keeps selection visible.
|
||||
fn adjust_scroll(&mut self, visible_rows: usize) {
|
||||
if visible_rows == 0 {
|
||||
return;
|
||||
}
|
||||
if self.selected < self.scroll_offset {
|
||||
self.scroll_offset = self.selected;
|
||||
}
|
||||
if self.selected >= self.scroll_offset + visible_rows {
|
||||
self.scroll_offset = self.selected - visible_rows + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Colors for the entity table. Will be replaced by Theme injection.
|
||||
pub struct TableColors {
|
||||
pub header_fg: PackedRgba,
|
||||
pub header_bg: PackedRgba,
|
||||
pub row_fg: PackedRgba,
|
||||
pub row_alt_bg: PackedRgba,
|
||||
pub selected_fg: PackedRgba,
|
||||
pub selected_bg: PackedRgba,
|
||||
pub sort_indicator: PackedRgba,
|
||||
pub border: PackedRgba,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute which columns are visible given the available width.
|
||||
///
|
||||
/// Returns indices of visible columns sorted by original order,
|
||||
/// along with their allocated widths.
|
||||
pub fn visible_columns(columns: &[ColumnDef], available_width: u16) -> Vec<(usize, u16)> {
|
||||
// Sort by priority (lowest = most important).
|
||||
let mut indexed: Vec<(usize, &ColumnDef)> = columns.iter().enumerate().collect();
|
||||
indexed.sort_by_key(|(_, col)| col.priority);
|
||||
|
||||
let mut result: Vec<(usize, u16)> = Vec::new();
|
||||
let mut used_width: u16 = 0;
|
||||
let gap = 1u16; // 1-char gap between columns.
|
||||
|
||||
for (idx, col) in &indexed {
|
||||
let needed = col.min_width + if result.is_empty() { 0 } else { gap };
|
||||
if used_width + needed <= available_width {
|
||||
result.push((*idx, col.min_width));
|
||||
used_width += needed;
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute remaining space by flex weight.
|
||||
let remaining = available_width.saturating_sub(used_width);
|
||||
if remaining > 0 {
|
||||
let total_weight: u16 = result
|
||||
.iter()
|
||||
.map(|(idx, _)| columns[*idx].flex_weight)
|
||||
.sum();
|
||||
|
||||
if total_weight > 0 {
|
||||
for (idx, width) in &mut result {
|
||||
let weight = columns[*idx].flex_weight;
|
||||
let extra =
|
||||
(u32::from(remaining) * u32::from(weight) / u32::from(total_weight)) as u16;
|
||||
*width += extra;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by original column order for rendering.
|
||||
result.sort_by_key(|(idx, _)| *idx);
|
||||
result
|
||||
}
|
||||
|
||||
/// Render the entity table header row.
|
||||
pub fn render_header(
|
||||
frame: &mut Frame<'_>,
|
||||
columns: &[ColumnDef],
|
||||
visible: &[(usize, u16)],
|
||||
state: &EntityTableState,
|
||||
y: u16,
|
||||
area_x: u16,
|
||||
colors: &TableColors,
|
||||
) {
|
||||
let header_cell = Cell {
|
||||
fg: colors.header_fg,
|
||||
bg: colors.header_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let sort_cell = Cell {
|
||||
fg: colors.sort_indicator,
|
||||
bg: colors.header_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
// Fill header background.
|
||||
let total_width: u16 = visible.iter().map(|(_, w)| w + 1).sum();
|
||||
let header_rect = Rect::new(area_x, y, total_width, 1);
|
||||
frame.draw_rect_filled(
|
||||
header_rect,
|
||||
Cell {
|
||||
bg: colors.header_bg,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut x = area_x;
|
||||
for (col_idx, col_width) in visible {
|
||||
let col = &columns[*col_idx];
|
||||
let col_max = x.saturating_add(*col_width);
|
||||
|
||||
let after_name = frame.print_text_clipped(x, y, col.name, header_cell, col_max);
|
||||
|
||||
// Sort indicator.
|
||||
if *col_idx == state.sort_column {
|
||||
let arrow = if state.sort_ascending { " ^" } else { " v" };
|
||||
frame.print_text_clipped(after_name, y, arrow, sort_cell, col_max);
|
||||
}
|
||||
|
||||
x = col_max.saturating_add(1); // gap
|
||||
}
|
||||
}
|
||||
|
||||
/// Style context for rendering a single row.
|
||||
pub struct RowContext<'a> {
|
||||
pub columns: &'a [ColumnDef],
|
||||
pub visible: &'a [(usize, u16)],
|
||||
pub is_selected: bool,
|
||||
pub is_alt: bool,
|
||||
pub colors: &'a TableColors,
|
||||
}
|
||||
|
||||
/// Render a data row.
|
||||
pub fn render_row<R: TableRow>(
|
||||
frame: &mut Frame<'_>,
|
||||
row: &R,
|
||||
y: u16,
|
||||
area_x: u16,
|
||||
ctx: &RowContext<'_>,
|
||||
) {
|
||||
let (fg, bg) = if ctx.is_selected {
|
||||
(ctx.colors.selected_fg, ctx.colors.selected_bg)
|
||||
} else if ctx.is_alt {
|
||||
(ctx.colors.row_fg, ctx.colors.row_alt_bg)
|
||||
} else {
|
||||
(ctx.colors.row_fg, Cell::default().bg)
|
||||
};
|
||||
|
||||
let cell_style = Cell {
|
||||
fg,
|
||||
bg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
// Fill row background if selected or alt.
|
||||
if ctx.is_selected || ctx.is_alt {
|
||||
let total_width: u16 = ctx.visible.iter().map(|(_, w)| w + 1).sum();
|
||||
frame.draw_rect_filled(
|
||||
Rect::new(area_x, y, total_width, 1),
|
||||
Cell {
|
||||
bg,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let cells = row.cells(ctx.columns.len());
|
||||
let mut x = area_x;
|
||||
|
||||
for (col_idx, col_width) in ctx.visible {
|
||||
let col_max = x.saturating_add(*col_width);
|
||||
let text = cells.get(*col_idx).map(String::as_str).unwrap_or("");
|
||||
|
||||
match ctx.columns[*col_idx].align {
|
||||
Align::Left => {
|
||||
frame.print_text_clipped(x, y, text, cell_style, col_max);
|
||||
}
|
||||
Align::Right => {
|
||||
let text_len = text.len() as u16;
|
||||
let start = if text_len < *col_width {
|
||||
x + col_width - text_len
|
||||
} else {
|
||||
x
|
||||
};
|
||||
frame.print_text_clipped(start, y, text, cell_style, col_max);
|
||||
}
|
||||
}
|
||||
|
||||
x = col_max.saturating_add(1); // gap
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a complete entity table: header + scrollable rows.
|
||||
pub fn render_entity_table<R: TableRow>(
|
||||
frame: &mut Frame<'_>,
|
||||
rows: &[R],
|
||||
columns: &[ColumnDef],
|
||||
state: &mut EntityTableState,
|
||||
area: Rect,
|
||||
colors: &TableColors,
|
||||
) {
|
||||
if area.height < 2 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let visible = visible_columns(columns, area.width);
|
||||
if visible.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Header row.
|
||||
render_header(frame, columns, &visible, state, area.y, area.x, colors);
|
||||
|
||||
// Separator.
|
||||
let sep_y = area.y.saturating_add(1);
|
||||
let sep_cell = Cell {
|
||||
fg: colors.border,
|
||||
..Cell::default()
|
||||
};
|
||||
let rule = "─".repeat(area.width as usize);
|
||||
frame.print_text_clipped(
|
||||
area.x,
|
||||
sep_y,
|
||||
&rule,
|
||||
sep_cell,
|
||||
area.x.saturating_add(area.width),
|
||||
);
|
||||
|
||||
// Data rows.
|
||||
let data_start_y = area.y.saturating_add(2);
|
||||
let visible_rows = area.height.saturating_sub(2) as usize; // minus header + separator
|
||||
|
||||
state.adjust_scroll(visible_rows);
|
||||
|
||||
let start = state.scroll_offset;
|
||||
let end = (start + visible_rows).min(rows.len());
|
||||
|
||||
for (i, row) in rows[start..end].iter().enumerate() {
|
||||
let row_y = data_start_y.saturating_add(i as u16);
|
||||
let absolute_idx = start + i;
|
||||
let ctx = RowContext {
|
||||
columns,
|
||||
visible: &visible,
|
||||
is_selected: absolute_idx == state.selected,
|
||||
is_alt: absolute_idx % 2 == 1,
|
||||
colors,
|
||||
};
|
||||
|
||||
render_row(frame, row, row_y, area.x, &ctx);
|
||||
}
|
||||
|
||||
// Scroll indicator if more rows below.
|
||||
if end < rows.len() {
|
||||
let indicator_y = data_start_y.saturating_add(visible_rows as u16);
|
||||
if indicator_y < area.y.saturating_add(area.height) {
|
||||
let muted = Cell {
|
||||
fg: colors.border,
|
||||
..Cell::default()
|
||||
};
|
||||
let remaining = rows.len() - end;
|
||||
frame.print_text_clipped(
|
||||
area.x,
|
||||
indicator_y,
|
||||
&format!("... {remaining} more"),
|
||||
muted,
|
||||
area.x.saturating_add(area.width),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}};
|
||||
}
|
||||
|
||||
fn test_columns() -> Vec<ColumnDef> {
|
||||
vec![
|
||||
ColumnDef {
|
||||
name: "IID",
|
||||
min_width: 5,
|
||||
flex_weight: 0,
|
||||
priority: 0,
|
||||
align: Align::Right,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Title",
|
||||
min_width: 10,
|
||||
flex_weight: 3,
|
||||
priority: 0,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "State",
|
||||
min_width: 8,
|
||||
flex_weight: 1,
|
||||
priority: 1,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Author",
|
||||
min_width: 8,
|
||||
flex_weight: 1,
|
||||
priority: 2,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Updated",
|
||||
min_width: 10,
|
||||
flex_weight: 0,
|
||||
priority: 3,
|
||||
align: Align::Right,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
struct TestRow {
|
||||
cells: Vec<String>,
|
||||
}
|
||||
|
||||
impl TableRow for TestRow {
|
||||
fn cells(&self, _col_count: usize) -> Vec<String> {
|
||||
self.cells.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_colors() -> TableColors {
|
||||
TableColors {
|
||||
header_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
|
||||
header_bg: PackedRgba::rgb(0x30, 0x30, 0x30),
|
||||
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
row_alt_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||
selected_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
|
||||
selected_bg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
|
||||
sort_indicator: PackedRgba::rgb(0xDA, 0x70, 0x2C),
|
||||
border: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_columns_all_fit() {
|
||||
let cols = test_columns();
|
||||
let vis = visible_columns(&cols, 100);
|
||||
assert_eq!(vis.len(), 5, "All 5 columns should fit at 100 cols");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_columns_hides_low_priority() {
|
||||
let cols = test_columns();
|
||||
// min widths: 5 + 10 + 8 + 8 + 10 + 4 gaps = 45.
|
||||
// At 25 cols, only priority 0 columns (IID + Title) should fit.
|
||||
let vis = visible_columns(&cols, 25);
|
||||
let visible_indices: Vec<usize> = vis.iter().map(|(idx, _)| *idx).collect();
|
||||
assert!(visible_indices.contains(&0), "IID should always be visible");
|
||||
assert!(
|
||||
visible_indices.contains(&1),
|
||||
"Title should always be visible"
|
||||
);
|
||||
assert!(
|
||||
!visible_indices.contains(&4),
|
||||
"Updated (priority 3) should be hidden"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_hiding_at_60_cols() {
|
||||
let cols = test_columns();
|
||||
let vis = visible_columns(&cols, 60);
|
||||
// min widths for priority 0,1,2: 5+10+8+8 + 3 gaps = 34.
|
||||
// Priority 3 (Updated, min 10 + gap) = 45 total, should still fit.
|
||||
assert!(vis.len() >= 3, "At least 3 columns at 60 cols");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_select_next_prev() {
|
||||
let mut state = EntityTableState::default();
|
||||
state.select_next(5);
|
||||
assert_eq!(state.selected, 1);
|
||||
state.select_next(5);
|
||||
assert_eq!(state.selected, 2);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_select_bounds() {
|
||||
let mut state = EntityTableState::default();
|
||||
state.select_prev(); // at 0, can't go below
|
||||
assert_eq!(state.selected, 0);
|
||||
|
||||
state.select_next(3);
|
||||
state.select_next(3);
|
||||
state.select_next(3); // at 2, can't go above last
|
||||
assert_eq!(state.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_page_up_down() {
|
||||
let mut state = EntityTableState::default();
|
||||
state.page_down(20, 5);
|
||||
assert_eq!(state.selected, 5);
|
||||
state.page_up(3);
|
||||
assert_eq!(state.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_first_last() {
|
||||
let mut state = EntityTableState {
|
||||
selected: 5,
|
||||
..Default::default()
|
||||
};
|
||||
state.select_first();
|
||||
assert_eq!(state.selected, 0);
|
||||
state.select_last(10);
|
||||
assert_eq!(state.selected, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_cycle_sort() {
|
||||
let mut state = EntityTableState::default();
|
||||
assert_eq!(state.sort_column, 0);
|
||||
state.cycle_sort(5);
|
||||
assert_eq!(state.sort_column, 1);
|
||||
state.sort_column = 4;
|
||||
state.cycle_sort(5); // wraps to 0
|
||||
assert_eq!(state.sort_column, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_toggle_sort_direction() {
|
||||
let mut state = EntityTableState::default();
|
||||
assert!(state.sort_ascending);
|
||||
state.toggle_sort_direction();
|
||||
assert!(!state.sort_ascending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_adjust_scroll() {
|
||||
let mut state = EntityTableState {
|
||||
selected: 15,
|
||||
scroll_offset: 0,
|
||||
..Default::default()
|
||||
};
|
||||
state.adjust_scroll(10);
|
||||
assert_eq!(state.scroll_offset, 6); // selected=15 should be at bottom of 10-row window
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_entity_table_no_panic() {
|
||||
with_frame!(80, 20, |frame| {
|
||||
let cols = test_columns();
|
||||
let rows = vec![
|
||||
TestRow {
|
||||
cells: vec![
|
||||
"#42".into(),
|
||||
"Fix auth bug".into(),
|
||||
"opened".into(),
|
||||
"taylor".into(),
|
||||
"2h ago".into(),
|
||||
],
|
||||
},
|
||||
TestRow {
|
||||
cells: vec![
|
||||
"#43".into(),
|
||||
"Add tests".into(),
|
||||
"merged".into(),
|
||||
"alice".into(),
|
||||
"1d ago".into(),
|
||||
],
|
||||
},
|
||||
];
|
||||
let mut state = EntityTableState::default();
|
||||
let colors = test_colors();
|
||||
|
||||
render_entity_table(
|
||||
&mut frame,
|
||||
&rows,
|
||||
&cols,
|
||||
&mut state,
|
||||
Rect::new(0, 0, 80, 20),
|
||||
&colors,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_entity_table_tiny_noop() {
|
||||
with_frame!(3, 1, |frame| {
|
||||
let cols = test_columns();
|
||||
let rows: Vec<TestRow> = vec![];
|
||||
let mut state = EntityTableState::default();
|
||||
let colors = test_colors();
|
||||
|
||||
render_entity_table(
|
||||
&mut frame,
|
||||
&rows,
|
||||
&cols,
|
||||
&mut state,
|
||||
Rect::new(0, 0, 3, 1),
|
||||
&colors,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_entity_table_empty_rows() {
|
||||
with_frame!(80, 10, |frame| {
|
||||
let cols = test_columns();
|
||||
let rows: Vec<TestRow> = vec![];
|
||||
let mut state = EntityTableState::default();
|
||||
let colors = test_colors();
|
||||
|
||||
render_entity_table(
|
||||
&mut frame,
|
||||
&rows,
|
||||
&cols,
|
||||
&mut state,
|
||||
Rect::new(0, 0, 80, 10),
|
||||
&colors,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_select_next_empty() {
|
||||
let mut state = EntityTableState::default();
|
||||
state.select_next(0); // no rows
|
||||
assert_eq!(state.selected, 0);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,15 @@ pub fn render_error_toast(
|
||||
let max_toast_width = (area.width / 2).clamp(20, 60);
|
||||
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
|
||||
let trunc_len = max_toast_width.saturating_sub(7) as usize;
|
||||
format!(" {}... ", &msg[..trunc_len.min(msg.len())])
|
||||
// Find a char boundary at or before trunc_len to avoid panicking
|
||||
// on multi-byte UTF-8 (e.g., emoji or CJK in error messages).
|
||||
let safe_end = msg
|
||||
.char_indices()
|
||||
.take_while(|&(i, _)| i <= trunc_len)
|
||||
.last()
|
||||
.map_or(0, |(i, c)| i + c.len_utf8())
|
||||
.min(msg.len());
|
||||
format!(" {}... ", &msg[..safe_end])
|
||||
} else {
|
||||
format!(" {msg} ")
|
||||
};
|
||||
|
||||
469
crates/lore-tui/src/view/common/filter_bar.rs
Normal file
469
crates/lore-tui/src/view/common/filter_bar.rs
Normal file
@@ -0,0 +1,469 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
|
||||
|
||||
//! Filter bar widget for list screens.
|
||||
//!
|
||||
//! Wraps a text input with DSL parsing, inline diagnostics for unknown
|
||||
//! fields, and rendered filter chips below the input.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::filter_dsl::{self, FilterToken};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter bar state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the filter bar widget.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FilterBarState {
|
||||
/// Current filter input text.
|
||||
pub input: String,
|
||||
/// Cursor position within the input string (byte offset).
|
||||
pub cursor: usize,
|
||||
/// Whether the filter bar has focus.
|
||||
pub focused: bool,
|
||||
/// Parsed tokens from the current input.
|
||||
pub tokens: Vec<FilterToken>,
|
||||
/// Fields that are unknown for the current entity type.
|
||||
pub unknown_fields: Vec<String>,
|
||||
}
|
||||
|
||||
impl FilterBarState {
|
||||
/// Update parsed tokens from the current input text.
|
||||
pub fn reparse(&mut self, known_fields: &[&str]) {
|
||||
self.tokens = filter_dsl::parse_filter_tokens(&self.input);
|
||||
self.unknown_fields = filter_dsl::unknown_fields(&self.tokens, known_fields)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert_char(&mut self, ch: char) {
|
||||
if self.cursor > self.input.len() {
|
||||
self.cursor = self.input.len();
|
||||
}
|
||||
self.input.insert(self.cursor, ch);
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor (backspace).
|
||||
pub fn delete_back(&mut self) {
|
||||
if self.cursor > 0 && !self.input.is_empty() {
|
||||
// Find the previous character boundary.
|
||||
let prev = self.input[..self.cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
self.input.remove(prev);
|
||||
self.cursor = prev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor (delete key).
|
||||
pub fn delete_forward(&mut self) {
|
||||
if self.cursor < self.input.len() {
|
||||
self.input.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor left by one character.
|
||||
pub fn move_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor = self.input[..self.cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right by one character.
|
||||
pub fn move_right(&mut self) {
|
||||
if self.cursor < self.input.len() {
|
||||
self.cursor = self.input[self.cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.map(|ch| self.cursor + ch.len_utf8())
|
||||
.unwrap_or(self.input.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor to start.
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Move cursor to end.
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.input.len();
|
||||
}
|
||||
|
||||
/// Clear the input.
|
||||
pub fn clear(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor = 0;
|
||||
self.tokens.clear();
|
||||
self.unknown_fields.clear();
|
||||
}
|
||||
|
||||
/// Whether the filter has any active tokens.
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.tokens.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Colors for the filter bar.
|
||||
pub struct FilterBarColors {
|
||||
pub input_fg: PackedRgba,
|
||||
pub input_bg: PackedRgba,
|
||||
pub cursor_fg: PackedRgba,
|
||||
pub cursor_bg: PackedRgba,
|
||||
pub chip_fg: PackedRgba,
|
||||
pub chip_bg: PackedRgba,
|
||||
pub error_fg: PackedRgba,
|
||||
pub label_fg: PackedRgba,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the filter bar.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// Row 0: [Filter: ][input text with cursor___________]
|
||||
/// Row 1: [chip1] [chip2] [chip3] (if tokens present)
|
||||
/// ```
|
||||
///
|
||||
/// Returns the number of rows consumed (1 or 2).
|
||||
pub fn render_filter_bar(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &FilterBarState,
|
||||
area: Rect,
|
||||
colors: &FilterBarColors,
|
||||
) -> u16 {
|
||||
if area.height == 0 || area.width < 10 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let y = area.y;
|
||||
|
||||
// Label.
|
||||
let label = if state.focused { "Filter: " } else { "/ " };
|
||||
let label_cell = Cell {
|
||||
fg: colors.label_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let after_label = frame.print_text_clipped(area.x, y, label, label_cell, max_x);
|
||||
|
||||
// Input text.
|
||||
let input_cell = Cell {
|
||||
fg: colors.input_fg,
|
||||
bg: if state.focused {
|
||||
colors.input_bg
|
||||
} else {
|
||||
Cell::default().bg
|
||||
},
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
if state.input.is_empty() && !state.focused {
|
||||
let muted = Cell {
|
||||
fg: colors.label_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(after_label, y, "type / to filter", muted, max_x);
|
||||
} else {
|
||||
// Render input text with cursor highlight.
|
||||
render_input_with_cursor(frame, state, after_label, y, max_x, input_cell, colors);
|
||||
}
|
||||
|
||||
// Error indicators for unknown fields.
|
||||
if !state.unknown_fields.is_empty() {
|
||||
let err_cell = Cell {
|
||||
fg: colors.error_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let err_msg = format!("Unknown: {}", state.unknown_fields.join(", "));
|
||||
// Right-align the error.
|
||||
let err_x = max_x.saturating_sub(err_msg.len() as u16 + 1);
|
||||
frame.print_text_clipped(err_x, y, &err_msg, err_cell, max_x);
|
||||
}
|
||||
|
||||
// Chip row (if tokens present and space available).
|
||||
if !state.tokens.is_empty() && area.height >= 2 {
|
||||
let chip_y = y.saturating_add(1);
|
||||
render_chips(frame, &state.tokens, area.x, chip_y, max_x, colors);
|
||||
return 2;
|
||||
}
|
||||
|
||||
1
|
||||
}
|
||||
|
||||
/// Render input text with cursor highlight at the correct position.
|
||||
fn render_input_with_cursor(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &FilterBarState,
|
||||
start_x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
base_cell: Cell,
|
||||
colors: &FilterBarColors,
|
||||
) {
|
||||
if !state.focused {
|
||||
frame.print_text_clipped(start_x, y, &state.input, base_cell, max_x);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split at cursor position.
|
||||
let cursor = state.cursor;
|
||||
let input = &state.input;
|
||||
let (before, after) = if cursor <= input.len() {
|
||||
(&input[..cursor], &input[cursor..])
|
||||
} else {
|
||||
(input.as_str(), "")
|
||||
};
|
||||
|
||||
let mut x = frame.print_text_clipped(start_x, y, before, base_cell, max_x);
|
||||
|
||||
// Cursor character (or space if at end).
|
||||
let cursor_cell = Cell {
|
||||
fg: colors.cursor_fg,
|
||||
bg: colors.cursor_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
if let Some(ch) = after.chars().next() {
|
||||
let s = String::from(ch);
|
||||
x = frame.print_text_clipped(x, y, &s, cursor_cell, max_x);
|
||||
let remaining = &after[ch.len_utf8()..];
|
||||
frame.print_text_clipped(x, y, remaining, base_cell, max_x);
|
||||
} else {
|
||||
// Cursor at end — render a visible block.
|
||||
frame.print_text_clipped(x, y, " ", cursor_cell, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render filter chips as compact tags.
|
||||
fn render_chips(
|
||||
frame: &mut Frame<'_>,
|
||||
tokens: &[FilterToken],
|
||||
start_x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
colors: &FilterBarColors,
|
||||
) {
|
||||
let chip_cell = Cell {
|
||||
fg: colors.chip_fg,
|
||||
bg: colors.chip_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let mut x = start_x;
|
||||
|
||||
for token in tokens {
|
||||
if x >= max_x {
|
||||
break;
|
||||
}
|
||||
|
||||
let label = match token {
|
||||
FilterToken::FieldValue { field, value } => format!("{field}:{value}"),
|
||||
FilterToken::Negation { field, value } => format!("-{field}:{value}"),
|
||||
FilterToken::FreeText(text) => text.clone(),
|
||||
FilterToken::QuotedValue(text) => format!("\"{text}\""),
|
||||
};
|
||||
|
||||
let chip_text = format!("[{label}]");
|
||||
x = frame.print_text_clipped(x, y, &chip_text, chip_cell, max_x);
|
||||
x = x.saturating_add(1); // gap between chips
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::filter_dsl::ISSUE_FIELDS;
|
||||
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_colors() -> FilterBarColors {
|
||||
FilterBarColors {
|
||||
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
|
||||
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
|
||||
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_insert_char() {
|
||||
let mut state = FilterBarState::default();
|
||||
state.insert_char('a');
|
||||
state.insert_char('b');
|
||||
assert_eq!(state.input, "ab");
|
||||
assert_eq!(state.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_delete_back() {
|
||||
let mut state = FilterBarState {
|
||||
input: "abc".into(),
|
||||
cursor: 3,
|
||||
..Default::default()
|
||||
};
|
||||
state.delete_back();
|
||||
assert_eq!(state.input, "ab");
|
||||
assert_eq!(state.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_delete_back_at_start() {
|
||||
let mut state = FilterBarState {
|
||||
input: "abc".into(),
|
||||
cursor: 0,
|
||||
..Default::default()
|
||||
};
|
||||
state.delete_back();
|
||||
assert_eq!(state.input, "abc");
|
||||
assert_eq!(state.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_move_left_right() {
|
||||
let mut state = FilterBarState {
|
||||
input: "abc".into(),
|
||||
cursor: 2,
|
||||
..Default::default()
|
||||
};
|
||||
state.move_left();
|
||||
assert_eq!(state.cursor, 1);
|
||||
state.move_right();
|
||||
assert_eq!(state.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_home_end() {
|
||||
let mut state = FilterBarState {
|
||||
input: "hello".into(),
|
||||
cursor: 3,
|
||||
..Default::default()
|
||||
};
|
||||
state.move_home();
|
||||
assert_eq!(state.cursor, 0);
|
||||
state.move_end();
|
||||
assert_eq!(state.cursor, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_clear() {
|
||||
let mut state = FilterBarState {
|
||||
input: "state:opened".into(),
|
||||
cursor: 12,
|
||||
tokens: vec![FilterToken::FieldValue {
|
||||
field: "state".into(),
|
||||
value: "opened".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
state.clear();
|
||||
assert!(state.input.is_empty());
|
||||
assert_eq!(state.cursor, 0);
|
||||
assert!(state.tokens.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_reparse() {
|
||||
let mut state = FilterBarState {
|
||||
input: "state:opened bogus:val".into(),
|
||||
..Default::default()
|
||||
};
|
||||
state.reparse(ISSUE_FIELDS);
|
||||
assert_eq!(state.tokens.len(), 2);
|
||||
assert_eq!(state.unknown_fields, vec!["bogus"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_state_is_active() {
|
||||
let mut state = FilterBarState::default();
|
||||
assert!(!state.is_active());
|
||||
|
||||
state.input = "state:opened".into();
|
||||
state.reparse(ISSUE_FIELDS);
|
||||
assert!(state.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_filter_bar_unfocused_no_panic() {
|
||||
with_frame!(80, 2, |frame| {
|
||||
let state = FilterBarState::default();
|
||||
let colors = test_colors();
|
||||
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
|
||||
assert_eq!(rows, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_filter_bar_focused_no_panic() {
|
||||
with_frame!(80, 2, |frame| {
|
||||
let mut state = FilterBarState {
|
||||
input: "state:opened".into(),
|
||||
cursor: 12,
|
||||
focused: true,
|
||||
..Default::default()
|
||||
};
|
||||
state.reparse(ISSUE_FIELDS);
|
||||
let colors = test_colors();
|
||||
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
|
||||
assert_eq!(rows, 2); // chips rendered
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_filter_bar_tiny_noop() {
|
||||
with_frame!(5, 1, |frame| {
|
||||
let state = FilterBarState::default();
|
||||
let colors = test_colors();
|
||||
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 5, 1), &colors);
|
||||
assert_eq!(rows, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_bar_unicode_cursor() {
|
||||
let mut state = FilterBarState {
|
||||
input: "author:田中".into(),
|
||||
cursor: 7, // points at start of 田
|
||||
..Default::default()
|
||||
};
|
||||
state.move_right();
|
||||
assert_eq!(state.cursor, 10); // past 田 (3 bytes)
|
||||
state.move_left();
|
||||
assert_eq!(state.cursor, 7); // back to 田
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,17 @@
|
||||
//! no side effects.
|
||||
|
||||
mod breadcrumb;
|
||||
pub mod entity_table;
|
||||
mod error_toast;
|
||||
pub mod filter_bar;
|
||||
mod help_overlay;
|
||||
mod loading;
|
||||
mod status_bar;
|
||||
|
||||
pub use breadcrumb::render_breadcrumb;
|
||||
pub use entity_table::{ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table};
|
||||
pub use error_toast::render_error_toast;
|
||||
pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
|
||||
pub use help_overlay::render_help_overlay;
|
||||
pub use loading::render_loading;
|
||||
pub use status_bar::render_status_bar;
|
||||
|
||||
554
crates/lore-tui/src/view/dashboard.rs
Normal file
554
crates/lore-tui/src/view/dashboard.rs
Normal file
@@ -0,0 +1,554 @@
|
||||
#![allow(dead_code)] // Phase 2: wired into render_screen dispatch
|
||||
|
||||
//! Dashboard screen view — entity counts, project sync status, recent activity.
|
||||
//!
|
||||
//! Responsive layout using [`crate::layout::classify_width`]:
|
||||
//! - Wide (Lg/Xl, >=120 cols): 3-column `[Stats | Projects | Recent]`
|
||||
//! - Medium (Md, 90–119): 2-column `[Stats+Projects | Recent]`
|
||||
//! - Narrow (Xs/Sm, <90): single column stacked
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::layout::{Breakpoint, Constraint, Flex};
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::layout::classify_width;
|
||||
use crate::state::dashboard::{DashboardState, EntityCounts, LastSyncInfo, RecentActivityItem};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (Flexoki palette — will use injected Theme in a later phase)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
|
||||
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
|
||||
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
|
||||
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
||||
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
|
||||
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the full dashboard screen into `area`.
|
||||
pub fn render_dashboard(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||
if area.height < 2 || area.width < 10 {
|
||||
return; // Too small to render.
|
||||
}
|
||||
|
||||
let bp = classify_width(area.width);
|
||||
|
||||
match bp {
|
||||
Breakpoint::Lg | Breakpoint::Xl => render_wide(frame, state, area),
|
||||
Breakpoint::Md => render_medium(frame, state, area),
|
||||
Breakpoint::Xs | Breakpoint::Sm => render_narrow(frame, state, area),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wide: 3-column [Stats | Projects | Recent Activity].
|
||||
fn render_wide(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||
let cols = Flex::horizontal()
|
||||
.constraints([
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_stat_panel(frame, &state.counts, cols[0]);
|
||||
render_project_list(frame, state, cols[1]);
|
||||
render_recent_activity(frame, state, cols[2]);
|
||||
}
|
||||
|
||||
/// Medium: 2-column [Stats+Projects stacked | Recent Activity].
|
||||
fn render_medium(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||
let cols = Flex::horizontal()
|
||||
.constraints([Constraint::Ratio(2, 5), Constraint::Ratio(3, 5)])
|
||||
.split(area);
|
||||
|
||||
// Left column: stats on top, projects below.
|
||||
let left_rows = Flex::vertical()
|
||||
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
|
||||
.split(cols[0]);
|
||||
|
||||
render_stat_panel(frame, &state.counts, left_rows[0]);
|
||||
render_project_list(frame, state, left_rows[1]);
|
||||
|
||||
render_recent_activity(frame, state, cols[1]);
|
||||
}
|
||||
|
||||
/// Narrow: single column stacked.
|
||||
fn render_narrow(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||
let rows = Flex::vertical()
|
||||
.constraints([
|
||||
Constraint::Fixed(8), // stats
|
||||
Constraint::Fixed(4), // projects (compact)
|
||||
Constraint::Fill, // recent
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_stat_panel(frame, &state.counts, rows[0]);
|
||||
render_project_list(frame, state, rows[1]);
|
||||
render_recent_activity(frame, state, rows[2]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Entity counts panel.
|
||||
fn render_stat_panel(frame: &mut Frame<'_>, counts: &EntityCounts, area: Rect) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_cell = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let label_cell = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let value_cell = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
let x = area.x.saturating_add(1); // 1-char left padding
|
||||
|
||||
// Title
|
||||
frame.print_text_clipped(x, y, "Entity Counts", title_cell, max_x);
|
||||
y = y.saturating_add(1);
|
||||
|
||||
// Separator
|
||||
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
|
||||
y = y.saturating_add(1);
|
||||
|
||||
// Stats rows
|
||||
let stats: &[(&str, String)] = &[
|
||||
(
|
||||
"Issues",
|
||||
format!("{} open / {}", counts.issues_open, counts.issues_total),
|
||||
),
|
||||
(
|
||||
"MRs",
|
||||
format!("{} open / {}", counts.mrs_open, counts.mrs_total),
|
||||
),
|
||||
("Discussions", counts.discussions.to_string()),
|
||||
(
|
||||
"Notes",
|
||||
format!(
|
||||
"{} ({}% system)",
|
||||
counts.notes_total, counts.notes_system_pct
|
||||
),
|
||||
),
|
||||
("Documents", counts.documents.to_string()),
|
||||
("Embeddings", counts.embeddings.to_string()),
|
||||
];
|
||||
|
||||
for (label, value) in stats {
|
||||
if y >= area.y.saturating_add(area.height) {
|
||||
break;
|
||||
}
|
||||
let after_label = frame.print_text_clipped(x, y, label, label_cell, max_x);
|
||||
let after_colon = frame.print_text_clipped(after_label, y, ": ", label_cell, max_x);
|
||||
frame.print_text_clipped(after_colon, y, value, value_cell, max_x);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-project sync freshness list.
|
||||
fn render_project_list(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_cell = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let label_cell = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
let x = area.x.saturating_add(1);
|
||||
|
||||
frame.print_text_clipped(x, y, "Projects", title_cell, max_x);
|
||||
y = y.saturating_add(1);
|
||||
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
|
||||
y = y.saturating_add(1);
|
||||
|
||||
if state.projects.is_empty() {
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, "No projects synced", muted, max_x);
|
||||
return;
|
||||
}
|
||||
|
||||
for proj in &state.projects {
|
||||
if y >= area.y.saturating_add(area.height) {
|
||||
break;
|
||||
}
|
||||
|
||||
let freshness_color = staleness_color(proj.minutes_since_sync);
|
||||
let freshness_cell = Cell {
|
||||
fg: freshness_color,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let indicator = staleness_indicator(proj.minutes_since_sync);
|
||||
let after_dot = frame.print_text_clipped(x, y, &indicator, freshness_cell, max_x);
|
||||
let after_space = frame.print_text_clipped(after_dot, y, " ", label_cell, max_x);
|
||||
frame.print_text_clipped(after_space, y, &proj.path, label_cell, max_x);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Last sync summary if available.
|
||||
if let Some(ref sync) = state.last_sync
|
||||
&& y < area.y.saturating_add(area.height)
|
||||
{
|
||||
y = y.saturating_add(1); // blank line
|
||||
render_sync_summary(frame, sync, x, y, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollable recent activity list.
|
||||
fn render_recent_activity(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_cell = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
let x = area.x.saturating_add(1);
|
||||
|
||||
frame.print_text_clipped(x, y, "Recent Activity", title_cell, max_x);
|
||||
y = y.saturating_add(1);
|
||||
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
|
||||
y = y.saturating_add(1);
|
||||
|
||||
if state.recent.is_empty() {
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, "No recent activity", muted, max_x);
|
||||
return;
|
||||
}
|
||||
|
||||
let visible_rows = (area.y.saturating_add(area.height)).saturating_sub(y) as usize;
|
||||
let items = &state.recent;
|
||||
let start = state.scroll_offset.min(items.len().saturating_sub(1));
|
||||
let end = (start + visible_rows).min(items.len());
|
||||
|
||||
for item in &items[start..end] {
|
||||
if y >= area.y.saturating_add(area.height) {
|
||||
break;
|
||||
}
|
||||
render_activity_row(frame, item, x, y, max_x);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
// Scroll indicator if there's more content.
|
||||
if end < items.len() && y < area.y.saturating_add(area.height) {
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let remaining = items.len() - end;
|
||||
frame.print_text_clipped(x, y, &format!("... {remaining} more"), muted, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a single recent activity row.
|
||||
fn render_activity_row(
|
||||
frame: &mut Frame<'_>,
|
||||
item: &RecentActivityItem,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) {
|
||||
let type_color = if item.entity_type == "issue" {
|
||||
CYAN
|
||||
} else {
|
||||
ACCENT
|
||||
};
|
||||
let type_cell = Cell {
|
||||
fg: type_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let text_cell = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted_cell = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let type_label = if item.entity_type == "issue" {
|
||||
format!("#{}", item.iid)
|
||||
} else {
|
||||
format!("!{}", item.iid)
|
||||
};
|
||||
|
||||
let after_type = frame.print_text_clipped(x, y, &type_label, type_cell, max_x);
|
||||
let after_space = frame.print_text_clipped(after_type, y, " ", text_cell, max_x);
|
||||
|
||||
// Truncate title to leave room for time.
|
||||
let time_str = format_relative_time(item.minutes_ago);
|
||||
let time_width = time_str.len() as u16 + 2; // " " + time
|
||||
let title_max = max_x.saturating_sub(time_width);
|
||||
|
||||
let after_title = frame.print_text_clipped(after_space, y, &item.title, text_cell, title_max);
|
||||
|
||||
// Right-align time string.
|
||||
let time_x = max_x.saturating_sub(time_str.len() as u16 + 1);
|
||||
if time_x > after_title {
|
||||
frame.print_text_clipped(time_x, y, &time_str, muted_cell, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a last-sync summary line.
|
||||
fn render_sync_summary(frame: &mut Frame<'_>, sync: &LastSyncInfo, x: u16, y: u16, max_x: u16) {
|
||||
let status_color = if sync.status == "succeeded" {
|
||||
GREEN
|
||||
} else {
|
||||
RED
|
||||
};
|
||||
let cell = Cell {
|
||||
fg: status_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let label_end = frame.print_text_clipped(x, y, "Last sync: ", muted, max_x);
|
||||
let status_end = frame.print_text_clipped(label_end, y, &sync.status, cell, max_x);
|
||||
|
||||
if let Some(ref err) = sync.error {
|
||||
let err_cell = Cell {
|
||||
fg: RED,
|
||||
..Cell::default()
|
||||
};
|
||||
let after_space = frame.print_text_clipped(status_end, y, " — ", muted, max_x);
|
||||
frame.print_text_clipped(after_space, y, err, err_cell, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a horizontal rule across a row.
|
||||
fn render_horizontal_rule(frame: &mut Frame<'_>, x: u16, y: u16, width: u16, color: PackedRgba) {
|
||||
let cell = Cell {
|
||||
fg: color,
|
||||
..Cell::default()
|
||||
};
|
||||
let rule = "─".repeat(width as usize);
|
||||
frame.print_text_clipped(x, y, &rule, cell, x.saturating_add(width));
|
||||
}
|
||||
|
||||
/// Staleness color: green <60min, yellow <360min, red >360min.
|
||||
const fn staleness_color(minutes: u64) -> PackedRgba {
|
||||
if minutes == u64::MAX {
|
||||
RED // Never synced.
|
||||
} else if minutes < 60 {
|
||||
GREEN
|
||||
} else if minutes < 360 {
|
||||
YELLOW
|
||||
} else {
|
||||
RED
|
||||
}
|
||||
}
|
||||
|
||||
/// Staleness dot indicator.
|
||||
fn staleness_indicator(minutes: u64) -> String {
|
||||
if minutes == u64::MAX {
|
||||
"● never".to_string()
|
||||
} else if minutes < 60 {
|
||||
format!("● {minutes}m ago")
|
||||
} else if minutes < 1440 {
|
||||
format!("● {}h ago", minutes / 60)
|
||||
} else {
|
||||
format!("● {}d ago", minutes / 1440)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format relative time for activity feed.
|
||||
fn format_relative_time(minutes: u64) -> String {
|
||||
if minutes == 0 {
|
||||
"just now".to_string()
|
||||
} else if minutes < 60 {
|
||||
format!("{minutes}m ago")
|
||||
} else if minutes < 1440 {
|
||||
format!("{}h ago", minutes / 60)
|
||||
} else {
|
||||
format!("{}d ago", minutes / 1440)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::dashboard::{DashboardData, EntityCounts, ProjectSyncInfo};
|
||||
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_state() -> DashboardState {
|
||||
let mut state = DashboardState::default();
|
||||
state.update(DashboardData {
|
||||
counts: EntityCounts {
|
||||
issues_open: 42,
|
||||
issues_total: 100,
|
||||
mrs_open: 10,
|
||||
mrs_total: 50,
|
||||
discussions: 200,
|
||||
notes_total: 500,
|
||||
notes_system_pct: 30,
|
||||
documents: 80,
|
||||
embeddings: 75,
|
||||
},
|
||||
projects: vec![
|
||||
ProjectSyncInfo {
|
||||
path: "group/alpha".into(),
|
||||
minutes_since_sync: 15,
|
||||
},
|
||||
ProjectSyncInfo {
|
||||
path: "group/beta".into(),
|
||||
minutes_since_sync: 120,
|
||||
},
|
||||
],
|
||||
recent: vec![RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: 42,
|
||||
title: "Fix authentication bug".into(),
|
||||
state: "opened".into(),
|
||||
minutes_ago: 5,
|
||||
}],
|
||||
last_sync: None,
|
||||
});
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dashboard_wide_no_panic() {
|
||||
with_frame!(140, 30, |frame| {
|
||||
let state = sample_state();
|
||||
let area = Rect::new(0, 0, 140, 30);
|
||||
render_dashboard(&mut frame, &state, area);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dashboard_medium_no_panic() {
|
||||
with_frame!(100, 24, |frame| {
|
||||
let state = sample_state();
|
||||
let area = Rect::new(0, 0, 100, 24);
|
||||
render_dashboard(&mut frame, &state, area);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dashboard_narrow_no_panic() {
|
||||
with_frame!(60, 20, |frame| {
|
||||
let state = sample_state();
|
||||
let area = Rect::new(0, 0, 60, 20);
|
||||
render_dashboard(&mut frame, &state, area);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dashboard_tiny_noop() {
|
||||
with_frame!(5, 1, |frame| {
|
||||
let state = DashboardState::default();
|
||||
let area = Rect::new(0, 0, 5, 1);
|
||||
render_dashboard(&mut frame, &state, area);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_dashboard_empty_state_no_panic() {
|
||||
with_frame!(120, 24, |frame| {
|
||||
let state = DashboardState::default();
|
||||
let area = Rect::new(0, 0, 120, 24);
|
||||
render_dashboard(&mut frame, &state, area);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_staleness_color_thresholds() {
|
||||
assert_eq!(staleness_color(0), GREEN);
|
||||
assert_eq!(staleness_color(59), GREEN);
|
||||
assert_eq!(staleness_color(60), YELLOW);
|
||||
assert_eq!(staleness_color(359), YELLOW);
|
||||
assert_eq!(staleness_color(360), RED);
|
||||
assert_eq!(staleness_color(u64::MAX), RED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_staleness_indicator() {
|
||||
assert_eq!(staleness_indicator(15), "● 15m ago");
|
||||
assert_eq!(staleness_indicator(120), "● 2h ago");
|
||||
assert_eq!(staleness_indicator(2880), "● 2d ago");
|
||||
assert_eq!(staleness_indicator(u64::MAX), "● never");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time() {
|
||||
assert_eq!(format_relative_time(0), "just now");
|
||||
assert_eq!(format_relative_time(5), "5m ago");
|
||||
assert_eq!(format_relative_time(90), "1h ago");
|
||||
assert_eq!(format_relative_time(1500), "1d ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stat_panel_renders_title() {
|
||||
with_frame!(40, 10, |frame| {
|
||||
let counts = EntityCounts {
|
||||
issues_open: 3,
|
||||
issues_total: 10,
|
||||
..Default::default()
|
||||
};
|
||||
render_stat_panel(&mut frame, &counts, Rect::new(0, 0, 40, 10));
|
||||
|
||||
// Check that 'E' from "Entity Counts" is rendered at x=1, y=0.
|
||||
let cell = frame.buffer.get(1, 0).unwrap();
|
||||
assert_eq!(cell.content.as_char(), Some('E'), "Expected 'E' at (1,0)");
|
||||
});
|
||||
}
|
||||
}
|
||||
353
crates/lore-tui/src/view/issue_list.rs
Normal file
353
crates/lore-tui/src/view/issue_list.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||
|
||||
//! Issue list screen view.
|
||||
//!
|
||||
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
|
||||
//! with issue-specific column definitions and [`TableRow`] implementation.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::state::issue_list::{IssueListRow, IssueListState, SortField, SortOrder};
|
||||
use crate::view::common::entity_table::{
|
||||
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
|
||||
};
|
||||
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TableRow implementation for IssueListRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl TableRow for IssueListRow {
|
||||
fn cells(&self, col_count: usize) -> Vec<String> {
|
||||
let mut cells = Vec::with_capacity(col_count);
|
||||
|
||||
// Column order must match ISSUE_COLUMNS definition.
|
||||
// 0: IID
|
||||
cells.push(format!("#{}", self.iid));
|
||||
// 1: Title
|
||||
cells.push(self.title.clone());
|
||||
// 2: State
|
||||
cells.push(self.state.clone());
|
||||
// 3: Author
|
||||
cells.push(self.author.clone());
|
||||
// 4: Labels
|
||||
cells.push(self.labels.join(", "));
|
||||
// 5: Project
|
||||
cells.push(self.project_path.clone());
|
||||
|
||||
cells.truncate(col_count);
|
||||
cells
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Column definitions for the issue list table.
|
||||
const ISSUE_COLUMNS: &[ColumnDef] = &[
|
||||
ColumnDef {
|
||||
name: "IID",
|
||||
min_width: 5,
|
||||
flex_weight: 0,
|
||||
priority: 0,
|
||||
align: Align::Right,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Title",
|
||||
min_width: 15,
|
||||
flex_weight: 4,
|
||||
priority: 0,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "State",
|
||||
min_width: 7,
|
||||
flex_weight: 0,
|
||||
priority: 0,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Author",
|
||||
min_width: 8,
|
||||
flex_weight: 1,
|
||||
priority: 1,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Labels",
|
||||
min_width: 10,
|
||||
flex_weight: 2,
|
||||
priority: 2,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Project",
|
||||
min_width: 12,
|
||||
flex_weight: 1,
|
||||
priority: 3,
|
||||
align: Align::Left,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn table_colors() -> TableColors {
|
||||
TableColors {
|
||||
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
|
||||
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
|
||||
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
|
||||
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_colors() -> FilterBarColors {
|
||||
FilterBarColors {
|
||||
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
|
||||
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
|
||||
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the full issue list screen.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// Row 0: [Filter bar: / filter input_________]
|
||||
/// Row 1: [chip1] [chip2] (if filter active)
|
||||
/// Row 2: ─────────────────────────────────────
|
||||
/// Row 3..N: IID Title State Author ...
|
||||
/// ───────────────────────────────────────
|
||||
/// #42 Fix login bug open alice ...
|
||||
/// #41 Add tests open bob ...
|
||||
/// Bottom: Showing 42 of 128 issues
|
||||
/// ```
|
||||
pub fn render_issue_list(frame: &mut Frame<'_>, state: &IssueListState, area: Rect) {
|
||||
if area.height < 3 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut y = area.y;
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
// -- Filter bar ---------------------------------------------------------
|
||||
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
|
||||
let fb_state = FilterBarState {
|
||||
input: state.filter_input.clone(),
|
||||
cursor: state.filter_input.len(),
|
||||
focused: state.filter_focused,
|
||||
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
|
||||
unknown_fields: Vec::new(),
|
||||
};
|
||||
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
|
||||
y = y.saturating_add(filter_rows);
|
||||
|
||||
// -- Status line (total count) ------------------------------------------
|
||||
let remaining_height = area.height.saturating_sub(y - area.y);
|
||||
if remaining_height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve bottom row for status.
|
||||
let table_height = remaining_height.saturating_sub(1);
|
||||
let status_y = y.saturating_add(table_height);
|
||||
|
||||
// -- Entity table -------------------------------------------------------
|
||||
let sort_col = match state.sort_field {
|
||||
SortField::UpdatedAt => 0, // Map to IID column (closest visual proxy)
|
||||
SortField::Iid => 0,
|
||||
SortField::Title => 1,
|
||||
SortField::State => 2,
|
||||
SortField::Author => 3,
|
||||
};
|
||||
|
||||
let mut table_state = EntityTableState {
|
||||
selected: state.selected_index,
|
||||
scroll_offset: state.scroll_offset,
|
||||
sort_column: sort_col,
|
||||
sort_ascending: matches!(state.sort_order, SortOrder::Asc),
|
||||
};
|
||||
|
||||
let table_area = Rect::new(area.x, y, area.width, table_height);
|
||||
render_entity_table(
|
||||
frame,
|
||||
&state.rows,
|
||||
ISSUE_COLUMNS,
|
||||
&mut table_state,
|
||||
table_area,
|
||||
&table_colors(),
|
||||
);
|
||||
|
||||
// -- Bottom status ------------------------------------------------------
|
||||
if status_y < area.y.saturating_add(area.height) {
|
||||
render_status_line(frame, state, area.x, status_y, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the bottom status line showing row count and pagination info.
|
||||
fn render_status_line(frame: &mut Frame<'_>, state: &IssueListState, x: u16, y: u16, max_x: u16) {
|
||||
let muted = Cell {
|
||||
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let status = if state.rows.is_empty() {
|
||||
"No issues found".to_string()
|
||||
} else {
|
||||
let showing = state.rows.len();
|
||||
let total = state.total_count;
|
||||
if state.next_cursor.is_some() {
|
||||
format!("Showing {showing} of {total} issues (more available)")
|
||||
} else {
|
||||
format!("Showing {showing} of {total} issues")
|
||||
}
|
||||
};
|
||||
|
||||
frame.print_text_clipped(x, y, &status, muted, 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
|
||||
}};
|
||||
}
|
||||
|
||||
fn sample_state(row_count: usize) -> IssueListState {
|
||||
let rows: Vec<IssueListRow> = (0..row_count)
|
||||
.map(|i| IssueListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: (i + 1) as i64,
|
||||
title: format!("Issue {}", i + 1),
|
||||
state: if i % 2 == 0 { "opened" } else { "closed" }.into(),
|
||||
author: "taylor".into(),
|
||||
labels: if i == 0 {
|
||||
vec!["bug".into(), "critical".into()]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||
})
|
||||
.collect();
|
||||
|
||||
IssueListState {
|
||||
total_count: row_count as u64,
|
||||
rows,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_list_no_panic() {
|
||||
with_frame!(120, 30, |frame| {
|
||||
let state = sample_state(10);
|
||||
render_issue_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_list_empty_no_panic() {
|
||||
with_frame!(80, 20, |frame| {
|
||||
let state = IssueListState::default();
|
||||
render_issue_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_list_tiny_noop() {
|
||||
with_frame!(5, 2, |frame| {
|
||||
let state = sample_state(5);
|
||||
render_issue_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
|
||||
// Should not panic with too-small area.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_list_narrow_no_panic() {
|
||||
with_frame!(40, 15, |frame| {
|
||||
let state = sample_state(5);
|
||||
render_issue_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_list_with_filter_no_panic() {
|
||||
with_frame!(100, 25, |frame| {
|
||||
let mut state = sample_state(5);
|
||||
state.filter_input = "state:opened".into();
|
||||
state.filter_focused = true;
|
||||
render_issue_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_list_row_cells() {
|
||||
let row = IssueListRow {
|
||||
project_path: "group/proj".into(),
|
||||
iid: 42,
|
||||
title: "Fix bug".into(),
|
||||
state: "opened".into(),
|
||||
author: "alice".into(),
|
||||
labels: vec!["bug".into(), "urgent".into()],
|
||||
updated_at: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
let cells = row.cells(6);
|
||||
assert_eq!(cells[0], "#42");
|
||||
assert_eq!(cells[1], "Fix bug");
|
||||
assert_eq!(cells[2], "opened");
|
||||
assert_eq!(cells[3], "alice");
|
||||
assert_eq!(cells[4], "bug, urgent");
|
||||
assert_eq!(cells[5], "group/proj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_list_row_cells_truncated() {
|
||||
let row = IssueListRow {
|
||||
project_path: "g/p".into(),
|
||||
iid: 1,
|
||||
title: "t".into(),
|
||||
state: "opened".into(),
|
||||
author: "a".into(),
|
||||
labels: vec![],
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// Request fewer columns than available.
|
||||
let cells = row.cells(3);
|
||||
assert_eq!(cells.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_count() {
|
||||
assert_eq!(ISSUE_COLUMNS.len(), 6);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,23 @@
|
||||
//! bar, and optional overlays (help, error toast).
|
||||
|
||||
pub mod common;
|
||||
pub mod dashboard;
|
||||
pub mod issue_list;
|
||||
pub mod mr_list;
|
||||
|
||||
use ftui::layout::{Constraint, Flex};
|
||||
use ftui::render::cell::PackedRgba;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::app::LoreApp;
|
||||
use crate::message::Screen;
|
||||
|
||||
use common::{
|
||||
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
|
||||
};
|
||||
use dashboard::render_dashboard;
|
||||
use issue_list::render_issue_list;
|
||||
use mr_list::render_mr_list;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
|
||||
@@ -79,12 +86,14 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
|
||||
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
|
||||
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
|
||||
|
||||
// Per-screen content dispatch (Phase 2+).
|
||||
// match screen {
|
||||
// Screen::Dashboard => ...,
|
||||
// Screen::IssueList => ...,
|
||||
// ...
|
||||
// }
|
||||
// Per-screen content dispatch (other screens wired in later phases).
|
||||
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);
|
||||
} else if screen == &Screen::MrList {
|
||||
render_mr_list(frame, &app.state.mr_list, content_area);
|
||||
}
|
||||
|
||||
// --- Status bar ---
|
||||
render_status_bar(
|
||||
|
||||
390
crates/lore-tui/src/view/mr_list.rs
Normal file
390
crates/lore-tui/src/view/mr_list.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||
|
||||
//! MR list screen view.
|
||||
//!
|
||||
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
|
||||
//! with MR-specific column definitions and [`TableRow`] implementation.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::state::mr_list::{MrListRow, MrListState, MrSortField, MrSortOrder};
|
||||
use crate::view::common::entity_table::{
|
||||
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
|
||||
};
|
||||
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TableRow implementation for MrListRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl TableRow for MrListRow {
|
||||
fn cells(&self, col_count: usize) -> Vec<String> {
|
||||
let mut cells = Vec::with_capacity(col_count);
|
||||
|
||||
// Column order must match MR_COLUMNS definition.
|
||||
// 0: IID (with draft indicator)
|
||||
let iid_text = if self.draft {
|
||||
format!("!{} [WIP]", self.iid)
|
||||
} else {
|
||||
format!("!{}", self.iid)
|
||||
};
|
||||
cells.push(iid_text);
|
||||
// 1: Title
|
||||
cells.push(self.title.clone());
|
||||
// 2: State
|
||||
cells.push(self.state.clone());
|
||||
// 3: Author
|
||||
cells.push(self.author.clone());
|
||||
// 4: Target Branch
|
||||
cells.push(self.target_branch.clone());
|
||||
// 5: Labels
|
||||
cells.push(self.labels.join(", "));
|
||||
// 6: Project
|
||||
cells.push(self.project_path.clone());
|
||||
|
||||
cells.truncate(col_count);
|
||||
cells
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Column definitions for the MR list table.
|
||||
const MR_COLUMNS: &[ColumnDef] = &[
|
||||
ColumnDef {
|
||||
name: "IID",
|
||||
min_width: 6,
|
||||
flex_weight: 0,
|
||||
priority: 0,
|
||||
align: Align::Right,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Title",
|
||||
min_width: 15,
|
||||
flex_weight: 4,
|
||||
priority: 0,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "State",
|
||||
min_width: 7,
|
||||
flex_weight: 0,
|
||||
priority: 0,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Author",
|
||||
min_width: 8,
|
||||
flex_weight: 1,
|
||||
priority: 1,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Target",
|
||||
min_width: 8,
|
||||
flex_weight: 1,
|
||||
priority: 1,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Labels",
|
||||
min_width: 10,
|
||||
flex_weight: 2,
|
||||
priority: 2,
|
||||
align: Align::Left,
|
||||
},
|
||||
ColumnDef {
|
||||
name: "Project",
|
||||
min_width: 12,
|
||||
flex_weight: 1,
|
||||
priority: 3,
|
||||
align: Align::Left,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn table_colors() -> TableColors {
|
||||
TableColors {
|
||||
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
|
||||
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
|
||||
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
|
||||
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_colors() -> FilterBarColors {
|
||||
FilterBarColors {
|
||||
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
|
||||
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
|
||||
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the full MR list screen.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// Row 0: [Filter bar: / filter input_________]
|
||||
/// Row 1: [chip1] [chip2] (if filter active)
|
||||
/// Row 2: -----------------------------------------
|
||||
/// Row 3..N: IID Title State Author ...
|
||||
/// -----------------------------------------
|
||||
/// !42 Fix pipeline opened alice ...
|
||||
/// !41 Add CI config merged bob ...
|
||||
/// Bottom: Showing 42 of 128 merge requests
|
||||
/// ```
|
||||
pub fn render_mr_list(frame: &mut Frame<'_>, state: &MrListState, area: Rect) {
|
||||
if area.height < 3 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut y = area.y;
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
// -- Filter bar ---------------------------------------------------------
|
||||
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
|
||||
let fb_state = FilterBarState {
|
||||
input: state.filter_input.clone(),
|
||||
cursor: state.filter_input.len(),
|
||||
focused: state.filter_focused,
|
||||
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
|
||||
unknown_fields: Vec::new(),
|
||||
};
|
||||
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
|
||||
y = y.saturating_add(filter_rows);
|
||||
|
||||
// -- Status line (total count) ------------------------------------------
|
||||
let remaining_height = area.height.saturating_sub(y - area.y);
|
||||
if remaining_height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserve bottom row for status.
|
||||
let table_height = remaining_height.saturating_sub(1);
|
||||
let status_y = y.saturating_add(table_height);
|
||||
|
||||
// -- Entity table -------------------------------------------------------
|
||||
let sort_col = match state.sort_field {
|
||||
MrSortField::UpdatedAt | MrSortField::Iid => 0,
|
||||
MrSortField::Title => 1,
|
||||
MrSortField::State => 2,
|
||||
MrSortField::Author => 3,
|
||||
MrSortField::TargetBranch => 4,
|
||||
};
|
||||
|
||||
let mut table_state = EntityTableState {
|
||||
selected: state.selected_index,
|
||||
scroll_offset: state.scroll_offset,
|
||||
sort_column: sort_col,
|
||||
sort_ascending: matches!(state.sort_order, MrSortOrder::Asc),
|
||||
};
|
||||
|
||||
let table_area = Rect::new(area.x, y, area.width, table_height);
|
||||
render_entity_table(
|
||||
frame,
|
||||
&state.rows,
|
||||
MR_COLUMNS,
|
||||
&mut table_state,
|
||||
table_area,
|
||||
&table_colors(),
|
||||
);
|
||||
|
||||
// -- Bottom status ------------------------------------------------------
|
||||
if status_y < area.y.saturating_add(area.height) {
|
||||
render_status_line(frame, state, area.x, status_y, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the bottom status line showing row count and pagination info.
|
||||
fn render_status_line(frame: &mut Frame<'_>, state: &MrListState, x: u16, y: u16, max_x: u16) {
|
||||
let muted = Cell {
|
||||
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let status = if state.rows.is_empty() {
|
||||
"No merge requests found".to_string()
|
||||
} else {
|
||||
let showing = state.rows.len();
|
||||
let total = state.total_count;
|
||||
if state.next_cursor.is_some() {
|
||||
format!("Showing {showing} of {total} merge requests (more available)")
|
||||
} else {
|
||||
format!("Showing {showing} of {total} merge requests")
|
||||
}
|
||||
};
|
||||
|
||||
frame.print_text_clipped(x, y, &status, muted, 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
|
||||
}};
|
||||
}
|
||||
|
||||
fn sample_state(row_count: usize) -> MrListState {
|
||||
let rows: Vec<MrListRow> = (0..row_count)
|
||||
.map(|i| MrListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: (i + 1) as i64,
|
||||
title: format!("MR {}", i + 1),
|
||||
state: if i % 2 == 0 { "opened" } else { "merged" }.into(),
|
||||
author: "taylor".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: if i == 0 {
|
||||
vec!["backend".into(), "urgent".into()]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||
draft: i % 3 == 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
MrListState {
|
||||
total_count: row_count as u64,
|
||||
rows,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_list_no_panic() {
|
||||
with_frame!(120, 30, |frame| {
|
||||
let state = sample_state(10);
|
||||
render_mr_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_list_empty_no_panic() {
|
||||
with_frame!(80, 20, |frame| {
|
||||
let state = MrListState::default();
|
||||
render_mr_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_list_tiny_noop() {
|
||||
with_frame!(5, 2, |frame| {
|
||||
let state = sample_state(5);
|
||||
render_mr_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_list_narrow_no_panic() {
|
||||
with_frame!(40, 15, |frame| {
|
||||
let state = sample_state(5);
|
||||
render_mr_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_list_with_filter_no_panic() {
|
||||
with_frame!(100, 25, |frame| {
|
||||
let mut state = sample_state(5);
|
||||
state.filter_input = "state:opened".into();
|
||||
state.filter_focused = true;
|
||||
render_mr_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_list_row_cells() {
|
||||
let row = MrListRow {
|
||||
project_path: "group/proj".into(),
|
||||
iid: 42,
|
||||
title: "Fix pipeline".into(),
|
||||
state: "opened".into(),
|
||||
author: "alice".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: vec!["backend".into(), "urgent".into()],
|
||||
updated_at: 1_700_000_000_000,
|
||||
draft: false,
|
||||
};
|
||||
|
||||
let cells = row.cells(7);
|
||||
assert_eq!(cells[0], "!42");
|
||||
assert_eq!(cells[1], "Fix pipeline");
|
||||
assert_eq!(cells[2], "opened");
|
||||
assert_eq!(cells[3], "alice");
|
||||
assert_eq!(cells[4], "main");
|
||||
assert_eq!(cells[5], "backend, urgent");
|
||||
assert_eq!(cells[6], "group/proj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_list_row_cells_draft() {
|
||||
let row = MrListRow {
|
||||
project_path: "g/p".into(),
|
||||
iid: 7,
|
||||
title: "WIP MR".into(),
|
||||
state: "opened".into(),
|
||||
author: "bob".into(),
|
||||
target_branch: "develop".into(),
|
||||
labels: vec![],
|
||||
updated_at: 0,
|
||||
draft: true,
|
||||
};
|
||||
|
||||
let cells = row.cells(7);
|
||||
assert_eq!(cells[0], "!7 [WIP]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_list_row_cells_truncated() {
|
||||
let row = MrListRow {
|
||||
project_path: "g/p".into(),
|
||||
iid: 1,
|
||||
title: "t".into(),
|
||||
state: "opened".into(),
|
||||
author: "a".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: vec![],
|
||||
updated_at: 0,
|
||||
draft: false,
|
||||
};
|
||||
|
||||
let cells = row.cells(3);
|
||||
assert_eq!(cells.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_count() {
|
||||
assert_eq!(MR_COLUMNS.len(), 7);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user