Four view modules (search, command_palette, file_history, trace) each had their own copy of cursor_cell_offset / text_cell_width for converting a byte-offset cursor position to a display-column offset. Phase 5 introduced a proper text_width module with these functions; this commit removes the duplicates and rewires all call sites to use crate::text_width. - search.rs: removed local text_cell_width + cursor_cell_offset definitions - command_palette.rs: removed local cursor_cell_offset definition - file_history.rs: replaced inline chars().count() cursor calc with import - trace.rs: replaced inline chars().count() cursor calc with import
571 lines
17 KiB
Rust
571 lines
17 KiB
Rust
#![allow(dead_code)]
|
|
|
|
//! File History view — renders per-file MR timeline with rename chains.
|
|
//!
|
|
//! Layout:
|
|
//! ```text
|
|
//! +-----------------------------------+
|
|
//! | Path: [src/lib.rs_] [R] [M] [D] | <- path input + option toggles
|
|
//! | Rename chain: a.rs -> b.rs -> ... | <- shown when renames followed
|
|
//! | 5 merge requests across 2 paths | <- summary line
|
|
//! +-----------------------------------+
|
|
//! | > !42 Fix auth @alice modified ... | <- MR list (selected = >)
|
|
//! | !39 Refactor @bob renamed ... |
|
|
//! | @carol: "This looks off..." | <- inline discussion (if toggled)
|
|
//! +-----------------------------------+
|
|
//! | r:renames m:merged d:discussions | <- hint bar
|
|
//! +-----------------------------------+
|
|
//! ```
|
|
|
|
use ftui::render::cell::{Cell, PackedRgba};
|
|
use ftui::render::drawing::Draw;
|
|
use ftui::render::frame::Frame;
|
|
|
|
use super::common::truncate_str;
|
|
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
|
|
use crate::text_width::cursor_cell_offset;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Colors (Flexoki palette)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
|
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
|
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
|
|
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
|
|
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
|
|
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
|
|
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
|
|
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
|
const SELECTION_BG: PackedRgba = PackedRgba::rgb(0x34, 0x34, 0x31); // bg-3
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Render the File History screen.
|
|
pub fn render_file_history(
|
|
frame: &mut Frame<'_>,
|
|
state: &FileHistoryState,
|
|
area: ftui::core::geometry::Rect,
|
|
) {
|
|
if area.width < 10 || area.height < 3 {
|
|
return; // Terminal too small.
|
|
}
|
|
|
|
let x = area.x;
|
|
let max_x = area.right();
|
|
let width = area.width;
|
|
let mut y = area.y;
|
|
|
|
// --- Path input bar ---
|
|
render_path_input(frame, state, x, y, width);
|
|
y += 1;
|
|
|
|
if area.height < 5 {
|
|
return;
|
|
}
|
|
|
|
// --- Option toggles indicator ---
|
|
render_toggle_indicators(frame, state, x, y, width);
|
|
y += 1;
|
|
|
|
// --- Loading indicator ---
|
|
if state.loading {
|
|
render_loading(frame, x, y, max_x);
|
|
return;
|
|
}
|
|
|
|
let Some(result) = &state.result else {
|
|
render_empty_state(frame, x, y, max_x);
|
|
return;
|
|
};
|
|
|
|
// --- Rename chain (if followed) ---
|
|
if result.renames_followed && result.rename_chain.len() > 1 {
|
|
render_rename_chain(frame, &result.rename_chain, x, y, max_x);
|
|
y += 1;
|
|
}
|
|
|
|
// --- Summary line ---
|
|
render_summary(frame, result, x, y, max_x);
|
|
y += 1;
|
|
|
|
if result.merge_requests.is_empty() {
|
|
render_no_results(frame, x, y, max_x);
|
|
return;
|
|
}
|
|
|
|
// Reserve 1 row for hint bar at the bottom.
|
|
let hint_y = area.bottom().saturating_sub(1);
|
|
let list_height = hint_y.saturating_sub(y) as usize;
|
|
|
|
if list_height == 0 {
|
|
return;
|
|
}
|
|
|
|
// --- MR list ---
|
|
render_mr_list(frame, result, state, x, y, width, list_height);
|
|
|
|
// --- Hint bar ---
|
|
render_hint_bar(frame, x, hint_y, max_x);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn render_path_input(frame: &mut Frame<'_>, state: &FileHistoryState, x: u16, y: u16, width: u16) {
|
|
let max_x = x + width;
|
|
let label = "Path: ";
|
|
let label_style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
let after_label = frame.print_text_clipped(x, y, label, label_style, max_x);
|
|
|
|
// Input text.
|
|
let input_style = Cell {
|
|
fg: if state.path_focused { TEXT } else { TEXT_MUTED },
|
|
..Cell::default()
|
|
};
|
|
let display_text = if state.path_input.is_empty() && !state.path_focused {
|
|
"type a file path..."
|
|
} else {
|
|
&state.path_input
|
|
};
|
|
frame.print_text_clipped(after_label, y, display_text, input_style, max_x);
|
|
|
|
// Cursor indicator.
|
|
if state.path_focused {
|
|
let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
|
|
if cursor_x < max_x {
|
|
let cursor_cell = Cell {
|
|
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg
|
|
bg: TEXT,
|
|
..Cell::default()
|
|
};
|
|
let ch = state
|
|
.path_input
|
|
.get(state.path_cursor..)
|
|
.and_then(|s| s.chars().next())
|
|
.unwrap_or(' ');
|
|
frame.print_text_clipped(cursor_x, y, &ch.to_string(), cursor_cell, max_x);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_toggle_indicators(
|
|
frame: &mut Frame<'_>,
|
|
state: &FileHistoryState,
|
|
x: u16,
|
|
y: u16,
|
|
width: u16,
|
|
) {
|
|
let max_x = x + width;
|
|
|
|
let on_style = Cell {
|
|
fg: GREEN,
|
|
..Cell::default()
|
|
};
|
|
let off_style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
|
|
let renames_tag = if state.follow_renames {
|
|
"[renames:on]"
|
|
} else {
|
|
"[renames:off]"
|
|
};
|
|
let merged_tag = if state.merged_only {
|
|
"[merged:on]"
|
|
} else {
|
|
"[merged:off]"
|
|
};
|
|
let disc_tag = if state.show_discussions {
|
|
"[disc:on]"
|
|
} else {
|
|
"[disc:off]"
|
|
};
|
|
|
|
let renames_style = if state.follow_renames {
|
|
on_style
|
|
} else {
|
|
off_style
|
|
};
|
|
let merged_style = if state.merged_only {
|
|
on_style
|
|
} else {
|
|
off_style
|
|
};
|
|
let disc_style = if state.show_discussions {
|
|
on_style
|
|
} else {
|
|
off_style
|
|
};
|
|
|
|
let after_r = frame.print_text_clipped(x + 1, y, renames_tag, renames_style, max_x);
|
|
let after_m = frame.print_text_clipped(after_r + 1, y, merged_tag, merged_style, max_x);
|
|
frame.print_text_clipped(after_m + 1, y, disc_tag, disc_style, max_x);
|
|
}
|
|
|
|
fn render_rename_chain(frame: &mut Frame<'_>, chain: &[String], x: u16, y: u16, max_x: u16) {
|
|
let label_style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
let chain_style = Cell {
|
|
fg: CYAN,
|
|
..Cell::default()
|
|
};
|
|
|
|
let after_label = frame.print_text_clipped(x + 1, y, "Renames: ", label_style, max_x);
|
|
let chain_str = chain.join(" -> ");
|
|
frame.print_text_clipped(after_label, y, &chain_str, chain_style, max_x);
|
|
}
|
|
|
|
fn render_summary(frame: &mut Frame<'_>, result: &FileHistoryResult, x: u16, y: u16, max_x: u16) {
|
|
let summary = if result.paths_searched > 1 {
|
|
format!(
|
|
"{} merge request{} across {} paths",
|
|
result.total_mrs,
|
|
if result.total_mrs == 1 { "" } else { "s" },
|
|
result.paths_searched,
|
|
)
|
|
} else {
|
|
format!(
|
|
"{} merge request{}",
|
|
result.total_mrs,
|
|
if result.total_mrs == 1 { "" } else { "s" },
|
|
)
|
|
};
|
|
|
|
let style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
|
|
}
|
|
|
|
fn render_mr_list(
|
|
frame: &mut Frame<'_>,
|
|
result: &FileHistoryResult,
|
|
state: &FileHistoryState,
|
|
x: u16,
|
|
start_y: u16,
|
|
width: u16,
|
|
height: usize,
|
|
) {
|
|
let max_x = x + width;
|
|
let offset = state.scroll_offset as usize;
|
|
|
|
for (i, mr) in result
|
|
.merge_requests
|
|
.iter()
|
|
.skip(offset)
|
|
.enumerate()
|
|
.take(height)
|
|
{
|
|
let y = start_y + i as u16;
|
|
let row_idx = offset + i;
|
|
let selected = row_idx == state.selected_mr_index;
|
|
|
|
// Selection background.
|
|
if selected {
|
|
let bg_cell = Cell {
|
|
bg: SELECTION_BG,
|
|
..Cell::default()
|
|
};
|
|
for col in x..max_x {
|
|
frame.buffer.set(col, y, bg_cell);
|
|
}
|
|
}
|
|
|
|
// State icon.
|
|
let (icon, icon_color) = match mr.state.as_str() {
|
|
"merged" => ("M", GREEN),
|
|
"opened" => ("O", YELLOW),
|
|
"closed" => ("C", RED),
|
|
_ => ("?", TEXT_MUTED),
|
|
};
|
|
let prefix = if selected { "> " } else { " " };
|
|
let sel_bg = if selected { SELECTION_BG } else { BG_SURFACE };
|
|
|
|
let prefix_style = Cell {
|
|
fg: ACCENT,
|
|
bg: sel_bg,
|
|
..Cell::default()
|
|
};
|
|
let after_prefix = frame.print_text_clipped(x, y, prefix, prefix_style, max_x);
|
|
|
|
let icon_style = Cell {
|
|
fg: icon_color,
|
|
bg: sel_bg,
|
|
..Cell::default()
|
|
};
|
|
let after_icon = frame.print_text_clipped(after_prefix, y, icon, icon_style, max_x);
|
|
|
|
// !iid
|
|
let iid_str = format!(" !{}", mr.iid);
|
|
let ref_style = Cell {
|
|
fg: ACCENT,
|
|
bg: sel_bg,
|
|
..Cell::default()
|
|
};
|
|
let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
|
|
|
|
// Title (truncated).
|
|
let title = truncate_str(&mr.title, 35);
|
|
let title_style = Cell {
|
|
fg: TEXT,
|
|
bg: sel_bg,
|
|
..Cell::default()
|
|
};
|
|
let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x);
|
|
|
|
// @author + change_type
|
|
let meta = format!(
|
|
"@{} {}",
|
|
truncate_str(&mr.author_username, 12),
|
|
mr.change_type
|
|
);
|
|
let meta_style = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: sel_bg,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(after_title + 1, y, &meta, meta_style, max_x);
|
|
}
|
|
|
|
// Inline discussion snippets (rendered beneath MRs when toggled on).
|
|
// For simplicity, discussions are shown as a separate block after the MR list
|
|
// in this initial implementation. Full inline rendering (grouped by MR) is
|
|
// a follow-up enhancement.
|
|
if state.show_discussions && !result.discussions.is_empty() {
|
|
let disc_start_y = start_y + result.merge_requests.len().min(height) as u16;
|
|
let remaining = height.saturating_sub(result.merge_requests.len().min(height));
|
|
render_discussions(frame, result, x, disc_start_y, max_x, remaining);
|
|
}
|
|
}
|
|
|
|
fn render_discussions(
|
|
frame: &mut Frame<'_>,
|
|
result: &FileHistoryResult,
|
|
x: u16,
|
|
start_y: u16,
|
|
max_x: u16,
|
|
max_rows: usize,
|
|
) {
|
|
if max_rows == 0 {
|
|
return;
|
|
}
|
|
|
|
let sep_style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(x + 1, start_y, "-- discussions --", sep_style, max_x);
|
|
|
|
let disc_style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
let author_style = Cell {
|
|
fg: CYAN,
|
|
..Cell::default()
|
|
};
|
|
|
|
for (i, disc) in result
|
|
.discussions
|
|
.iter()
|
|
.enumerate()
|
|
.take(max_rows.saturating_sub(1))
|
|
{
|
|
let y = start_y + 1 + i as u16;
|
|
let after_author = frame.print_text_clipped(
|
|
x + 2,
|
|
y,
|
|
&format!("@{}: ", disc.author_username),
|
|
author_style,
|
|
max_x,
|
|
);
|
|
let snippet = truncate_str(&disc.body_snippet, 60);
|
|
frame.print_text_clipped(after_author, y, &snippet, disc_style, max_x);
|
|
}
|
|
}
|
|
|
|
fn render_loading(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
|
|
let style = Cell {
|
|
fg: ACCENT,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(x + 1, y, "Loading file history...", style, max_x);
|
|
}
|
|
|
|
fn render_empty_state(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
|
|
let style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(
|
|
x + 1,
|
|
y,
|
|
"Enter a file path and press Enter to search.",
|
|
style,
|
|
max_x,
|
|
);
|
|
}
|
|
|
|
fn render_no_results(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
|
|
let style = Cell {
|
|
fg: TEXT_MUTED,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(x + 1, y, "No MRs found for this file.", style, max_x);
|
|
frame.print_text_clipped(
|
|
x + 1,
|
|
y + 1,
|
|
"Hint: Ensure 'lore sync' has fetched MR file changes.",
|
|
style,
|
|
max_x,
|
|
);
|
|
}
|
|
|
|
fn render_hint_bar(frame: &mut Frame<'_>, x: u16, y: u16, max_x: u16) {
|
|
let style = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
|
|
// Fill background.
|
|
for col in x..max_x {
|
|
frame.buffer.set(col, y, style);
|
|
}
|
|
|
|
let hints = "/:path r:renames m:merged d:discussions Enter:open MR q:back";
|
|
frame.print_text_clipped(x + 1, y, hints, style, max_x);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::file_history::{FileHistoryMr, FileHistoryResult, FileHistoryState};
|
|
use ftui::render::grapheme_pool::GraphemePool;
|
|
|
|
macro_rules! with_frame {
|
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
|
let mut pool = GraphemePool::new();
|
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
|
$body
|
|
}};
|
|
}
|
|
|
|
fn test_area(w: u16, h: u16) -> ftui::core::geometry::Rect {
|
|
ftui::core::geometry::Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width: w,
|
|
height: h,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_empty_no_panic() {
|
|
with_frame!(80, 24, |frame| {
|
|
let state = FileHistoryState::default();
|
|
render_file_history(&mut frame, &state, test_area(80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_tiny_terminal_noop() {
|
|
with_frame!(5, 2, |frame| {
|
|
let state = FileHistoryState::default();
|
|
render_file_history(&mut frame, &state, test_area(5, 2));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_loading() {
|
|
with_frame!(80, 24, |frame| {
|
|
let state = FileHistoryState {
|
|
loading: true,
|
|
..FileHistoryState::default()
|
|
};
|
|
render_file_history(&mut frame, &state, test_area(80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_with_results() {
|
|
with_frame!(100, 30, |frame| {
|
|
let state = FileHistoryState {
|
|
result: Some(FileHistoryResult {
|
|
path: "src/lib.rs".into(),
|
|
rename_chain: vec!["src/lib.rs".into()],
|
|
renames_followed: false,
|
|
merge_requests: vec![
|
|
FileHistoryMr {
|
|
iid: 42,
|
|
title: "Fix authentication flow".into(),
|
|
state: "merged".into(),
|
|
author_username: "alice".into(),
|
|
change_type: "modified".into(),
|
|
merged_at_ms: Some(1_700_000_000_000),
|
|
updated_at_ms: 1_700_000_000_000,
|
|
merge_commit_sha: Some("abc123".into()),
|
|
},
|
|
FileHistoryMr {
|
|
iid: 39,
|
|
title: "Refactor module structure".into(),
|
|
state: "opened".into(),
|
|
author_username: "bob".into(),
|
|
change_type: "renamed".into(),
|
|
merged_at_ms: None,
|
|
updated_at_ms: 1_699_000_000_000,
|
|
merge_commit_sha: None,
|
|
},
|
|
],
|
|
discussions: vec![],
|
|
total_mrs: 2,
|
|
paths_searched: 1,
|
|
}),
|
|
..FileHistoryState::default()
|
|
};
|
|
render_file_history(&mut frame, &state, test_area(100, 30));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_with_rename_chain() {
|
|
with_frame!(80, 24, |frame| {
|
|
let state = FileHistoryState {
|
|
result: Some(FileHistoryResult {
|
|
path: "src/old.rs".into(),
|
|
rename_chain: vec!["src/old.rs".into(), "src/new.rs".into()],
|
|
renames_followed: true,
|
|
merge_requests: vec![],
|
|
discussions: vec![],
|
|
total_mrs: 0,
|
|
paths_searched: 2,
|
|
}),
|
|
..FileHistoryState::default()
|
|
};
|
|
render_file_history(&mut frame, &state, test_area(80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_str() {
|
|
assert_eq!(truncate_str("hello", 10), "hello");
|
|
assert_eq!(truncate_str("hello world", 5), "hell…");
|
|
assert_eq!(truncate_str("", 5), "");
|
|
}
|
|
}
|