Compare commits
6 Commits
master
...
050e00345a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050e00345a | ||
|
|
90c8b43267 | ||
|
|
c5b7f4c864 | ||
|
|
28ce63f818 | ||
|
|
eb5b464d03 | ||
|
|
4664e0cfe3 |
312
.beads/.br_history/issues.20260212_211122.jsonl
Normal file
312
.beads/.br_history/issues.20260212_211122.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-1elx
|
||||
bd-2kr0
|
||||
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -1,11 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
# Rust build output
|
||||
/target
|
||||
**/target/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -25,14 +20,11 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local config files
|
||||
lore.config.json
|
||||
|
||||
# beads
|
||||
# beads viewer cache
|
||||
.bv/
|
||||
|
||||
# SQLite databases (local development)
|
||||
@@ -40,10 +32,15 @@ lore.config.json
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
|
||||
# Mock seed data
|
||||
tools/mock-seed/
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
||||
# Profiling / benchmarks
|
||||
perf.data
|
||||
perf.data.old
|
||||
flamegraph.svg
|
||||
*.profraw
|
||||
|
||||
3252
crates/lore-tui/Cargo.lock
generated
Normal file
3252
crates/lore-tui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
crates/lore-tui/Cargo.toml
Normal file
46
crates/lore-tui/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "lore-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Terminal UI for Gitlore — local GitLab data explorer"
|
||||
authors = ["Taylor Eernisse"]
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "lore-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# FrankenTUI (Elm-architecture TUI framework)
|
||||
ftui = "0.1.1"
|
||||
|
||||
# Lore library (config, db, ingestion, search, etc.)
|
||||
lore = { path = "../.." }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Paths
|
||||
dirs = "6"
|
||||
|
||||
# Database (read-only queries from TUI)
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
|
||||
# Terminal (crossterm for raw mode + event reading, used by ftui runtime)
|
||||
crossterm = "0.28"
|
||||
|
||||
# Serialization (crash context NDJSON dumps)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Regex (used by safety module for PII/secret redaction)
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
4
crates/lore-tui/rust-toolchain.toml
Normal file
4
crates/lore-tui/rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2026-02-08"
|
||||
profile = "minimal"
|
||||
components = ["rustfmt", "clippy"]
|
||||
2835
crates/lore-tui/src/action.rs
Normal file
2835
crates/lore-tui/src/action.rs
Normal file
File diff suppressed because it is too large
Load Diff
73
crates/lore-tui/src/app/mod.rs
Normal file
73
crates/lore-tui/src/app/mod.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
#![allow(dead_code)] // Phase 1: methods consumed as screens are implemented
|
||||
|
||||
//! Full FrankenTUI Model implementation for the lore TUI.
|
||||
//!
|
||||
//! LoreApp is the central coordinator: it owns all state, dispatches
|
||||
//! messages through a 5-stage key pipeline, records crash context
|
||||
//! breadcrumbs, manages async tasks via the supervisor, and routes
|
||||
//! view() to per-screen render functions.
|
||||
|
||||
mod tests;
|
||||
mod update;
|
||||
|
||||
use crate::clock::{Clock, SystemClock};
|
||||
use crate::commands::{CommandRegistry, build_registry};
|
||||
use crate::crash_context::CrashContext;
|
||||
use crate::db::DbManager;
|
||||
use crate::message::InputMode;
|
||||
use crate::navigation::NavigationStack;
|
||||
use crate::state::AppState;
|
||||
use crate::task_supervisor::TaskSupervisor;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoreApp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Root model for the lore TUI.
|
||||
///
|
||||
/// Owns all state and implements the FrankenTUI Model trait. The
|
||||
/// update() method is the single entry point for all state transitions.
|
||||
pub struct LoreApp {
|
||||
pub state: AppState,
|
||||
pub navigation: NavigationStack,
|
||||
pub supervisor: TaskSupervisor,
|
||||
pub crash_context: CrashContext,
|
||||
pub command_registry: CommandRegistry,
|
||||
pub input_mode: InputMode,
|
||||
pub clock: Box<dyn Clock>,
|
||||
pub db: Option<DbManager>,
|
||||
}
|
||||
|
||||
impl LoreApp {
|
||||
/// Create a new LoreApp with default state.
|
||||
///
|
||||
/// Uses a real system clock and no DB connection (set separately).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AppState::default(),
|
||||
navigation: NavigationStack::new(),
|
||||
supervisor: TaskSupervisor::new(),
|
||||
crash_context: CrashContext::new(),
|
||||
command_registry: build_registry(),
|
||||
input_mode: InputMode::Normal,
|
||||
clock: Box::new(SystemClock),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a LoreApp for testing with a custom clock.
|
||||
#[cfg(test)]
|
||||
fn with_clock(clock: Box<dyn Clock>) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoreApp {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
330
crates/lore-tui/src/app/tests.rs
Normal file
330
crates/lore-tui/src/app/tests.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
//! Tests for LoreApp.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use chrono::TimeDelta;
|
||||
use ftui::{Cmd, Event, KeyCode, KeyEvent, Model, Modifiers};
|
||||
|
||||
use crate::clock::FakeClock;
|
||||
use crate::message::{InputMode, Msg, Screen};
|
||||
|
||||
use super::LoreApp;
|
||||
|
||||
fn test_app() -> LoreApp {
|
||||
LoreApp::with_clock(Box::new(FakeClock::new(chrono::Utc::now())))
|
||||
}
|
||||
|
||||
/// Verify that `App::fullscreen(LoreApp::new()).run()` compiles.
|
||||
fn _assert_app_fullscreen_compiles() {
|
||||
fn _inner() {
|
||||
use ftui::App;
|
||||
let _app_builder = App::fullscreen(LoreApp::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `App::inline(LoreApp::new(), 12).run()` compiles.
|
||||
fn _assert_app_inline_compiles() {
|
||||
fn _inner() {
|
||||
use ftui::App;
|
||||
let _app_builder = App::inline(LoreApp::new(), 12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_init_returns_none() {
|
||||
let mut app = test_app();
|
||||
let cmd = app.init();
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_quit_returns_quit_cmd() {
|
||||
let mut app = test_app();
|
||||
let cmd = app.update(Msg::Quit);
|
||||
assert!(matches!(cmd, Cmd::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_tick_returns_none() {
|
||||
let mut app = test_app();
|
||||
let cmd = app.update(Msg::Tick);
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_navigate_to_updates_nav_stack() {
|
||||
let mut app = test_app();
|
||||
let cmd = app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||
assert_eq!(app.navigation.depth(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_go_back() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
app.update(Msg::GoBack);
|
||||
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_go_forward() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
app.update(Msg::GoBack);
|
||||
app.update(Msg::GoForward);
|
||||
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_c_always_quits() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL);
|
||||
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(cmd, Cmd::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_q_key_quits_in_normal_mode() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('q'));
|
||||
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(cmd, Cmd::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_q_key_blocked_in_text_mode() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Text;
|
||||
let key = KeyEvent::new(KeyCode::Char('q'));
|
||||
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
||||
// q in text mode should NOT quit.
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_blurs_text_mode() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Text;
|
||||
app.state.search.query_focused = true;
|
||||
|
||||
let key = KeyEvent::new(KeyCode::Escape);
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
assert!(!app.state.has_text_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_prefix_enters_go_mode() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('g'));
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_then_i_navigates_to_issues() {
|
||||
let mut app = test_app();
|
||||
|
||||
// First key: 'g'
|
||||
let key_g = KeyEvent::new(KeyCode::Char('g'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_g)));
|
||||
|
||||
// Second key: 'i'
|
||||
let key_i = KeyEvent::new(KeyCode::Char('i'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_i)));
|
||||
|
||||
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_prefix_timeout_cancels() {
|
||||
let clock = FakeClock::new(chrono::Utc::now());
|
||||
let mut app = LoreApp::with_clock(Box::new(clock.clone()));
|
||||
|
||||
// Press 'g'.
|
||||
let key_g = KeyEvent::new(KeyCode::Char('g'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_g)));
|
||||
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
|
||||
|
||||
// Advance clock past timeout.
|
||||
clock.advance(TimeDelta::milliseconds(600));
|
||||
|
||||
// Press 'i' after timeout — should NOT navigate to issues.
|
||||
let key_i = KeyEvent::new(KeyCode::Char('i'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_i)));
|
||||
|
||||
// Should still be at Dashboard (no navigation happened).
|
||||
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_help_toggles() {
|
||||
let mut app = test_app();
|
||||
assert!(!app.state.show_help);
|
||||
|
||||
app.update(Msg::ShowHelp);
|
||||
assert!(app.state.show_help);
|
||||
|
||||
app.update(Msg::ShowHelp);
|
||||
assert!(!app.state.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_msg_sets_toast() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::Error(crate::message::AppError::DbBusy));
|
||||
assert!(app.state.error_toast.is_some());
|
||||
assert!(app.state.error_toast.as_ref().unwrap().contains("busy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_updates_terminal_size() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::Resize {
|
||||
width: 120,
|
||||
height: 40,
|
||||
});
|
||||
assert_eq!(app.state.terminal_size, (120, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_result_dropped() {
|
||||
use crate::message::Screen;
|
||||
use crate::task_supervisor::TaskKey;
|
||||
|
||||
let mut app = test_app();
|
||||
|
||||
// Submit two tasks for IssueList — second supersedes first.
|
||||
let gen1 = app
|
||||
.supervisor
|
||||
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
||||
.generation;
|
||||
let gen2 = app
|
||||
.supervisor
|
||||
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
||||
.generation;
|
||||
|
||||
// Stale result with gen1 should be ignored.
|
||||
app.update(Msg::IssueListLoaded {
|
||||
generation: gen1,
|
||||
page: crate::state::issue_list::IssueListPage {
|
||||
rows: vec![crate::state::issue_list::IssueListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: 1,
|
||||
title: "stale".into(),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
labels: vec![],
|
||||
updated_at: 1_700_000_000_000,
|
||||
}],
|
||||
next_cursor: None,
|
||||
total_count: 1,
|
||||
},
|
||||
});
|
||||
assert!(app.state.issue_list.rows.is_empty());
|
||||
|
||||
// Current result with gen2 should be applied.
|
||||
app.update(Msg::IssueListLoaded {
|
||||
generation: gen2,
|
||||
page: crate::state::issue_list::IssueListPage {
|
||||
rows: vec![crate::state::issue_list::IssueListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: 2,
|
||||
title: "fresh".into(),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
labels: vec![],
|
||||
updated_at: 1_700_000_000_000,
|
||||
}],
|
||||
next_cursor: None,
|
||||
total_count: 1,
|
||||
},
|
||||
});
|
||||
assert_eq!(app.state.issue_list.rows.len(), 1);
|
||||
assert_eq!(app.state.issue_list.rows[0].title, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crash_context_records_events() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::Tick);
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
|
||||
// Should have recorded at least 2 events.
|
||||
assert!(app.crash_context.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_sets_loading_initial_on_first_visit() {
|
||||
use crate::state::LoadState;
|
||||
|
||||
let mut app = test_app();
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
// First visit should show full-screen spinner (LoadingInitial).
|
||||
assert_eq!(
|
||||
*app.state.load_state.get(&Screen::IssueList),
|
||||
LoadState::LoadingInitial
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_sets_refreshing_on_revisit() {
|
||||
use crate::state::LoadState;
|
||||
|
||||
let mut app = test_app();
|
||||
// First visit → LoadingInitial.
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
// Simulate load completing.
|
||||
app.state.set_loading(Screen::IssueList, LoadState::Idle);
|
||||
// Go back, then revisit.
|
||||
app.update(Msg::GoBack);
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
// Second visit should show corner spinner (Refreshing).
|
||||
assert_eq!(
|
||||
*app.state.load_state.get(&Screen::IssueList),
|
||||
LoadState::Refreshing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_palette_opens_from_ctrl_p() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('p')).with_modifiers(Modifiers::CTRL);
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(app.input_mode, InputMode::Palette));
|
||||
assert!(app.state.command_palette.query_focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_closes_palette() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Palette;
|
||||
|
||||
let key = KeyEvent::new(KeyCode::Escape);
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blur_text_input_msg() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Text;
|
||||
app.state.search.query_focused = true;
|
||||
|
||||
app.update(Msg::BlurTextInput);
|
||||
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
assert!(!app.state.has_text_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_new() {
|
||||
let app = LoreApp::default();
|
||||
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
}
|
||||
419
crates/lore-tui/src/app/update.rs
Normal file
419
crates/lore-tui/src/app/update.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! Model trait impl and key dispatch for LoreApp.
|
||||
|
||||
use chrono::TimeDelta;
|
||||
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
|
||||
|
||||
use crate::crash_context::CrashEvent;
|
||||
use crate::message::{InputMode, Msg, Screen};
|
||||
use crate::state::LoadState;
|
||||
use crate::task_supervisor::TaskKey;
|
||||
|
||||
use super::LoreApp;
|
||||
|
||||
/// Timeout for the g-prefix key sequence.
|
||||
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
|
||||
|
||||
impl LoreApp {
|
||||
// -----------------------------------------------------------------------
|
||||
// Key dispatch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Normalize terminal key variants for cross-terminal consistency.
|
||||
fn normalize_key(key: &mut KeyEvent) {
|
||||
// BackTab -> Shift+Tab canonical form.
|
||||
if key.code == KeyCode::BackTab {
|
||||
key.code = KeyCode::Tab;
|
||||
key.modifiers |= Modifiers::SHIFT;
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-stage key dispatch pipeline.
|
||||
///
|
||||
/// Returns the Cmd to execute (Quit, None, or a task command).
|
||||
pub(crate) fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
|
||||
Self::normalize_key(&mut key);
|
||||
|
||||
let screen = self.navigation.current().clone();
|
||||
|
||||
// Record key press in crash context.
|
||||
self.crash_context.push(CrashEvent::KeyPress {
|
||||
key: format!("{:?}", key.code),
|
||||
mode: format!("{:?}", self.input_mode),
|
||||
screen: screen.label().to_string(),
|
||||
});
|
||||
|
||||
// --- Stage 1: Quit check ---
|
||||
// Ctrl+C always quits regardless of mode.
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) {
|
||||
return Cmd::quit();
|
||||
}
|
||||
|
||||
// --- Stage 2: InputMode routing ---
|
||||
match &self.input_mode {
|
||||
InputMode::Text => {
|
||||
return self.handle_text_mode_key(&key, &screen);
|
||||
}
|
||||
InputMode::Palette => {
|
||||
return self.handle_palette_mode_key(&key, &screen);
|
||||
}
|
||||
InputMode::GoPrefix { started_at } => {
|
||||
let elapsed = self.clock.now().signed_duration_since(*started_at);
|
||||
if elapsed > GO_PREFIX_TIMEOUT {
|
||||
// Timeout expired — cancel prefix and re-process as normal.
|
||||
self.input_mode = InputMode::Normal;
|
||||
} else {
|
||||
return self.handle_go_prefix_key(&key, &screen);
|
||||
}
|
||||
}
|
||||
InputMode::Normal => {}
|
||||
}
|
||||
|
||||
// --- Stage 3: Global shortcuts (Normal mode) ---
|
||||
// 'q' quits.
|
||||
if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE {
|
||||
return Cmd::quit();
|
||||
}
|
||||
|
||||
// 'g' starts prefix sequence.
|
||||
if self
|
||||
.command_registry
|
||||
.is_sequence_starter(&key.code, &key.modifiers)
|
||||
{
|
||||
self.input_mode = InputMode::GoPrefix {
|
||||
started_at: self.clock.now(),
|
||||
};
|
||||
return Cmd::none();
|
||||
}
|
||||
|
||||
// Registry-based single-key lookup.
|
||||
if let Some(cmd_def) =
|
||||
self.command_registry
|
||||
.lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode)
|
||||
{
|
||||
return self.execute_command(cmd_def.id, &screen);
|
||||
}
|
||||
|
||||
// --- Stage 4: Screen-local keys ---
|
||||
// Delegated to AppState::interpret_screen_key in future phases.
|
||||
|
||||
// --- Stage 5: Fallback (unhandled) ---
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Handle keys in Text input mode.
|
||||
///
|
||||
/// Only Esc and Ctrl+P pass through; everything else is consumed by
|
||||
/// the focused text widget (handled in future phases).
|
||||
fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
|
||||
// Esc blurs the text input.
|
||||
if key.code == KeyCode::Escape {
|
||||
self.state.blur_text_focus();
|
||||
self.input_mode = InputMode::Normal;
|
||||
return Cmd::none();
|
||||
}
|
||||
|
||||
// Ctrl+P opens palette even in text mode.
|
||||
if let Some(cmd_def) =
|
||||
self.command_registry
|
||||
.lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text)
|
||||
{
|
||||
return self.execute_command(cmd_def.id, screen);
|
||||
}
|
||||
|
||||
// All other keys consumed by text widget (future).
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Handle keys in Palette mode.
|
||||
fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd<Msg> {
|
||||
if key.code == KeyCode::Escape {
|
||||
self.input_mode = InputMode::Normal;
|
||||
return Cmd::none();
|
||||
}
|
||||
// Palette key dispatch will be expanded in the palette widget phase.
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Handle the second key of a g-prefix sequence.
|
||||
fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
|
||||
self.input_mode = InputMode::Normal;
|
||||
|
||||
if let Some(cmd_def) = self.command_registry.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&key.code,
|
||||
&key.modifiers,
|
||||
screen,
|
||||
) {
|
||||
return self.execute_command(cmd_def.id, screen);
|
||||
}
|
||||
|
||||
// Invalid second key — cancel prefix silently.
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Execute a command by ID.
|
||||
fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd<Msg> {
|
||||
match id {
|
||||
"quit" => Cmd::quit(),
|
||||
"go_back" => {
|
||||
self.navigation.pop();
|
||||
Cmd::none()
|
||||
}
|
||||
"show_help" => {
|
||||
self.state.show_help = !self.state.show_help;
|
||||
Cmd::none()
|
||||
}
|
||||
"command_palette" => {
|
||||
self.input_mode = InputMode::Palette;
|
||||
self.state.command_palette.query_focused = true;
|
||||
Cmd::none()
|
||||
}
|
||||
"open_in_browser" => {
|
||||
// Will dispatch OpenInBrowser msg in future phase.
|
||||
Cmd::none()
|
||||
}
|
||||
"show_cli" => {
|
||||
// Will show CLI equivalent in future phase.
|
||||
Cmd::none()
|
||||
}
|
||||
"go_home" => self.navigate_to(Screen::Dashboard),
|
||||
"go_issues" => self.navigate_to(Screen::IssueList),
|
||||
"go_mrs" => self.navigate_to(Screen::MrList),
|
||||
"go_search" => self.navigate_to(Screen::Search),
|
||||
"go_timeline" => self.navigate_to(Screen::Timeline),
|
||||
"go_who" => self.navigate_to(Screen::Who),
|
||||
"go_sync" => self.navigate_to(Screen::Sync),
|
||||
"jump_back" => {
|
||||
self.navigation.jump_back();
|
||||
Cmd::none()
|
||||
}
|
||||
"jump_forward" => {
|
||||
self.navigation.jump_forward();
|
||||
Cmd::none()
|
||||
}
|
||||
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
|
||||
// Screen-specific actions — delegated in future phases.
|
||||
Cmd::none()
|
||||
}
|
||||
_ => Cmd::none(),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Navigate to a screen, pushing the nav stack and starting a data load.
|
||||
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
|
||||
let screen_label = screen.label().to_string();
|
||||
let current_label = self.navigation.current().label().to_string();
|
||||
|
||||
self.crash_context.push(CrashEvent::StateTransition {
|
||||
from: current_label,
|
||||
to: screen_label,
|
||||
});
|
||||
|
||||
self.navigation.push(screen.clone());
|
||||
|
||||
// First visit → full-screen spinner; revisit → corner spinner over stale data.
|
||||
let load_state = if self.state.load_state.was_visited(&screen) {
|
||||
LoadState::Refreshing
|
||||
} else {
|
||||
LoadState::LoadingInitial
|
||||
};
|
||||
self.state.set_loading(screen.clone(), load_state);
|
||||
|
||||
// Spawn supervised task for data loading (placeholder — actual DB
|
||||
// query dispatch comes in Phase 2 screen implementations).
|
||||
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
|
||||
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Message dispatch (non-key)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Handle non-key messages.
|
||||
pub(crate) fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
|
||||
// Record in crash context.
|
||||
self.crash_context.push(CrashEvent::MsgDispatched {
|
||||
msg_name: format!("{msg:?}")
|
||||
.split('(')
|
||||
.next()
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
screen: self.navigation.current().label().to_string(),
|
||||
});
|
||||
|
||||
match msg {
|
||||
Msg::Quit => Cmd::quit(),
|
||||
|
||||
// --- Navigation ---
|
||||
Msg::NavigateTo(screen) => self.navigate_to(screen),
|
||||
Msg::GoBack => {
|
||||
self.navigation.pop();
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::GoForward => {
|
||||
self.navigation.go_forward();
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::GoHome => self.navigate_to(Screen::Dashboard),
|
||||
Msg::JumpBack(_) => {
|
||||
self.navigation.jump_back();
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::JumpForward(_) => {
|
||||
self.navigation.jump_forward();
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Error ---
|
||||
Msg::Error(err) => {
|
||||
self.state.set_error(err.to_string());
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Help / UI ---
|
||||
Msg::ShowHelp => {
|
||||
self.state.show_help = !self.state.show_help;
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::BlurTextInput => {
|
||||
self.state.blur_text_focus();
|
||||
self.input_mode = InputMode::Normal;
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Terminal ---
|
||||
Msg::Resize { width, height } => {
|
||||
self.state.terminal_size = (width, height);
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::Tick => Cmd::none(),
|
||||
|
||||
// --- Loaded results (stale guard) ---
|
||||
Msg::IssueListLoaded { generation, page } => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
|
||||
{
|
||||
self.state.issue_list.apply_page(page);
|
||||
self.state.set_loading(Screen::IssueList, LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::MrListLoaded { generation, page } => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
|
||||
{
|
||||
self.state.mr_list.apply_page(page);
|
||||
self.state.set_loading(Screen::MrList, LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::DashboardLoaded { generation, data } => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
|
||||
{
|
||||
self.state.dashboard.update(*data);
|
||||
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Issue detail ---
|
||||
Msg::IssueDetailLoaded {
|
||||
generation,
|
||||
key,
|
||||
data,
|
||||
} => {
|
||||
let screen = Screen::IssueDetail(key.clone());
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
|
||||
{
|
||||
self.state.issue_detail.apply_metadata(*data);
|
||||
self.state.set_loading(screen.clone(), LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(screen), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::DiscussionsLoaded {
|
||||
generation,
|
||||
key,
|
||||
discussions,
|
||||
} => {
|
||||
let screen = Screen::IssueDetail(key.clone());
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
|
||||
{
|
||||
self.state.issue_detail.apply_discussions(discussions);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- MR detail ---
|
||||
Msg::MrDetailLoaded {
|
||||
generation,
|
||||
key,
|
||||
data,
|
||||
} => {
|
||||
let screen = Screen::MrDetail(key.clone());
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
|
||||
{
|
||||
self.state.mr_detail.apply_metadata(*data);
|
||||
self.state.set_loading(screen.clone(), LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(screen), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// All other message variants: no-op for now.
|
||||
// Future phases will fill these in as screens are implemented.
|
||||
_ => Cmd::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Model for LoreApp {
|
||||
type Message = Msg;
|
||||
|
||||
fn init(&mut self) -> Cmd<Self::Message> {
|
||||
// Install crash context panic hook.
|
||||
crate::crash_context::CrashContext::install_panic_hook(&self.crash_context);
|
||||
crate::crash_context::CrashContext::prune_crash_files();
|
||||
|
||||
// Navigate to dashboard (will trigger data load in future phase).
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
|
||||
// Route raw key events through the 5-stage pipeline.
|
||||
if let Msg::RawEvent(Event::Key(key)) = msg {
|
||||
return self.interpret_key(key);
|
||||
}
|
||||
|
||||
// Everything else goes through message dispatch.
|
||||
self.handle_msg(msg)
|
||||
}
|
||||
|
||||
fn view(&self, frame: &mut Frame) {
|
||||
crate::view::render_screen(frame, self);
|
||||
}
|
||||
}
|
||||
165
crates/lore-tui/src/clock.rs
Normal file
165
crates/lore-tui/src/clock.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Injected clock for deterministic time in tests and consistent frame timestamps.
|
||||
//!
|
||||
//! All relative-time rendering (e.g., "3h ago") uses [`Clock::now()`] rather
|
||||
//! than wall-clock time directly. This enables:
|
||||
//! - Deterministic snapshot tests via [`FakeClock`]
|
||||
//! - Consistent timestamps within a single frame render pass
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
|
||||
/// Trait for obtaining the current time.
|
||||
///
|
||||
/// Inject via `Arc<dyn Clock>` to allow swapping between real and fake clocks.
|
||||
pub trait Clock: Send + Sync {
|
||||
/// Returns the current time.
|
||||
fn now(&self) -> DateTime<Utc>;
|
||||
|
||||
/// Returns the current time as milliseconds since the Unix epoch.
|
||||
fn now_ms(&self) -> i64 {
|
||||
self.now().timestamp_millis()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SystemClock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Real wall-clock time via `chrono::Utc::now()`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SystemClock;
|
||||
|
||||
impl Clock for SystemClock {
|
||||
fn now(&self) -> DateTime<Utc> {
|
||||
Utc::now()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeClock
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A controllable clock for tests. Returns a frozen time that can be
|
||||
/// advanced or set explicitly.
|
||||
///
|
||||
/// `FakeClock` is `Clone` (shares the inner `Arc`) and `Send + Sync`
|
||||
/// for use across `Cmd::task` threads.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FakeClock {
|
||||
inner: Arc<Mutex<DateTime<Utc>>>,
|
||||
}
|
||||
|
||||
impl FakeClock {
|
||||
/// Create a fake clock frozen at the given time.
|
||||
#[must_use]
|
||||
pub fn new(time: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(time)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fake clock frozen at the given millisecond epoch timestamp.
|
||||
///
|
||||
/// Convenience for action tests that work with raw epoch milliseconds.
|
||||
#[must_use]
|
||||
pub fn from_ms(epoch_ms: i64) -> Self {
|
||||
let time = DateTime::from_timestamp_millis(epoch_ms).expect("valid millisecond timestamp");
|
||||
Self::new(time)
|
||||
}
|
||||
|
||||
/// Advance the clock by `duration`. Uses `checked_add` to handle overflow
|
||||
/// gracefully — if the addition would overflow, the time is not changed.
|
||||
pub fn advance(&self, duration: TimeDelta) {
|
||||
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
|
||||
if let Some(advanced) = guard.checked_add_signed(duration) {
|
||||
*guard = advanced;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the clock to an exact time.
|
||||
pub fn set(&self, time: DateTime<Utc>) {
|
||||
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
|
||||
*guard = time;
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for FakeClock {
|
||||
fn now(&self) -> DateTime<Utc> {
|
||||
*self.inner.lock().expect("FakeClock mutex poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
fn fixed_time() -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 2, 12, 12, 0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_clock_frozen() {
|
||||
let clock = FakeClock::new(fixed_time());
|
||||
let t1 = clock.now();
|
||||
let t2 = clock.now();
|
||||
assert_eq!(t1, t2);
|
||||
assert_eq!(t1, fixed_time());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_clock_advance() {
|
||||
let clock = FakeClock::new(fixed_time());
|
||||
clock.advance(TimeDelta::hours(3));
|
||||
let expected = Utc.with_ymd_and_hms(2026, 2, 12, 15, 0, 0).unwrap();
|
||||
assert_eq!(clock.now(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_clock_set() {
|
||||
let clock = FakeClock::new(fixed_time());
|
||||
let new_time = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
|
||||
clock.set(new_time);
|
||||
assert_eq!(clock.now(), new_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_clock_clone_shares_state() {
|
||||
let clock1 = FakeClock::new(fixed_time());
|
||||
let clock2 = clock1.clone();
|
||||
clock1.advance(TimeDelta::minutes(30));
|
||||
// Both clones see the advanced time.
|
||||
assert_eq!(clock1.now(), clock2.now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_clock_returns_reasonable_time() {
|
||||
let clock = SystemClock;
|
||||
let now = clock.now();
|
||||
// Sanity: time should be after 2025.
|
||||
assert!(now.year() >= 2025);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_clock_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<FakeClock>();
|
||||
assert_send_sync::<SystemClock>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clock_trait_object_works() {
|
||||
let fake: Arc<dyn Clock> = Arc::new(FakeClock::new(fixed_time()));
|
||||
assert_eq!(fake.now(), fixed_time());
|
||||
|
||||
let real: Arc<dyn Clock> = Arc::new(SystemClock);
|
||||
let _ = real.now(); // Just verify it doesn't panic.
|
||||
}
|
||||
|
||||
use chrono::Datelike;
|
||||
}
|
||||
807
crates/lore-tui/src/commands.rs.bak
Normal file
807
crates/lore-tui/src/commands.rs.bak
Normal file
@@ -0,0 +1,807 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Command registry — single source of truth for all TUI actions.
|
||||
//!
|
||||
//! Every keybinding, palette entry, help text, CLI equivalent, and
|
||||
//! status hint is generated from [`CommandRegistry`]. No hardcoded
|
||||
//! duplicate maps exist in view/state modules.
|
||||
//!
|
||||
//! Supports single-key and two-key sequences (g-prefix vim bindings).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ftui::{KeyCode, Modifiers};
|
||||
|
||||
use crate::message::{InputMode, Screen};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a key code + modifiers as a human-readable string.
|
||||
fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if modifiers.contains(Modifiers::CTRL) {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.contains(Modifiers::ALT) {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.contains(Modifiers::SHIFT) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
let key_name = match code {
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Enter => "Enter".to_string(),
|
||||
KeyCode::Escape => "Esc".to_string(),
|
||||
KeyCode::Tab => "Tab".to_string(),
|
||||
KeyCode::Backspace => "Backspace".to_string(),
|
||||
KeyCode::Delete => "Del".to_string(),
|
||||
KeyCode::Up => "Up".to_string(),
|
||||
KeyCode::Down => "Down".to_string(),
|
||||
KeyCode::Left => "Left".to_string(),
|
||||
KeyCode::Right => "Right".to_string(),
|
||||
KeyCode::Home => "Home".to_string(),
|
||||
KeyCode::End => "End".to_string(),
|
||||
KeyCode::PageUp => "PgUp".to_string(),
|
||||
KeyCode::PageDown => "PgDn".to_string(),
|
||||
KeyCode::F(n) => format!("F{n}"),
|
||||
_ => "?".to_string(),
|
||||
};
|
||||
parts.push(&key_name);
|
||||
// We need to own the joined string.
|
||||
let joined: String = parts.join("+");
|
||||
joined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KeyCombo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A keybinding: either a single key or a two-key sequence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum KeyCombo {
|
||||
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
|
||||
Single { code: KeyCode, modifiers: Modifiers },
|
||||
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
|
||||
Sequence {
|
||||
first_code: KeyCode,
|
||||
first_modifiers: Modifiers,
|
||||
second_code: KeyCode,
|
||||
second_modifiers: Modifiers,
|
||||
},
|
||||
}
|
||||
|
||||
impl KeyCombo {
|
||||
/// Convenience: single key with no modifiers.
|
||||
#[must_use]
|
||||
pub const fn key(code: KeyCode) -> Self {
|
||||
Self::Single {
|
||||
code,
|
||||
modifiers: Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: single key with Ctrl modifier.
|
||||
#[must_use]
|
||||
pub const fn ctrl(code: KeyCode) -> Self {
|
||||
Self::Single {
|
||||
code,
|
||||
modifiers: Modifiers::CTRL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: g-prefix sequence (g + char).
|
||||
#[must_use]
|
||||
pub const fn g_then(c: char) -> Self {
|
||||
Self::Sequence {
|
||||
first_code: KeyCode::Char('g'),
|
||||
first_modifiers: Modifiers::NONE,
|
||||
second_code: KeyCode::Char(c),
|
||||
second_modifiers: Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable display string for this key combo.
|
||||
#[must_use]
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Single { code, modifiers } => format_key(*code, *modifiers),
|
||||
Self::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
second_code,
|
||||
second_modifiers,
|
||||
} => {
|
||||
let first = format_key(*first_code, *first_modifiers);
|
||||
let second = format_key(*second_code, *second_modifiers);
|
||||
format!("{first} {second}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this combo starts with the given key.
|
||||
#[must_use]
|
||||
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
|
||||
match self {
|
||||
Self::Single {
|
||||
code: c,
|
||||
modifiers: m,
|
||||
} => c == code && m == modifiers,
|
||||
Self::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
..
|
||||
} => first_code == code && first_modifiers == modifiers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Specifies which screens a command is available on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScreenFilter {
|
||||
/// Available on all screens.
|
||||
Global,
|
||||
/// Available only on specific screens.
|
||||
Only(Vec<Screen>),
|
||||
}
|
||||
|
||||
impl ScreenFilter {
|
||||
/// Whether the command is available on the given screen.
|
||||
#[must_use]
|
||||
pub fn matches(&self, screen: &Screen) -> bool {
|
||||
match self {
|
||||
Self::Global => true,
|
||||
Self::Only(screens) => screens.contains(screen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unique command identifier.
|
||||
pub type CommandId = &'static str;
|
||||
|
||||
/// A registered command with its keybinding, help text, and scope.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandDef {
|
||||
/// Unique identifier (e.g., "quit", "go_issues").
|
||||
pub id: CommandId,
|
||||
/// Human-readable label for palette and help overlay.
|
||||
pub label: &'static str,
|
||||
/// Keybinding (if any).
|
||||
pub keybinding: Option<KeyCombo>,
|
||||
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
|
||||
pub cli_equivalent: Option<&'static str>,
|
||||
/// Description for help overlay.
|
||||
pub help_text: &'static str,
|
||||
/// Short hint for status bar (e.g., "q:quit").
|
||||
pub status_hint: &'static str,
|
||||
/// Which screens this command is available on.
|
||||
pub available_in: ScreenFilter,
|
||||
/// Whether this command works in Text input mode.
|
||||
pub available_in_text_mode: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Single source of truth for all TUI commands.
|
||||
///
|
||||
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
|
||||
/// by keybinding and per-screen filtering.
|
||||
pub struct CommandRegistry {
|
||||
commands: Vec<CommandDef>,
|
||||
/// Single-key -> command IDs that start with this key.
|
||||
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
|
||||
/// Full sequence -> command index (for two-key combos).
|
||||
by_sequence: HashMap<KeyCombo, usize>,
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
/// Look up a command by a single key press on a given screen and input mode.
|
||||
///
|
||||
/// Returns `None` if no matching command is found. For sequence starters
|
||||
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
|
||||
/// that case.
|
||||
#[must_use]
|
||||
pub fn lookup_key(
|
||||
&self,
|
||||
code: &KeyCode,
|
||||
modifiers: &Modifiers,
|
||||
screen: &Screen,
|
||||
mode: &InputMode,
|
||||
) -> Option<&CommandDef> {
|
||||
let is_text = matches!(mode, InputMode::Text);
|
||||
let key = (*code, *modifiers);
|
||||
|
||||
let indices = self.by_single_key.get(&key)?;
|
||||
for &idx in indices {
|
||||
let cmd = &self.commands[idx];
|
||||
if !cmd.available_in.matches(screen) {
|
||||
continue;
|
||||
}
|
||||
if is_text && !cmd.available_in_text_mode {
|
||||
continue;
|
||||
}
|
||||
// Only match Single combos here, not sequence starters.
|
||||
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
|
||||
return Some(cmd);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Complete a two-key sequence.
|
||||
///
|
||||
/// Called after the first key of a sequence is detected (e.g., after 'g').
|
||||
#[must_use]
|
||||
pub fn complete_sequence(
|
||||
&self,
|
||||
first_code: &KeyCode,
|
||||
first_modifiers: &Modifiers,
|
||||
second_code: &KeyCode,
|
||||
second_modifiers: &Modifiers,
|
||||
screen: &Screen,
|
||||
) -> Option<&CommandDef> {
|
||||
let combo = KeyCombo::Sequence {
|
||||
first_code: *first_code,
|
||||
first_modifiers: *first_modifiers,
|
||||
second_code: *second_code,
|
||||
second_modifiers: *second_modifiers,
|
||||
};
|
||||
let &idx = self.by_sequence.get(&combo)?;
|
||||
let cmd = &self.commands[idx];
|
||||
if cmd.available_in.matches(screen) {
|
||||
Some(cmd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a key starts a multi-key sequence (e.g., 'g').
|
||||
#[must_use]
|
||||
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
|
||||
self.by_sequence
|
||||
.keys()
|
||||
.any(|combo| combo.starts_with(code, modifiers))
|
||||
}
|
||||
|
||||
/// Commands available for the command palette on a given screen.
|
||||
///
|
||||
/// Returned sorted by label.
|
||||
#[must_use]
|
||||
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
|
||||
let mut entries: Vec<&CommandDef> = self
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.collect();
|
||||
entries.sort_by_key(|c| c.label);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Commands for the help overlay on a given screen.
|
||||
#[must_use]
|
||||
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.filter(|c| c.keybinding.is_some())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Status bar hints for the current screen.
|
||||
#[must_use]
|
||||
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.filter(|c| !c.status_hint.is_empty())
|
||||
.map(|c| c.status_hint)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total number of registered commands.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.commands.len()
|
||||
}
|
||||
|
||||
/// Whether the registry has no commands.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the command registry with all TUI commands.
|
||||
///
|
||||
/// This is the single source of truth — every keybinding, help text,
|
||||
/// and palette entry originates here.
|
||||
#[must_use]
|
||||
pub fn build_registry() -> CommandRegistry {
|
||||
let commands = vec![
|
||||
// --- Global commands ---
|
||||
CommandDef {
|
||||
id: "quit",
|
||||
label: "Quit",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Exit the TUI",
|
||||
status_hint: "q:quit",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_back",
|
||||
label: "Go Back",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
|
||||
cli_equivalent: None,
|
||||
help_text: "Go back to previous screen",
|
||||
status_hint: "esc:back",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: true,
|
||||
},
|
||||
CommandDef {
|
||||
id: "show_help",
|
||||
label: "Help",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Show keybinding help overlay",
|
||||
status_hint: "?:help",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "command_palette",
|
||||
label: "Command Palette",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open command palette",
|
||||
status_hint: "C-p:palette",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: true,
|
||||
},
|
||||
CommandDef {
|
||||
id: "open_in_browser",
|
||||
label: "Open in Browser",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open current entity in browser",
|
||||
status_hint: "o:browser",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "show_cli",
|
||||
label: "Show CLI Equivalent",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Show equivalent lore CLI command",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Navigation: g-prefix sequences ---
|
||||
CommandDef {
|
||||
id: "go_home",
|
||||
label: "Go to Dashboard",
|
||||
keybinding: Some(KeyCombo::g_then('h')),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump to dashboard",
|
||||
status_hint: "gh:home",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_issues",
|
||||
label: "Go to Issues",
|
||||
keybinding: Some(KeyCombo::g_then('i')),
|
||||
cli_equivalent: Some("lore issues"),
|
||||
help_text: "Jump to issue list",
|
||||
status_hint: "gi:issues",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_mrs",
|
||||
label: "Go to Merge Requests",
|
||||
keybinding: Some(KeyCombo::g_then('m')),
|
||||
cli_equivalent: Some("lore mrs"),
|
||||
help_text: "Jump to MR list",
|
||||
status_hint: "gm:mrs",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_search",
|
||||
label: "Go to Search",
|
||||
keybinding: Some(KeyCombo::g_then('/')),
|
||||
cli_equivalent: Some("lore search"),
|
||||
help_text: "Jump to search",
|
||||
status_hint: "g/:search",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_timeline",
|
||||
label: "Go to Timeline",
|
||||
keybinding: Some(KeyCombo::g_then('t')),
|
||||
cli_equivalent: Some("lore timeline"),
|
||||
help_text: "Jump to timeline",
|
||||
status_hint: "gt:timeline",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_who",
|
||||
label: "Go to Who",
|
||||
keybinding: Some(KeyCombo::g_then('w')),
|
||||
cli_equivalent: Some("lore who"),
|
||||
help_text: "Jump to people intelligence",
|
||||
status_hint: "gw:who",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_sync",
|
||||
label: "Go to Sync",
|
||||
keybinding: Some(KeyCombo::g_then('s')),
|
||||
cli_equivalent: Some("lore sync"),
|
||||
help_text: "Jump to sync status",
|
||||
status_hint: "gs:sync",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Vim-style jump list ---
|
||||
CommandDef {
|
||||
id: "jump_back",
|
||||
label: "Jump Back",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump backward through visited detail views",
|
||||
status_hint: "C-o:jump back",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "jump_forward",
|
||||
label: "Jump Forward",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump forward through visited detail views",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- List navigation ---
|
||||
CommandDef {
|
||||
id: "move_down",
|
||||
label: "Move Down",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Move cursor down",
|
||||
status_hint: "j:down",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "move_up",
|
||||
label: "Move Up",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Move cursor up",
|
||||
status_hint: "k:up",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "select_item",
|
||||
label: "Select",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open selected item",
|
||||
status_hint: "enter:open",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Filter ---
|
||||
CommandDef {
|
||||
id: "focus_filter",
|
||||
label: "Filter",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Focus the filter input",
|
||||
status_hint: "/:filter",
|
||||
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Scroll ---
|
||||
CommandDef {
|
||||
id: "scroll_to_top",
|
||||
label: "Scroll to Top",
|
||||
keybinding: Some(KeyCombo::g_then('g')),
|
||||
cli_equivalent: None,
|
||||
help_text: "Scroll to the top of the current view",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
];
|
||||
|
||||
build_from_defs(commands)
|
||||
}
|
||||
|
||||
/// Build index maps from a list of command definitions.
|
||||
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
|
||||
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
|
||||
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
|
||||
|
||||
for (idx, cmd) in commands.iter().enumerate() {
|
||||
if let Some(combo) = &cmd.keybinding {
|
||||
match combo {
|
||||
KeyCombo::Single { code, modifiers } => {
|
||||
by_single_key
|
||||
.entry((*code, *modifiers))
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
KeyCombo::Sequence { .. } => {
|
||||
by_sequence.insert(combo.clone(), idx);
|
||||
// Also index the first key so is_sequence_starter works via by_single_key.
|
||||
if let KeyCombo::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
..
|
||||
} = combo
|
||||
{
|
||||
by_single_key
|
||||
.entry((*first_code, *first_modifiers))
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandRegistry {
|
||||
commands,
|
||||
by_single_key,
|
||||
by_sequence,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
#[test]
|
||||
fn test_registry_builds_successfully() {
|
||||
let reg = build_registry();
|
||||
assert!(!reg.is_empty());
|
||||
assert!(reg.len() >= 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_lookup_quit() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_lookup_quit_blocked_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_esc_works_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Escape,
|
||||
&Modifiers::NONE,
|
||||
&Screen::IssueList,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "go_back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_ctrl_p_works_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('p'),
|
||||
&Modifiers::CTRL,
|
||||
&Screen::Search,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "command_palette");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_is_sequence_starter() {
|
||||
let reg = build_registry();
|
||||
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
|
||||
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_sequence_gi() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&KeyCode::Char('i'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "go_issues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_sequence_invalid_second_key() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&KeyCode::Char('x'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_specific_command() {
|
||||
let reg = build_registry();
|
||||
// 'j' (move_down) should work on IssueList
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('j'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::IssueList,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "move_down");
|
||||
|
||||
// 'j' should NOT match on Dashboard (move_down is list-only).
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('j'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_entries_sorted_by_label() {
|
||||
let reg = build_registry();
|
||||
let entries = reg.palette_entries(&Screen::Dashboard);
|
||||
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
|
||||
let mut sorted = labels.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(labels, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_entries_only_include_keybindings() {
|
||||
let reg = build_registry();
|
||||
let entries = reg.help_entries(&Screen::Dashboard);
|
||||
for entry in &entries {
|
||||
assert!(
|
||||
entry.keybinding.is_some(),
|
||||
"help entry without keybinding: {}",
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_hints_non_empty() {
|
||||
let reg = build_registry();
|
||||
let hints = reg.status_hints(&Screen::Dashboard);
|
||||
assert!(!hints.is_empty());
|
||||
// All returned hints should be non-empty strings.
|
||||
for hint in &hints {
|
||||
assert!(!hint.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_equivalents_populated() {
|
||||
let reg = build_registry();
|
||||
let with_cli: Vec<&CommandDef> = reg
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| c.cli_equivalent.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
with_cli.len() >= 5,
|
||||
"expected at least 5 commands with cli_equivalent, got {}",
|
||||
with_cli.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_prefix_timeout_detection() {
|
||||
let reg = build_registry();
|
||||
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
|
||||
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
|
||||
|
||||
// Simulate InputMode::GoPrefix with timeout check.
|
||||
let started = Utc::now();
|
||||
let mode = InputMode::GoPrefix {
|
||||
started_at: started,
|
||||
};
|
||||
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&mode,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_commands_have_nonempty_help() {
|
||||
let reg = build_registry();
|
||||
for cmd in ®.commands {
|
||||
assert!(
|
||||
!cmd.help_text.is_empty(),
|
||||
"command {} has empty help_text",
|
||||
cmd.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
180
crates/lore-tui/src/commands/defs.rs
Normal file
180
crates/lore-tui/src/commands/defs.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Command definitions — types for keybindings, screen filtering, and command metadata.
|
||||
|
||||
use ftui::{KeyCode, Modifiers};
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a key code + modifiers as a human-readable string.
|
||||
pub(crate) fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if modifiers.contains(Modifiers::CTRL) {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.contains(Modifiers::ALT) {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.contains(Modifiers::SHIFT) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
let key_name = match code {
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Enter => "Enter".to_string(),
|
||||
KeyCode::Escape => "Esc".to_string(),
|
||||
KeyCode::Tab => "Tab".to_string(),
|
||||
KeyCode::Backspace => "Backspace".to_string(),
|
||||
KeyCode::Delete => "Del".to_string(),
|
||||
KeyCode::Up => "Up".to_string(),
|
||||
KeyCode::Down => "Down".to_string(),
|
||||
KeyCode::Left => "Left".to_string(),
|
||||
KeyCode::Right => "Right".to_string(),
|
||||
KeyCode::Home => "Home".to_string(),
|
||||
KeyCode::End => "End".to_string(),
|
||||
KeyCode::PageUp => "PgUp".to_string(),
|
||||
KeyCode::PageDown => "PgDn".to_string(),
|
||||
KeyCode::F(n) => format!("F{n}"),
|
||||
_ => "?".to_string(),
|
||||
};
|
||||
parts.push(&key_name);
|
||||
// We need to own the joined string.
|
||||
let joined: String = parts.join("+");
|
||||
joined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KeyCombo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A keybinding: either a single key or a two-key sequence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum KeyCombo {
|
||||
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
|
||||
Single { code: KeyCode, modifiers: Modifiers },
|
||||
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
|
||||
Sequence {
|
||||
first_code: KeyCode,
|
||||
first_modifiers: Modifiers,
|
||||
second_code: KeyCode,
|
||||
second_modifiers: Modifiers,
|
||||
},
|
||||
}
|
||||
|
||||
impl KeyCombo {
|
||||
/// Convenience: single key with no modifiers.
|
||||
#[must_use]
|
||||
pub const fn key(code: KeyCode) -> Self {
|
||||
Self::Single {
|
||||
code,
|
||||
modifiers: Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: single key with Ctrl modifier.
|
||||
#[must_use]
|
||||
pub const fn ctrl(code: KeyCode) -> Self {
|
||||
Self::Single {
|
||||
code,
|
||||
modifiers: Modifiers::CTRL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: g-prefix sequence (g + char).
|
||||
#[must_use]
|
||||
pub const fn g_then(c: char) -> Self {
|
||||
Self::Sequence {
|
||||
first_code: KeyCode::Char('g'),
|
||||
first_modifiers: Modifiers::NONE,
|
||||
second_code: KeyCode::Char(c),
|
||||
second_modifiers: Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable display string for this key combo.
|
||||
#[must_use]
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Single { code, modifiers } => format_key(*code, *modifiers),
|
||||
Self::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
second_code,
|
||||
second_modifiers,
|
||||
} => {
|
||||
let first = format_key(*first_code, *first_modifiers);
|
||||
let second = format_key(*second_code, *second_modifiers);
|
||||
format!("{first} {second}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this combo starts with the given key.
|
||||
#[must_use]
|
||||
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
|
||||
match self {
|
||||
Self::Single {
|
||||
code: c,
|
||||
modifiers: m,
|
||||
} => c == code && m == modifiers,
|
||||
Self::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
..
|
||||
} => first_code == code && first_modifiers == modifiers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Specifies which screens a command is available on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScreenFilter {
|
||||
/// Available on all screens.
|
||||
Global,
|
||||
/// Available only on specific screens.
|
||||
Only(Vec<Screen>),
|
||||
}
|
||||
|
||||
impl ScreenFilter {
|
||||
/// Whether the command is available on the given screen.
|
||||
#[must_use]
|
||||
pub fn matches(&self, screen: &Screen) -> bool {
|
||||
match self {
|
||||
Self::Global => true,
|
||||
Self::Only(screens) => screens.contains(screen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unique command identifier.
|
||||
pub type CommandId = &'static str;
|
||||
|
||||
/// A registered command with its keybinding, help text, and scope.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandDef {
|
||||
/// Unique identifier (e.g., "quit", "go_issues").
|
||||
pub id: CommandId,
|
||||
/// Human-readable label for palette and help overlay.
|
||||
pub label: &'static str,
|
||||
/// Keybinding (if any).
|
||||
pub keybinding: Option<KeyCombo>,
|
||||
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
|
||||
pub cli_equivalent: Option<&'static str>,
|
||||
/// Description for help overlay.
|
||||
pub help_text: &'static str,
|
||||
/// Short hint for status bar (e.g., "q:quit").
|
||||
pub status_hint: &'static str,
|
||||
/// Which screens this command is available on.
|
||||
pub available_in: ScreenFilter,
|
||||
/// Whether this command works in Text input mode.
|
||||
pub available_in_text_mode: bool,
|
||||
}
|
||||
227
crates/lore-tui/src/commands/mod.rs
Normal file
227
crates/lore-tui/src/commands/mod.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Command registry — single source of truth for all TUI actions.
|
||||
//!
|
||||
//! Every keybinding, palette entry, help text, CLI equivalent, and
|
||||
//! status hint is generated from [`CommandRegistry`]. No hardcoded
|
||||
//! duplicate maps exist in view/state modules.
|
||||
//!
|
||||
//! Supports single-key and two-key sequences (g-prefix vim bindings).
|
||||
|
||||
mod defs;
|
||||
mod registry;
|
||||
|
||||
// Re-export public API — preserves `crate::commands::{CommandRegistry, build_registry, ...}`.
|
||||
pub use defs::{CommandDef, CommandId, KeyCombo, ScreenFilter};
|
||||
pub use registry::{CommandRegistry, build_registry};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use ftui::{KeyCode, Modifiers};
|
||||
|
||||
use crate::message::{InputMode, Screen};
|
||||
|
||||
#[test]
|
||||
fn test_registry_builds_successfully() {
|
||||
let reg = build_registry();
|
||||
assert!(!reg.is_empty());
|
||||
assert!(reg.len() >= 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_lookup_quit() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_lookup_quit_blocked_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_esc_works_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Escape,
|
||||
&Modifiers::NONE,
|
||||
&Screen::IssueList,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "go_back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_ctrl_p_works_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('p'),
|
||||
&Modifiers::CTRL,
|
||||
&Screen::Search,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "command_palette");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_is_sequence_starter() {
|
||||
let reg = build_registry();
|
||||
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
|
||||
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_sequence_gi() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&KeyCode::Char('i'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "go_issues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_sequence_invalid_second_key() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&KeyCode::Char('x'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_specific_command() {
|
||||
let reg = build_registry();
|
||||
// 'j' (move_down) should work on IssueList
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('j'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::IssueList,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "move_down");
|
||||
|
||||
// 'j' should NOT match on Dashboard (move_down is list-only).
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('j'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_entries_sorted_by_label() {
|
||||
let reg = build_registry();
|
||||
let entries = reg.palette_entries(&Screen::Dashboard);
|
||||
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
|
||||
let mut sorted = labels.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(labels, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_entries_only_include_keybindings() {
|
||||
let reg = build_registry();
|
||||
let entries = reg.help_entries(&Screen::Dashboard);
|
||||
for entry in &entries {
|
||||
assert!(
|
||||
entry.keybinding.is_some(),
|
||||
"help entry without keybinding: {}",
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_hints_non_empty() {
|
||||
let reg = build_registry();
|
||||
let hints = reg.status_hints(&Screen::Dashboard);
|
||||
assert!(!hints.is_empty());
|
||||
// All returned hints should be non-empty strings.
|
||||
for hint in &hints {
|
||||
assert!(!hint.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_equivalents_populated() {
|
||||
let reg = build_registry();
|
||||
let with_cli: Vec<&CommandDef> = reg
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| c.cli_equivalent.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
with_cli.len() >= 5,
|
||||
"expected at least 5 commands with cli_equivalent, got {}",
|
||||
with_cli.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_prefix_timeout_detection() {
|
||||
let reg = build_registry();
|
||||
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
|
||||
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
|
||||
|
||||
// Simulate InputMode::GoPrefix with timeout check.
|
||||
let started = Utc::now();
|
||||
let mode = InputMode::GoPrefix {
|
||||
started_at: started,
|
||||
};
|
||||
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&mode,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_commands_have_nonempty_help() {
|
||||
let reg = build_registry();
|
||||
for cmd in ®.commands {
|
||||
assert!(
|
||||
!cmd.help_text.is_empty(),
|
||||
"command {} has empty help_text",
|
||||
cmd.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
418
crates/lore-tui/src/commands/registry.rs
Normal file
418
crates/lore-tui/src/commands/registry.rs
Normal file
@@ -0,0 +1,418 @@
|
||||
//! Command registry — lookup, indexing, and the canonical command list.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ftui::{KeyCode, Modifiers};
|
||||
|
||||
use crate::message::{InputMode, Screen};
|
||||
|
||||
use super::defs::{CommandDef, KeyCombo, ScreenFilter};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Single source of truth for all TUI commands.
|
||||
///
|
||||
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
|
||||
/// by keybinding and per-screen filtering.
|
||||
pub struct CommandRegistry {
|
||||
pub(crate) commands: Vec<CommandDef>,
|
||||
/// Single-key -> command IDs that start with this key.
|
||||
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
|
||||
/// Full sequence -> command index (for two-key combos).
|
||||
by_sequence: HashMap<KeyCombo, usize>,
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
/// Look up a command by a single key press on a given screen and input mode.
|
||||
///
|
||||
/// Returns `None` if no matching command is found. For sequence starters
|
||||
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
|
||||
/// that case.
|
||||
#[must_use]
|
||||
pub fn lookup_key(
|
||||
&self,
|
||||
code: &KeyCode,
|
||||
modifiers: &Modifiers,
|
||||
screen: &Screen,
|
||||
mode: &InputMode,
|
||||
) -> Option<&CommandDef> {
|
||||
let is_text = matches!(mode, InputMode::Text);
|
||||
let key = (*code, *modifiers);
|
||||
|
||||
let indices = self.by_single_key.get(&key)?;
|
||||
for &idx in indices {
|
||||
let cmd = &self.commands[idx];
|
||||
if !cmd.available_in.matches(screen) {
|
||||
continue;
|
||||
}
|
||||
if is_text && !cmd.available_in_text_mode {
|
||||
continue;
|
||||
}
|
||||
// Only match Single combos here, not sequence starters.
|
||||
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
|
||||
return Some(cmd);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Complete a two-key sequence.
|
||||
///
|
||||
/// Called after the first key of a sequence is detected (e.g., after 'g').
|
||||
#[must_use]
|
||||
pub fn complete_sequence(
|
||||
&self,
|
||||
first_code: &KeyCode,
|
||||
first_modifiers: &Modifiers,
|
||||
second_code: &KeyCode,
|
||||
second_modifiers: &Modifiers,
|
||||
screen: &Screen,
|
||||
) -> Option<&CommandDef> {
|
||||
let combo = KeyCombo::Sequence {
|
||||
first_code: *first_code,
|
||||
first_modifiers: *first_modifiers,
|
||||
second_code: *second_code,
|
||||
second_modifiers: *second_modifiers,
|
||||
};
|
||||
let &idx = self.by_sequence.get(&combo)?;
|
||||
let cmd = &self.commands[idx];
|
||||
if cmd.available_in.matches(screen) {
|
||||
Some(cmd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a key starts a multi-key sequence (e.g., 'g').
|
||||
#[must_use]
|
||||
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
|
||||
self.by_sequence
|
||||
.keys()
|
||||
.any(|combo| combo.starts_with(code, modifiers))
|
||||
}
|
||||
|
||||
/// Commands available for the command palette on a given screen.
|
||||
///
|
||||
/// Returned sorted by label.
|
||||
#[must_use]
|
||||
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
|
||||
let mut entries: Vec<&CommandDef> = self
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.collect();
|
||||
entries.sort_by_key(|c| c.label);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Commands for the help overlay on a given screen.
|
||||
#[must_use]
|
||||
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.filter(|c| c.keybinding.is_some())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Status bar hints for the current screen.
|
||||
#[must_use]
|
||||
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.filter(|c| !c.status_hint.is_empty())
|
||||
.map(|c| c.status_hint)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total number of registered commands.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.commands.len()
|
||||
}
|
||||
|
||||
/// Whether the registry has no commands.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the command registry with all TUI commands.
|
||||
///
|
||||
/// This is the single source of truth — every keybinding, help text,
|
||||
/// and palette entry originates here.
|
||||
#[must_use]
|
||||
pub fn build_registry() -> CommandRegistry {
|
||||
let commands = vec![
|
||||
// --- Global commands ---
|
||||
CommandDef {
|
||||
id: "quit",
|
||||
label: "Quit",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Exit the TUI",
|
||||
status_hint: "q:quit",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_back",
|
||||
label: "Go Back",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
|
||||
cli_equivalent: None,
|
||||
help_text: "Go back to previous screen",
|
||||
status_hint: "esc:back",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: true,
|
||||
},
|
||||
CommandDef {
|
||||
id: "show_help",
|
||||
label: "Help",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Show keybinding help overlay",
|
||||
status_hint: "?:help",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "command_palette",
|
||||
label: "Command Palette",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open command palette",
|
||||
status_hint: "C-p:palette",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: true,
|
||||
},
|
||||
CommandDef {
|
||||
id: "open_in_browser",
|
||||
label: "Open in Browser",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open current entity in browser",
|
||||
status_hint: "o:browser",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "show_cli",
|
||||
label: "Show CLI Equivalent",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Show equivalent lore CLI command",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Navigation: g-prefix sequences ---
|
||||
CommandDef {
|
||||
id: "go_home",
|
||||
label: "Go to Dashboard",
|
||||
keybinding: Some(KeyCombo::g_then('h')),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump to dashboard",
|
||||
status_hint: "gh:home",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_issues",
|
||||
label: "Go to Issues",
|
||||
keybinding: Some(KeyCombo::g_then('i')),
|
||||
cli_equivalent: Some("lore issues"),
|
||||
help_text: "Jump to issue list",
|
||||
status_hint: "gi:issues",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_mrs",
|
||||
label: "Go to Merge Requests",
|
||||
keybinding: Some(KeyCombo::g_then('m')),
|
||||
cli_equivalent: Some("lore mrs"),
|
||||
help_text: "Jump to MR list",
|
||||
status_hint: "gm:mrs",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_search",
|
||||
label: "Go to Search",
|
||||
keybinding: Some(KeyCombo::g_then('/')),
|
||||
cli_equivalent: Some("lore search"),
|
||||
help_text: "Jump to search",
|
||||
status_hint: "g/:search",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_timeline",
|
||||
label: "Go to Timeline",
|
||||
keybinding: Some(KeyCombo::g_then('t')),
|
||||
cli_equivalent: Some("lore timeline"),
|
||||
help_text: "Jump to timeline",
|
||||
status_hint: "gt:timeline",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_who",
|
||||
label: "Go to Who",
|
||||
keybinding: Some(KeyCombo::g_then('w')),
|
||||
cli_equivalent: Some("lore who"),
|
||||
help_text: "Jump to people intelligence",
|
||||
status_hint: "gw:who",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_sync",
|
||||
label: "Go to Sync",
|
||||
keybinding: Some(KeyCombo::g_then('s')),
|
||||
cli_equivalent: Some("lore sync"),
|
||||
help_text: "Jump to sync status",
|
||||
status_hint: "gs:sync",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Vim-style jump list ---
|
||||
CommandDef {
|
||||
id: "jump_back",
|
||||
label: "Jump Back",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump backward through visited detail views",
|
||||
status_hint: "C-o:jump back",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "jump_forward",
|
||||
label: "Jump Forward",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump forward through visited detail views",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- List navigation ---
|
||||
CommandDef {
|
||||
id: "move_down",
|
||||
label: "Move Down",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Move cursor down",
|
||||
status_hint: "j:down",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "move_up",
|
||||
label: "Move Up",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Move cursor up",
|
||||
status_hint: "k:up",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "select_item",
|
||||
label: "Select",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open selected item",
|
||||
status_hint: "enter:open",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Filter ---
|
||||
CommandDef {
|
||||
id: "focus_filter",
|
||||
label: "Filter",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Focus the filter input",
|
||||
status_hint: "/:filter",
|
||||
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Scroll ---
|
||||
CommandDef {
|
||||
id: "scroll_to_top",
|
||||
label: "Scroll to Top",
|
||||
keybinding: Some(KeyCombo::g_then('g')),
|
||||
cli_equivalent: None,
|
||||
help_text: "Scroll to the top of the current view",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
];
|
||||
|
||||
build_from_defs(commands)
|
||||
}
|
||||
|
||||
/// Build index maps from a list of command definitions.
|
||||
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
|
||||
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
|
||||
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
|
||||
|
||||
for (idx, cmd) in commands.iter().enumerate() {
|
||||
if let Some(combo) = &cmd.keybinding {
|
||||
match combo {
|
||||
KeyCombo::Single { code, modifiers } => {
|
||||
by_single_key
|
||||
.entry((*code, *modifiers))
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
KeyCombo::Sequence { .. } => {
|
||||
by_sequence.insert(combo.clone(), idx);
|
||||
// Also index the first key so is_sequence_starter works via by_single_key.
|
||||
if let KeyCombo::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
..
|
||||
} = combo
|
||||
{
|
||||
by_single_key
|
||||
.entry((*first_code, *first_modifiers))
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandRegistry {
|
||||
commands,
|
||||
by_single_key,
|
||||
by_sequence,
|
||||
}
|
||||
}
|
||||
450
crates/lore-tui/src/crash_context.rs
Normal file
450
crates/lore-tui/src/crash_context.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Ring buffer of recent app events for post-mortem crash diagnostics.
|
||||
//!
|
||||
//! The TUI pushes every key press, message dispatch, and state transition
|
||||
//! into [`CrashContext`]. On panic the installed hook dumps the last 2000
|
||||
//! events to `~/.local/share/lore/crash-<timestamp>.json` as NDJSON.
|
||||
//!
|
||||
//! Retention: only the 5 most recent crash files are kept.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Maximum number of events retained in the ring buffer.
|
||||
const MAX_EVENTS: usize = 2000;
|
||||
|
||||
/// Maximum number of crash files to keep on disk.
|
||||
const MAX_CRASH_FILES: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrashEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single event recorded for crash diagnostics.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum CrashEvent {
|
||||
/// A key was pressed.
|
||||
KeyPress {
|
||||
key: String,
|
||||
mode: String,
|
||||
screen: String,
|
||||
},
|
||||
/// A message was dispatched through update().
|
||||
MsgDispatched { msg_name: String, screen: String },
|
||||
/// Navigation changed screens.
|
||||
StateTransition { from: String, to: String },
|
||||
/// An error occurred.
|
||||
Error { message: String },
|
||||
/// Catch-all for ad-hoc diagnostic breadcrumbs.
|
||||
Custom { tag: String, detail: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrashContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ring buffer of recent app events for panic diagnostics.
|
||||
///
|
||||
/// Holds at most [`MAX_EVENTS`] entries. When full, the oldest event
|
||||
/// is evicted on each push.
|
||||
pub struct CrashContext {
|
||||
events: VecDeque<CrashEvent>,
|
||||
}
|
||||
|
||||
impl CrashContext {
|
||||
/// Create an empty crash context with pre-allocated capacity.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: VecDeque::with_capacity(MAX_EVENTS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an event. Evicts the oldest when the buffer is full.
|
||||
pub fn push(&mut self, event: CrashEvent) {
|
||||
if self.events.len() == MAX_EVENTS {
|
||||
self.events.pop_front();
|
||||
}
|
||||
self.events.push_back(event);
|
||||
}
|
||||
|
||||
/// Number of events currently stored.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Whether the buffer is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
/// Iterate over stored events (oldest first).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &CrashEvent> {
|
||||
self.events.iter()
|
||||
}
|
||||
|
||||
/// Dump all events to a file as newline-delimited JSON.
|
||||
///
|
||||
/// Creates parent directories if they don't exist.
|
||||
/// Returns `Ok(())` on success, `Err` on I/O failure.
|
||||
pub fn dump_to_file(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = std::fs::File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
for event in &self.events {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(json) => {
|
||||
writeln!(writer, "{json}")?;
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to debug format if serialization fails.
|
||||
writeln!(
|
||||
writer,
|
||||
"{{\"type\":\"SerializationError\",\"debug\":\"{event:?}\"}}"
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default crash directory: `~/.local/share/lore/`.
|
||||
#[must_use]
|
||||
pub fn crash_dir() -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|d| d.join("lore"))
|
||||
}
|
||||
|
||||
/// Generate a timestamped crash file path.
|
||||
#[must_use]
|
||||
pub fn crash_file_path() -> Option<PathBuf> {
|
||||
let dir = Self::crash_dir()?;
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
|
||||
Some(dir.join(format!("crash-{timestamp}.json")))
|
||||
}
|
||||
|
||||
/// Remove old crash files, keeping only the most recent [`MAX_CRASH_FILES`].
|
||||
///
|
||||
/// Best-effort: silently ignores I/O errors on individual deletions.
|
||||
pub fn prune_crash_files() {
|
||||
let Some(dir) = Self::crash_dir() else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut crash_files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort ascending by filename (timestamps sort lexicographically).
|
||||
crash_files.sort();
|
||||
|
||||
if crash_files.len() > MAX_CRASH_FILES {
|
||||
let to_remove = crash_files.len() - MAX_CRASH_FILES;
|
||||
for path in &crash_files[..to_remove] {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a panic hook that dumps the crash context to disk.
|
||||
///
|
||||
/// Captures the current events via a snapshot. The hook chains with
|
||||
/// the default panic handler so backtraces are still printed.
|
||||
///
|
||||
/// FIXME: This snapshots events at install time, which is typically
|
||||
/// during init() when the buffer is empty. The crash dump will only
|
||||
/// contain the panic itself, not the preceding key presses and state
|
||||
/// transitions. Fix requires CrashContext to use interior mutability
|
||||
/// (Arc<Mutex<VecDeque<CrashEvent>>>) so the panic hook reads live
|
||||
/// state instead of a stale snapshot.
|
||||
pub fn install_panic_hook(ctx: &Self) {
|
||||
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
|
||||
let prev_hook = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
// Best-effort dump — never panic inside the panic hook.
|
||||
if let Some(path) = Self::crash_file_path() {
|
||||
let mut dump = CrashContext::new();
|
||||
for event in &snapshot {
|
||||
dump.push(event.clone());
|
||||
}
|
||||
// Add the panic info itself as the final event.
|
||||
dump.push(CrashEvent::Error {
|
||||
message: format!("{info}"),
|
||||
});
|
||||
let _ = dump.dump_to_file(&path);
|
||||
}
|
||||
|
||||
// Chain to the previous hook (prints backtrace, etc.).
|
||||
prev_hook(info);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CrashContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// Helper: create a numbered Custom event.
|
||||
fn event(n: usize) -> CrashEvent {
|
||||
CrashEvent::Custom {
|
||||
tag: "test".into(),
|
||||
detail: format!("event-{n}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ring_buffer_evicts_oldest() {
|
||||
let mut ctx = CrashContext::new();
|
||||
for i in 0..2500 {
|
||||
ctx.push(event(i));
|
||||
}
|
||||
assert_eq!(ctx.len(), MAX_EVENTS);
|
||||
|
||||
// First retained event should be #500 (0..499 evicted).
|
||||
let first = ctx.iter().next().unwrap();
|
||||
match first {
|
||||
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-500"),
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
|
||||
// Last retained event should be #2499.
|
||||
let last = ctx.iter().last().unwrap();
|
||||
match last {
|
||||
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-2499"),
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_is_empty() {
|
||||
let ctx = CrashContext::new();
|
||||
assert!(ctx.is_empty());
|
||||
assert_eq!(ctx.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_increments_len() {
|
||||
let mut ctx = CrashContext::new();
|
||||
ctx.push(event(1));
|
||||
ctx.push(event(2));
|
||||
assert_eq!(ctx.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_does_not_evict_below_capacity() {
|
||||
let mut ctx = CrashContext::new();
|
||||
for i in 0..MAX_EVENTS {
|
||||
ctx.push(event(i));
|
||||
}
|
||||
assert_eq!(ctx.len(), MAX_EVENTS);
|
||||
|
||||
// First should still be event-0.
|
||||
match ctx.iter().next().unwrap() {
|
||||
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-0"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dump_to_file_writes_ndjson() {
|
||||
let mut ctx = CrashContext::new();
|
||||
ctx.push(CrashEvent::KeyPress {
|
||||
key: "j".into(),
|
||||
mode: "Normal".into(),
|
||||
screen: "Dashboard".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::MsgDispatched {
|
||||
msg_name: "NavigateTo".into(),
|
||||
screen: "Dashboard".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::StateTransition {
|
||||
from: "Dashboard".into(),
|
||||
to: "IssueList".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::Error {
|
||||
message: "db busy".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::Custom {
|
||||
tag: "test".into(),
|
||||
detail: "hello".into(),
|
||||
});
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test-crash.json");
|
||||
ctx.dump_to_file(&path).unwrap();
|
||||
|
||||
// Verify: each line is valid JSON, total lines == 5.
|
||||
let file = std::fs::File::open(&path).unwrap();
|
||||
let reader = io::BufReader::new(file);
|
||||
let lines: Vec<String> = reader.lines().map(Result::unwrap).collect();
|
||||
assert_eq!(lines.len(), 5);
|
||||
|
||||
// Each line must parse as JSON.
|
||||
for line in &lines {
|
||||
let val: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
assert!(val.get("type").is_some(), "missing 'type' field: {line}");
|
||||
}
|
||||
|
||||
// Spot check first line: KeyPress with correct fields.
|
||||
let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
|
||||
assert_eq!(first["type"], "KeyPress");
|
||||
assert_eq!(first["key"], "j");
|
||||
assert_eq!(first["mode"], "Normal");
|
||||
assert_eq!(first["screen"], "Dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dump_creates_parent_directories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join("a").join("b").join("c").join("crash.json");
|
||||
|
||||
let mut ctx = CrashContext::new();
|
||||
ctx.push(event(1));
|
||||
ctx.dump_to_file(&nested).unwrap();
|
||||
|
||||
assert!(nested.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dump_empty_context_creates_empty_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("empty.json");
|
||||
|
||||
let ctx = CrashContext::new();
|
||||
ctx.dump_to_file(&path).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_keeps_newest_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let crash_dir = dir.path();
|
||||
|
||||
// Create 8 crash files with ordered timestamps.
|
||||
let filenames: Vec<String> = (0..8)
|
||||
.map(|i| format!("crash-2026010{i}-120000.000.json"))
|
||||
.collect();
|
||||
for name in &filenames {
|
||||
std::fs::write(crash_dir.join(name), "{}").unwrap();
|
||||
}
|
||||
|
||||
// Prune, pointing at our temp dir.
|
||||
prune_crash_files_in(crash_dir);
|
||||
|
||||
let remaining: Vec<String> = std::fs::read_dir(crash_dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with("crash-") && n.ends_with(".json"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(remaining.len(), MAX_CRASH_FILES);
|
||||
// Oldest 3 should be gone.
|
||||
for name in filenames.iter().take(3) {
|
||||
assert!(!remaining.contains(name));
|
||||
}
|
||||
// Newest 5 should remain.
|
||||
for name in filenames.iter().skip(3) {
|
||||
assert!(remaining.contains(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_event_variants_serialize() {
|
||||
let events = vec![
|
||||
CrashEvent::KeyPress {
|
||||
key: "q".into(),
|
||||
mode: "Normal".into(),
|
||||
screen: "Dashboard".into(),
|
||||
},
|
||||
CrashEvent::MsgDispatched {
|
||||
msg_name: "Quit".into(),
|
||||
screen: "Dashboard".into(),
|
||||
},
|
||||
CrashEvent::StateTransition {
|
||||
from: "Dashboard".into(),
|
||||
to: "IssueList".into(),
|
||||
},
|
||||
CrashEvent::Error {
|
||||
message: "oops".into(),
|
||||
},
|
||||
CrashEvent::Custom {
|
||||
tag: "debug".into(),
|
||||
detail: "trace".into(),
|
||||
},
|
||||
];
|
||||
|
||||
for event in events {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.get("type").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_new() {
|
||||
let ctx = CrashContext::default();
|
||||
assert!(ctx.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test helper: prune files in a specific directory (not the real path).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn prune_crash_files_in(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut crash_files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
crash_files.sort();
|
||||
|
||||
if crash_files.len() > MAX_CRASH_FILES {
|
||||
let to_remove = crash_files.len() - MAX_CRASH_FILES;
|
||||
for path in &crash_files[..to_remove] {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
270
crates/lore-tui/src/db.rs
Normal file
270
crates/lore-tui/src/db.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
||||
|
||||
//! Database access layer for the TUI.
|
||||
//!
|
||||
//! Provides a read pool (3 connections, round-robin) plus a dedicated writer
|
||||
//! connection. All connections use WAL mode and busy_timeout for concurrency.
|
||||
//!
|
||||
//! The TUI operates read-heavy: parallel queries for dashboard, list views,
|
||||
//! and prefetch. Writes are rare (TUI-local state: scroll positions, bookmarks).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Number of reader connections in the pool.
|
||||
const READER_COUNT: usize = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DbManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages a pool of read-only connections plus a dedicated writer.
|
||||
///
|
||||
/// Designed for `Arc<DbManager>` sharing across FrankenTUI's `Cmd::task`
|
||||
/// background threads. Each reader is individually `Mutex`-protected so
|
||||
/// concurrent tasks can query different readers without blocking.
|
||||
pub struct DbManager {
|
||||
readers: Vec<Mutex<Connection>>,
|
||||
writer: Mutex<Connection>,
|
||||
next_reader: AtomicUsize,
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
/// Open a database at `path` with 3 reader + 1 writer connections.
|
||||
///
|
||||
/// All connections get WAL mode, 5000ms busy_timeout, and foreign keys.
|
||||
/// Reader connections additionally set `query_only = ON` as a safety guard.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let mut readers = Vec::with_capacity(READER_COUNT);
|
||||
for i in 0..READER_COUNT {
|
||||
let conn =
|
||||
open_connection(path).with_context(|| format!("opening reader connection {i}"))?;
|
||||
conn.pragma_update(None, "query_only", "ON")
|
||||
.context("setting query_only on reader")?;
|
||||
readers.push(Mutex::new(conn));
|
||||
}
|
||||
|
||||
let writer = open_connection(path).context("opening writer connection")?;
|
||||
|
||||
Ok(Self {
|
||||
readers,
|
||||
writer: Mutex::new(writer),
|
||||
next_reader: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a read-only query against the pool.
|
||||
///
|
||||
/// Selects the next reader via round-robin. The connection is borrowed
|
||||
/// for the duration of `f` and cannot leak outside.
|
||||
pub fn with_reader<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Result<T>,
|
||||
{
|
||||
let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % READER_COUNT;
|
||||
let conn = self.readers[idx].lock().expect("reader mutex poisoned");
|
||||
f(&conn)
|
||||
}
|
||||
|
||||
/// Execute a write operation against the dedicated writer.
|
||||
///
|
||||
/// Serialized via a single `Mutex`. The TUI writes infrequently
|
||||
/// (bookmarks, scroll state) so contention is negligible.
|
||||
pub fn with_writer<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&Connection) -> Result<T>,
|
||||
{
|
||||
let conn = self.writer.lock().expect("writer mutex poisoned");
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open a single SQLite connection with TUI-appropriate pragmas.
|
||||
///
|
||||
/// Mirrors lore's `create_connection` pragmas (WAL, busy_timeout, etc.)
|
||||
/// but skips the sqlite-vec extension registration — the TUI reads standard
|
||||
/// tables only, never vec0 virtual tables.
|
||||
fn open_connection(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path).context("opening SQLite database")?;
|
||||
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
conn.pragma_update(None, "busy_timeout", 5000)?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Create a temporary database file for testing.
|
||||
///
|
||||
/// Uses an atomic counter + thread ID to guarantee unique paths even
|
||||
/// when tests run in parallel.
|
||||
fn test_db_path() -> std::path::PathBuf {
|
||||
use std::sync::atomic::AtomicU64;
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join("lore-tui-tests");
|
||||
std::fs::create_dir_all(&dir).expect("create test dir");
|
||||
dir.join(format!(
|
||||
"test-{}-{:?}-{n}.db",
|
||||
std::process::id(),
|
||||
std::thread::current().id(),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_test_table(conn: &Connection) {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS test_items (id INTEGER PRIMARY KEY, name TEXT);",
|
||||
)
|
||||
.expect("create test table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dbmanager_opens_successfully() {
|
||||
let path = test_db_path();
|
||||
let db = DbManager::open(&path).expect("open");
|
||||
// Writer creates the test table
|
||||
db.with_writer(|conn| {
|
||||
create_test_table(conn);
|
||||
Ok(())
|
||||
})
|
||||
.expect("create table via writer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reader_is_query_only() {
|
||||
let path = test_db_path();
|
||||
let db = DbManager::open(&path).expect("open");
|
||||
|
||||
// Create table via writer first
|
||||
db.with_writer(|conn| {
|
||||
create_test_table(conn);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Attempt INSERT via reader — should fail
|
||||
let result = db.with_reader(|conn| {
|
||||
conn.execute("INSERT INTO test_items (name) VALUES ('boom')", [])
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err(), "reader should reject writes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_writer_allows_mutations() {
|
||||
let path = test_db_path();
|
||||
let db = DbManager::open(&path).expect("open");
|
||||
|
||||
db.with_writer(|conn| {
|
||||
create_test_table(conn);
|
||||
conn.execute("INSERT INTO test_items (name) VALUES ('hello')", [])?;
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
|
||||
assert_eq!(count, 1);
|
||||
Ok(())
|
||||
})
|
||||
.expect("writer should allow mutations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_round_robin_rotates_readers() {
|
||||
let path = test_db_path();
|
||||
let db = DbManager::open(&path).expect("open");
|
||||
|
||||
// Call with_reader 6 times — should cycle through readers 0,1,2,0,1,2
|
||||
for expected_cycle in 0..2 {
|
||||
for expected_idx in 0..READER_COUNT {
|
||||
let current = db.next_reader.load(Ordering::Relaxed);
|
||||
assert_eq!(
|
||||
current % READER_COUNT,
|
||||
(expected_cycle * READER_COUNT + expected_idx) % READER_COUNT,
|
||||
);
|
||||
db.with_reader(|_conn| Ok(())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reader_can_read_writer_data() {
|
||||
let path = test_db_path();
|
||||
let db = DbManager::open(&path).expect("open");
|
||||
|
||||
db.with_writer(|conn| {
|
||||
create_test_table(conn);
|
||||
conn.execute("INSERT INTO test_items (name) VALUES ('visible')", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let name: String = db
|
||||
.with_reader(|conn| {
|
||||
let n: String =
|
||||
conn.query_row("SELECT name FROM test_items WHERE id = 1", [], |r| r.get(0))?;
|
||||
Ok(n)
|
||||
})
|
||||
.expect("reader should see writer's data");
|
||||
|
||||
assert_eq!(name, "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dbmanager_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<DbManager>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concurrent_reads() {
|
||||
let path = test_db_path();
|
||||
let db = Arc::new(DbManager::open(&path).expect("open"));
|
||||
|
||||
db.with_writer(|conn| {
|
||||
create_test_table(conn);
|
||||
for i in 0..10 {
|
||||
conn.execute(
|
||||
"INSERT INTO test_items (name) VALUES (?1)",
|
||||
[format!("item-{i}")],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..6 {
|
||||
let db = Arc::clone(&db);
|
||||
handles.push(std::thread::spawn(move || {
|
||||
db.with_reader(|conn| {
|
||||
let count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
|
||||
assert_eq!(count, 10);
|
||||
Ok(())
|
||||
})
|
||||
.expect("concurrent read should succeed");
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
h.join().expect("thread should not panic");
|
||||
}
|
||||
}
|
||||
}
|
||||
316
crates/lore-tui/src/filter_dsl.rs
Normal file
316
crates/lore-tui/src/filter_dsl.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by filter_bar widget
|
||||
|
||||
//! Filter DSL parser for entity list screens.
|
||||
//!
|
||||
//! Parses a compact filter string into structured tokens:
|
||||
//! - `field:value` — typed field filter (e.g., `state:opened`, `author:taylor`)
|
||||
//! - `-field:value` — negation filter (exclude matches)
|
||||
//! - `"quoted value"` — preserved as a single free-text token
|
||||
//! - bare words — free-text search terms
|
||||
//!
|
||||
//! The DSL is intentionally simple: no boolean operators, no nesting.
|
||||
//! Filters are AND-combined at the query layer.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single parsed filter token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FilterToken {
|
||||
/// `field:value` — match entities where `field` equals `value`.
|
||||
FieldValue { field: String, value: String },
|
||||
/// `-field:value` — exclude entities where `field` equals `value`.
|
||||
Negation { field: String, value: String },
|
||||
/// Bare word(s) used as free-text search.
|
||||
FreeText(String),
|
||||
/// `"quoted value"` — preserved as a single search term.
|
||||
QuotedValue(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known fields per entity type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Known filter fields for issues.
|
||||
pub const ISSUE_FIELDS: &[&str] = &[
|
||||
"state",
|
||||
"author",
|
||||
"assignee",
|
||||
"label",
|
||||
"milestone",
|
||||
"status",
|
||||
];
|
||||
|
||||
/// Known filter fields for merge requests.
|
||||
pub const MR_FIELDS: &[&str] = &[
|
||||
"state",
|
||||
"author",
|
||||
"reviewer",
|
||||
"target_branch",
|
||||
"source_branch",
|
||||
"label",
|
||||
"draft",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a filter input string into a sequence of tokens.
|
||||
///
|
||||
/// Empty input returns an empty vec (no-op filter = show all).
|
||||
pub fn parse_filter_tokens(input: &str) -> Vec<FilterToken> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while chars.peek().is_some() {
|
||||
// Skip whitespace between tokens.
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
|
||||
match chars.peek() {
|
||||
None => break,
|
||||
Some('"') => {
|
||||
// Quoted value — consume until closing quote or end.
|
||||
chars.next(); // consume opening "
|
||||
let value: String = consume_until(&mut chars, '"');
|
||||
if chars.peek() == Some(&'"') {
|
||||
chars.next(); // consume closing "
|
||||
}
|
||||
if !value.is_empty() {
|
||||
tokens.push(FilterToken::QuotedValue(value));
|
||||
}
|
||||
}
|
||||
Some('-') => {
|
||||
// Could be negation prefix or just a free-text word starting with -.
|
||||
chars.next(); // consume -
|
||||
let word = consume_word(&mut chars);
|
||||
if let Some((field, value)) = word.split_once(':') {
|
||||
tokens.push(FilterToken::Negation {
|
||||
field: field.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
} else if !word.is_empty() {
|
||||
// Bare negation without field:value — treat as free text with -.
|
||||
tokens.push(FilterToken::FreeText(format!("-{word}")));
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
let word = consume_word(&mut chars);
|
||||
if let Some((field, value)) = word.split_once(':') {
|
||||
tokens.push(FilterToken::FieldValue {
|
||||
field: field.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
} else if !word.is_empty() {
|
||||
tokens.push(FilterToken::FreeText(word));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Validate that a field name is known for the given entity type.
|
||||
///
|
||||
/// Returns `true` if the field is in the known set, `false` otherwise.
|
||||
pub fn is_known_field(field: &str, known_fields: &[&str]) -> bool {
|
||||
known_fields.contains(&field)
|
||||
}
|
||||
|
||||
/// Extract all unknown fields from a token list.
|
||||
pub fn unknown_fields<'a>(tokens: &'a [FilterToken], known_fields: &[&str]) -> Vec<&'a str> {
|
||||
tokens
|
||||
.iter()
|
||||
.filter_map(|t| match t {
|
||||
FilterToken::FieldValue { field, .. } | FilterToken::Negation { field, .. } => {
|
||||
if is_known_field(field, known_fields) {
|
||||
None
|
||||
} else {
|
||||
Some(field.as_str())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Consume characters until `delim` is found (exclusive) or end of input.
|
||||
fn consume_until(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, delim: char) -> String {
|
||||
let mut buf = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c == delim {
|
||||
break;
|
||||
}
|
||||
buf.push(c);
|
||||
chars.next();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Consume a non-whitespace word.
|
||||
fn consume_word(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
|
||||
let mut buf = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
// Stop at quote boundaries so they're handled separately.
|
||||
if c == '"' && !buf.is_empty() {
|
||||
break;
|
||||
}
|
||||
buf.push(c);
|
||||
chars.next();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- TDD Anchor: basic field:value parsing --
|
||||
|
||||
#[test]
|
||||
fn test_parse_filter_basic() {
|
||||
let tokens = parse_filter_tokens("state:opened author:taylor");
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(
|
||||
tokens[0],
|
||||
FilterToken::FieldValue {
|
||||
field: "state".into(),
|
||||
value: "opened".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tokens[1],
|
||||
FilterToken::FieldValue {
|
||||
field: "author".into(),
|
||||
value: "taylor".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_quoted_value() {
|
||||
let tokens = parse_filter_tokens("\"in progress\"");
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0], FilterToken::QuotedValue("in progress".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_negation() {
|
||||
let tokens = parse_filter_tokens("-state:closed");
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(
|
||||
tokens[0],
|
||||
FilterToken::Negation {
|
||||
field: "state".into(),
|
||||
value: "closed".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mixed() {
|
||||
let tokens = parse_filter_tokens("state:opened \"bug fix\" -label:wontfix");
|
||||
assert_eq!(tokens.len(), 3);
|
||||
assert_eq!(
|
||||
tokens[0],
|
||||
FilterToken::FieldValue {
|
||||
field: "state".into(),
|
||||
value: "opened".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(tokens[1], FilterToken::QuotedValue("bug fix".into()));
|
||||
assert_eq!(
|
||||
tokens[2],
|
||||
FilterToken::Negation {
|
||||
field: "label".into(),
|
||||
value: "wontfix".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_returns_empty() {
|
||||
assert!(parse_filter_tokens("").is_empty());
|
||||
assert!(parse_filter_tokens(" ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_free_text() {
|
||||
let tokens = parse_filter_tokens("authentication bug");
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(tokens[0], FilterToken::FreeText("authentication".into()));
|
||||
assert_eq!(tokens[1], FilterToken::FreeText("bug".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bare_negation_as_free_text() {
|
||||
let tokens = parse_filter_tokens("-wontfix");
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0], FilterToken::FreeText("-wontfix".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unicode() {
|
||||
let tokens = parse_filter_tokens("author:田中 \"認証バグ\"");
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert_eq!(
|
||||
tokens[0],
|
||||
FilterToken::FieldValue {
|
||||
field: "author".into(),
|
||||
value: "田中".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(tokens[1], FilterToken::QuotedValue("認証バグ".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unclosed_quote() {
|
||||
let tokens = parse_filter_tokens("\"open ended");
|
||||
assert_eq!(tokens.len(), 1);
|
||||
assert_eq!(tokens[0], FilterToken::QuotedValue("open ended".into()));
|
||||
}
|
||||
|
||||
// -- Field validation --
|
||||
|
||||
#[test]
|
||||
fn test_known_field_issues() {
|
||||
assert!(is_known_field("state", ISSUE_FIELDS));
|
||||
assert!(is_known_field("author", ISSUE_FIELDS));
|
||||
assert!(!is_known_field("reviewer", ISSUE_FIELDS));
|
||||
assert!(!is_known_field("bogus", ISSUE_FIELDS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_field_mrs() {
|
||||
assert!(is_known_field("draft", MR_FIELDS));
|
||||
assert!(is_known_field("reviewer", MR_FIELDS));
|
||||
assert!(!is_known_field("assignee", MR_FIELDS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_fields_detection() {
|
||||
let tokens = parse_filter_tokens("state:opened bogus:val author:taylor unknown:x");
|
||||
let unknown = unknown_fields(&tokens, ISSUE_FIELDS);
|
||||
assert_eq!(unknown, vec!["bogus", "unknown"]);
|
||||
}
|
||||
}
|
||||
102
crates/lore-tui/src/layout.rs
Normal file
102
crates/lore-tui/src/layout.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
|
||||
//! Responsive layout helpers for the Lore TUI.
|
||||
//!
|
||||
//! Wraps [`ftui::layout::Breakpoint`] and [`ftui::layout::Breakpoints`] with
|
||||
//! Lore-specific configuration: breakpoint thresholds, column counts per
|
||||
//! breakpoint, and preview-pane visibility rules.
|
||||
|
||||
use ftui::layout::{Breakpoint, Breakpoints};
|
||||
|
||||
/// Lore-specific breakpoint thresholds.
|
||||
///
|
||||
/// Uses the ftui defaults: Sm=60, Md=90, Lg=120, Xl=160 columns.
|
||||
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::DEFAULT;
|
||||
|
||||
/// Classify a terminal width into a [`Breakpoint`].
|
||||
#[inline]
|
||||
pub fn classify_width(width: u16) -> Breakpoint {
|
||||
LORE_BREAKPOINTS.classify_width(width)
|
||||
}
|
||||
|
||||
/// Number of dashboard columns for a given breakpoint.
|
||||
///
|
||||
/// - `Xs` / `Sm`: 1 column (narrow terminals)
|
||||
/// - `Md`: 2 columns (standard width)
|
||||
/// - `Lg` / `Xl`: 3 columns (wide terminals)
|
||||
#[inline]
|
||||
pub const fn dashboard_columns(bp: Breakpoint) -> u16 {
|
||||
match bp {
|
||||
Breakpoint::Xs | Breakpoint::Sm => 1,
|
||||
Breakpoint::Md => 2,
|
||||
Breakpoint::Lg | Breakpoint::Xl => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the preview pane should be visible at a given breakpoint.
|
||||
///
|
||||
/// Preview requires at least `Md` width to avoid cramping the main list.
|
||||
#[inline]
|
||||
pub const fn show_preview_pane(bp: Breakpoint) -> bool {
|
||||
match bp {
|
||||
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
|
||||
Breakpoint::Xs | Breakpoint::Sm => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_classify_width_boundaries() {
|
||||
// Xs: 0..59
|
||||
assert_eq!(classify_width(59), Breakpoint::Xs);
|
||||
// Sm: 60..89
|
||||
assert_eq!(classify_width(60), Breakpoint::Sm);
|
||||
assert_eq!(classify_width(89), Breakpoint::Sm);
|
||||
// Md: 90..119
|
||||
assert_eq!(classify_width(90), Breakpoint::Md);
|
||||
assert_eq!(classify_width(119), Breakpoint::Md);
|
||||
// Lg: 120..159
|
||||
assert_eq!(classify_width(120), Breakpoint::Lg);
|
||||
assert_eq!(classify_width(159), Breakpoint::Lg);
|
||||
// Xl: 160+
|
||||
assert_eq!(classify_width(160), Breakpoint::Xl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_columns_per_breakpoint() {
|
||||
assert_eq!(dashboard_columns(Breakpoint::Xs), 1);
|
||||
assert_eq!(dashboard_columns(Breakpoint::Sm), 1);
|
||||
assert_eq!(dashboard_columns(Breakpoint::Md), 2);
|
||||
assert_eq!(dashboard_columns(Breakpoint::Lg), 3);
|
||||
assert_eq!(dashboard_columns(Breakpoint::Xl), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_preview_pane_per_breakpoint() {
|
||||
assert!(!show_preview_pane(Breakpoint::Xs));
|
||||
assert!(!show_preview_pane(Breakpoint::Sm));
|
||||
assert!(show_preview_pane(Breakpoint::Md));
|
||||
assert!(show_preview_pane(Breakpoint::Lg));
|
||||
assert!(show_preview_pane(Breakpoint::Xl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// Width 0 must not panic, should classify as Xs
|
||||
assert_eq!(classify_width(0), Breakpoint::Xs);
|
||||
// Very wide terminal
|
||||
assert_eq!(classify_width(300), Breakpoint::Xl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_breakpoints_matches_defaults() {
|
||||
assert_eq!(LORE_BREAKPOINTS, Breakpoints::DEFAULT);
|
||||
}
|
||||
}
|
||||
71
crates/lore-tui/src/lib.rs
Normal file
71
crates/lore-tui/src/lib.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Gitlore TUI — terminal interface for exploring GitLab data locally.
|
||||
//!
|
||||
//! Built on FrankenTUI (Elm architecture): Model, update, view.
|
||||
//! The `lore` CLI spawns `lore-tui` via PATH lookup at runtime.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
// Phase 0 modules.
|
||||
pub mod clock; // Clock trait: SystemClock + FakeClock (bd-2lg6)
|
||||
pub mod message; // Msg, Screen, EntityKey, AppError, InputMode (bd-c9gk)
|
||||
|
||||
pub mod safety; // Terminal safety: sanitize + URL policy + redact (bd-3ir1)
|
||||
|
||||
pub mod db; // DbManager: read pool + dedicated writer (bd-2kop)
|
||||
pub mod theme; // Flexoki theme: build_theme, state_color, label_style (bd-5ofk)
|
||||
|
||||
pub mod app; // LoreApp Model trait impl (Phase 0 proof: bd-2emv, full: bd-6pmy)
|
||||
|
||||
// Phase 1 modules.
|
||||
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
|
||||
pub mod crash_context; // CrashContext ring buffer + panic hook (bd-2fr7)
|
||||
pub mod layout; // Responsive layout: breakpoints, columns, preview pane (bd-1pzj)
|
||||
pub mod navigation; // NavigationStack: back/forward/jump list (bd-1qpp)
|
||||
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
|
||||
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
|
||||
pub mod view; // View layer: render_screen + common widgets (bd-26f2)
|
||||
|
||||
// Phase 2 modules.
|
||||
pub mod action; // Data-fetching actions for TUI screens (bd-35g5+)
|
||||
pub mod filter_dsl; // Filter DSL tokenizer for list screen filter bars (bd-18qs)
|
||||
|
||||
/// Options controlling how the TUI launches.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOptions {
|
||||
/// Path to lore config file.
|
||||
pub config_path: Option<String>,
|
||||
/// Run a background sync before displaying data.
|
||||
pub sync_on_start: bool,
|
||||
/// Clear cached TUI state and start fresh.
|
||||
pub fresh: bool,
|
||||
/// Render backend: "crossterm" or "native".
|
||||
pub render_mode: String,
|
||||
/// Use ASCII-only box drawing characters.
|
||||
pub ascii: bool,
|
||||
/// Disable alternate screen (render inline).
|
||||
pub no_alt_screen: bool,
|
||||
}
|
||||
|
||||
/// Launch the TUI in browse mode (no sync).
|
||||
///
|
||||
/// Loads config from `options.config_path` (or default location),
|
||||
/// opens the database read-only, and enters the FrankenTUI event loop.
|
||||
pub fn launch_tui(options: LaunchOptions) -> Result<()> {
|
||||
let _options = options;
|
||||
// Phase 1 will wire this to LoreApp + App::fullscreen().run()
|
||||
eprintln!("lore-tui: browse mode not yet implemented (Phase 1)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Launch the TUI with an initial sync pass.
|
||||
///
|
||||
/// Runs `lore sync` in the background while displaying a progress screen,
|
||||
/// then transitions to browse mode once sync completes.
|
||||
pub fn launch_sync_tui(options: LaunchOptions) -> Result<()> {
|
||||
let _options = options;
|
||||
// Phase 2 will implement the sync progress screen
|
||||
eprintln!("lore-tui: sync mode not yet implemented (Phase 2)");
|
||||
Ok(())
|
||||
}
|
||||
53
crates/lore-tui/src/main.rs
Normal file
53
crates/lore-tui/src/main.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use lore_tui::LaunchOptions;
|
||||
|
||||
/// Terminal UI for Gitlore — explore GitLab issues, MRs, and search locally.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "lore-tui", version, about)]
|
||||
struct TuiCli {
|
||||
/// Path to lore config file (default: ~/.config/lore/config.json).
|
||||
#[arg(short, long, env = "LORE_CONFIG_PATH")]
|
||||
config: Option<String>,
|
||||
|
||||
/// Run a sync before launching the TUI.
|
||||
#[arg(long)]
|
||||
sync: bool,
|
||||
|
||||
/// Clear cached state and start fresh.
|
||||
#[arg(long)]
|
||||
fresh: bool,
|
||||
|
||||
/// Render mode: "crossterm" (default) or "native".
|
||||
#[arg(long, default_value = "crossterm")]
|
||||
render_mode: String,
|
||||
|
||||
/// Use ASCII-only drawing characters (no Unicode box drawing).
|
||||
#[arg(long)]
|
||||
ascii: bool,
|
||||
|
||||
/// Disable alternate screen (render inline).
|
||||
#[arg(long)]
|
||||
no_alt_screen: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = TuiCli::parse();
|
||||
|
||||
let options = LaunchOptions {
|
||||
config_path: cli.config,
|
||||
sync_on_start: cli.sync,
|
||||
fresh: cli.fresh,
|
||||
render_mode: cli.render_mode,
|
||||
ascii: cli.ascii,
|
||||
no_alt_screen: cli.no_alt_screen,
|
||||
};
|
||||
|
||||
if options.sync_on_start {
|
||||
lore_tui::launch_sync_tui(options)
|
||||
} else {
|
||||
lore_tui::launch_tui(options)
|
||||
}
|
||||
}
|
||||
503
crates/lore-tui/src/message.rs
Normal file
503
crates/lore-tui/src/message.rs
Normal file
@@ -0,0 +1,503 @@
|
||||
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
||||
|
||||
//! Core types for the lore-tui Elm architecture.
|
||||
//!
|
||||
//! - [`Msg`] — every user action and async result flows through this enum.
|
||||
//! - [`Screen`] — navigation targets.
|
||||
//! - [`EntityKey`] — safe cross-project entity identity.
|
||||
//! - [`AppError`] — structured error display in the TUI.
|
||||
//! - [`InputMode`] — controls key dispatch routing.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use ftui::Event;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityKind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Distinguishes issue vs merge request in an [`EntityKey`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EntityKind {
|
||||
Issue,
|
||||
MergeRequest,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Uniquely identifies an entity (issue or MR) across projects.
|
||||
///
|
||||
/// Bare `iid` is unsafe in multi-project datasets — equality requires
|
||||
/// project_id + iid + kind.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct EntityKey {
|
||||
pub project_id: i64,
|
||||
pub iid: i64,
|
||||
pub kind: EntityKind,
|
||||
}
|
||||
|
||||
impl EntityKey {
|
||||
#[must_use]
|
||||
pub fn issue(project_id: i64, iid: i64) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
iid,
|
||||
kind: EntityKind::Issue,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mr(project_id: i64, iid: i64) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
iid,
|
||||
kind: EntityKind::MergeRequest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EntityKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let prefix = match self.kind {
|
||||
EntityKind::Issue => "#",
|
||||
EntityKind::MergeRequest => "!",
|
||||
};
|
||||
write!(f, "p{}:{}{}", self.project_id, prefix, self.iid)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Navigation targets within the TUI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Screen {
|
||||
Dashboard,
|
||||
IssueList,
|
||||
IssueDetail(EntityKey),
|
||||
MrList,
|
||||
MrDetail(EntityKey),
|
||||
Search,
|
||||
Timeline,
|
||||
Who,
|
||||
Sync,
|
||||
Stats,
|
||||
Doctor,
|
||||
Bootstrap,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// Human-readable label for breadcrumbs and status bar.
|
||||
#[must_use]
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Dashboard => "Dashboard",
|
||||
Self::IssueList => "Issues",
|
||||
Self::IssueDetail(_) => "Issue",
|
||||
Self::MrList => "Merge Requests",
|
||||
Self::MrDetail(_) => "Merge Request",
|
||||
Self::Search => "Search",
|
||||
Self::Timeline => "Timeline",
|
||||
Self::Who => "Who",
|
||||
Self::Sync => "Sync",
|
||||
Self::Stats => "Stats",
|
||||
Self::Doctor => "Doctor",
|
||||
Self::Bootstrap => "Bootstrap",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this screen shows a specific entity detail view.
|
||||
#[must_use]
|
||||
pub fn is_detail_or_entity(&self) -> bool {
|
||||
matches!(self, Self::IssueDetail(_) | Self::MrDetail(_))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured error types for user-facing display in the TUI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppError {
|
||||
/// Database is busy (WAL contention).
|
||||
DbBusy,
|
||||
/// Database corruption detected.
|
||||
DbCorruption(String),
|
||||
/// GitLab rate-limited; retry after N seconds (if header present).
|
||||
NetworkRateLimited { retry_after_secs: Option<u64> },
|
||||
/// Network unavailable.
|
||||
NetworkUnavailable,
|
||||
/// GitLab authentication failed.
|
||||
AuthFailed,
|
||||
/// Data parsing error.
|
||||
ParseError(String),
|
||||
/// Internal / unexpected error.
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AppError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DbBusy => write!(f, "Database is busy — another process holds the lock"),
|
||||
Self::DbCorruption(detail) => write!(f, "Database corruption: {detail}"),
|
||||
Self::NetworkRateLimited {
|
||||
retry_after_secs: Some(secs),
|
||||
} => write!(f, "Rate limited by GitLab — retry in {secs}s"),
|
||||
Self::NetworkRateLimited {
|
||||
retry_after_secs: None,
|
||||
} => write!(f, "Rate limited by GitLab — try again shortly"),
|
||||
Self::NetworkUnavailable => write!(f, "Network unavailable — working offline"),
|
||||
Self::AuthFailed => write!(f, "GitLab authentication failed — check your token"),
|
||||
Self::ParseError(detail) => write!(f, "Parse error: {detail}"),
|
||||
Self::Internal(detail) => write!(f, "Internal error: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InputMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Controls how keystrokes are routed through the key dispatch pipeline.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum InputMode {
|
||||
/// Standard navigation mode — keys dispatch to screen-specific handlers.
|
||||
#[default]
|
||||
Normal,
|
||||
/// Text input focused (filter bar, search box).
|
||||
Text,
|
||||
/// Command palette is open.
|
||||
Palette,
|
||||
/// "g" prefix pressed — waiting for second key (500ms timeout).
|
||||
GoPrefix { started_at: DateTime<Utc> },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Msg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every user action and async result flows through this enum.
|
||||
///
|
||||
/// Generation fields (`generation: u64`) on async result variants enable
|
||||
/// stale-response detection: if the generation doesn't match the current
|
||||
/// request generation, the result is silently dropped.
|
||||
#[derive(Debug)]
|
||||
pub enum Msg {
|
||||
// --- Terminal events ---
|
||||
/// Raw terminal event (key, mouse, paste, focus, clipboard).
|
||||
RawEvent(Event),
|
||||
/// Periodic tick from runtime subscription.
|
||||
Tick,
|
||||
/// Terminal resized.
|
||||
Resize {
|
||||
width: u16,
|
||||
height: u16,
|
||||
},
|
||||
|
||||
// --- Navigation ---
|
||||
/// Navigate to a specific screen.
|
||||
NavigateTo(Screen),
|
||||
/// Go back in navigation history.
|
||||
GoBack,
|
||||
/// Go forward in navigation history.
|
||||
GoForward,
|
||||
/// Jump to the dashboard.
|
||||
GoHome,
|
||||
/// Jump back N screens in history.
|
||||
JumpBack(usize),
|
||||
/// Jump forward N screens in history.
|
||||
JumpForward(usize),
|
||||
|
||||
// --- Command palette ---
|
||||
OpenCommandPalette,
|
||||
CloseCommandPalette,
|
||||
CommandPaletteInput(String),
|
||||
CommandPaletteSelect(String),
|
||||
|
||||
// --- Issue list ---
|
||||
IssueListLoaded {
|
||||
generation: u64,
|
||||
page: crate::state::issue_list::IssueListPage,
|
||||
},
|
||||
IssueListFilterChanged(String),
|
||||
IssueListSortChanged,
|
||||
IssueSelected(EntityKey),
|
||||
|
||||
// --- MR list ---
|
||||
MrListLoaded {
|
||||
generation: u64,
|
||||
page: crate::state::mr_list::MrListPage,
|
||||
},
|
||||
MrListFilterChanged(String),
|
||||
MrSelected(EntityKey),
|
||||
|
||||
// --- Issue detail ---
|
||||
IssueDetailLoaded {
|
||||
generation: u64,
|
||||
key: EntityKey,
|
||||
data: Box<crate::state::issue_detail::IssueDetailData>,
|
||||
},
|
||||
|
||||
// --- MR detail ---
|
||||
MrDetailLoaded {
|
||||
generation: u64,
|
||||
key: EntityKey,
|
||||
data: Box<crate::state::mr_detail::MrDetailData>,
|
||||
},
|
||||
|
||||
// --- Discussions (shared by issue + MR detail) ---
|
||||
DiscussionsLoaded {
|
||||
generation: u64,
|
||||
key: EntityKey,
|
||||
discussions: Vec<crate::view::common::discussion_tree::DiscussionNode>,
|
||||
},
|
||||
|
||||
// --- Search ---
|
||||
SearchQueryChanged(String),
|
||||
SearchRequestStarted {
|
||||
generation: u64,
|
||||
query: String,
|
||||
},
|
||||
SearchExecuted {
|
||||
generation: u64,
|
||||
results: Vec<SearchResult>,
|
||||
},
|
||||
SearchResultSelected(EntityKey),
|
||||
SearchModeChanged,
|
||||
SearchCapabilitiesLoaded,
|
||||
|
||||
// --- Timeline ---
|
||||
TimelineLoaded {
|
||||
generation: u64,
|
||||
events: Vec<TimelineEvent>,
|
||||
},
|
||||
TimelineEntitySelected(EntityKey),
|
||||
|
||||
// --- Who (people) ---
|
||||
WhoResultLoaded {
|
||||
generation: u64,
|
||||
result: Box<WhoResult>,
|
||||
},
|
||||
WhoModeChanged,
|
||||
|
||||
// --- Sync ---
|
||||
SyncStarted,
|
||||
SyncProgress {
|
||||
stage: String,
|
||||
current: u64,
|
||||
total: u64,
|
||||
},
|
||||
SyncProgressBatch {
|
||||
stage: String,
|
||||
batch_size: u64,
|
||||
},
|
||||
SyncLogLine(String),
|
||||
SyncBackpressureDrop,
|
||||
SyncCompleted {
|
||||
elapsed_ms: u64,
|
||||
},
|
||||
SyncCancelled,
|
||||
SyncFailed(String),
|
||||
SyncStreamStats {
|
||||
bytes: u64,
|
||||
items: u64,
|
||||
},
|
||||
|
||||
// --- Search debounce ---
|
||||
SearchDebounceArmed {
|
||||
generation: u64,
|
||||
},
|
||||
SearchDebounceFired {
|
||||
generation: u64,
|
||||
},
|
||||
|
||||
// --- Dashboard ---
|
||||
DashboardLoaded {
|
||||
generation: u64,
|
||||
data: Box<crate::state::dashboard::DashboardData>,
|
||||
},
|
||||
|
||||
// --- Global actions ---
|
||||
Error(AppError),
|
||||
ShowHelp,
|
||||
ShowCliEquivalent,
|
||||
OpenInBrowser,
|
||||
BlurTextInput,
|
||||
ScrollToTopCurrentScreen,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Convert terminal events into messages.
|
||||
///
|
||||
/// FrankenTUI requires `From<Event>` on the message type so the runtime
|
||||
/// can inject terminal events into the model's update loop.
|
||||
impl From<Event> for Msg {
|
||||
fn from(event: Event) -> Self {
|
||||
match event {
|
||||
Event::Resize { width, height } => Self::Resize { width, height },
|
||||
Event::Tick => Self::Tick,
|
||||
other => Self::RawEvent(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder data types (will be fleshed out in Phase 1+)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Placeholder for issue detail payload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueDetail {
|
||||
pub key: EntityKey,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Placeholder for MR detail payload.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrDetail {
|
||||
pub key: EntityKey,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Placeholder for a discussion thread.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Discussion {
|
||||
pub id: String,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Placeholder for a search result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub key: EntityKey,
|
||||
pub title: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
/// Placeholder for a timeline event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimelineEvent {
|
||||
pub timestamp: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Placeholder for who/people intelligence result.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WhoResult {
|
||||
pub experts: Vec<String>,
|
||||
}
|
||||
|
||||
// DashboardData moved to crate::state::dashboard (enriched with
|
||||
// EntityCounts, ProjectSyncInfo, RecentActivityItem, LastSyncInfo).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_entity_key_equality() {
|
||||
assert_eq!(EntityKey::issue(1, 42), EntityKey::issue(1, 42));
|
||||
assert_ne!(EntityKey::issue(1, 42), EntityKey::mr(1, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_key_different_projects() {
|
||||
assert_ne!(EntityKey::issue(1, 42), EntityKey::issue(2, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_key_display() {
|
||||
assert_eq!(EntityKey::issue(5, 123).to_string(), "p5:#123");
|
||||
assert_eq!(EntityKey::mr(5, 456).to_string(), "p5:!456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_key_hash_is_usable_in_collections() {
|
||||
use std::collections::HashSet;
|
||||
let mut set = HashSet::new();
|
||||
set.insert(EntityKey::issue(1, 1));
|
||||
set.insert(EntityKey::issue(1, 1)); // duplicate
|
||||
set.insert(EntityKey::mr(1, 1));
|
||||
assert_eq!(set.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_labels() {
|
||||
assert_eq!(Screen::Dashboard.label(), "Dashboard");
|
||||
assert_eq!(Screen::IssueList.label(), "Issues");
|
||||
assert_eq!(Screen::MrList.label(), "Merge Requests");
|
||||
assert_eq!(Screen::Search.label(), "Search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_is_detail_or_entity() {
|
||||
assert!(Screen::IssueDetail(EntityKey::issue(1, 1)).is_detail_or_entity());
|
||||
assert!(Screen::MrDetail(EntityKey::mr(1, 1)).is_detail_or_entity());
|
||||
assert!(!Screen::Dashboard.is_detail_or_entity());
|
||||
assert!(!Screen::IssueList.is_detail_or_entity());
|
||||
assert!(!Screen::Search.is_detail_or_entity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_display() {
|
||||
let err = AppError::DbBusy;
|
||||
assert!(err.to_string().contains("busy"));
|
||||
|
||||
let err = AppError::NetworkRateLimited {
|
||||
retry_after_secs: Some(30),
|
||||
};
|
||||
assert!(err.to_string().contains("30s"));
|
||||
|
||||
let err = AppError::NetworkRateLimited {
|
||||
retry_after_secs: None,
|
||||
};
|
||||
assert!(err.to_string().contains("shortly"));
|
||||
|
||||
let err = AppError::AuthFailed;
|
||||
assert!(err.to_string().contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_mode_default_is_normal() {
|
||||
assert!(matches!(InputMode::default(), InputMode::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_msg_from_event_resize() {
|
||||
let event = Event::Resize {
|
||||
width: 80,
|
||||
height: 24,
|
||||
};
|
||||
let msg = Msg::from(event);
|
||||
assert!(matches!(
|
||||
msg,
|
||||
Msg::Resize {
|
||||
width: 80,
|
||||
height: 24
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_msg_from_event_tick() {
|
||||
let msg = Msg::from(Event::Tick);
|
||||
assert!(matches!(msg, Msg::Tick));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_msg_from_event_focus_wraps_raw() {
|
||||
let msg = Msg::from(Event::Focus(true));
|
||||
assert!(matches!(msg, Msg::RawEvent(Event::Focus(true))));
|
||||
}
|
||||
}
|
||||
350
crates/lore-tui/src/navigation.rs
Normal file
350
crates/lore-tui/src/navigation.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Browser-like navigation stack with vim-style jump list.
|
||||
//!
|
||||
//! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I),
|
||||
//! and breadcrumb generation. State is preserved when navigating away —
|
||||
//! screens are never cleared on pop.
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavigationStack
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Browser-like navigation with back/forward stacks and a vim jump list.
|
||||
///
|
||||
/// The jump list only records "significant" hops — detail views and
|
||||
/// cross-references — skipping list/dashboard screens that users
|
||||
/// visit briefly during drilling.
|
||||
pub struct NavigationStack {
|
||||
back_stack: Vec<Screen>,
|
||||
current: Screen,
|
||||
forward_stack: Vec<Screen>,
|
||||
jump_list: Vec<Screen>,
|
||||
jump_index: usize,
|
||||
}
|
||||
|
||||
impl NavigationStack {
|
||||
/// Create a new stack starting at the Dashboard.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
back_stack: Vec::new(),
|
||||
current: Screen::Dashboard,
|
||||
forward_stack: Vec::new(),
|
||||
jump_list: Vec::new(),
|
||||
jump_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently displayed screen.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> &Screen {
|
||||
&self.current
|
||||
}
|
||||
|
||||
/// Whether the current screen matches the given screen.
|
||||
#[must_use]
|
||||
pub fn is_at(&self, screen: &Screen) -> bool {
|
||||
&self.current == screen
|
||||
}
|
||||
|
||||
/// Navigate to a new screen.
|
||||
///
|
||||
/// Pushes current to back_stack, clears forward_stack (browser behavior),
|
||||
/// and records detail hops in the jump list.
|
||||
pub fn push(&mut self, screen: Screen) {
|
||||
let old = std::mem::replace(&mut self.current, screen);
|
||||
self.back_stack.push(old);
|
||||
self.forward_stack.clear();
|
||||
|
||||
// Record significant hops in jump list (vim behavior):
|
||||
// Keep entries up to and including the current position, discard
|
||||
// any forward entries beyond it, then append the new destination.
|
||||
if self.current.is_detail_or_entity() {
|
||||
self.jump_list.truncate(self.jump_index.saturating_add(1));
|
||||
self.jump_list.push(self.current.clone());
|
||||
self.jump_index = self.jump_list.len();
|
||||
}
|
||||
}
|
||||
|
||||
/// Go back to the previous screen.
|
||||
///
|
||||
/// Returns `None` at root (can't pop past the initial screen).
|
||||
pub fn pop(&mut self) -> Option<&Screen> {
|
||||
let prev = self.back_stack.pop()?;
|
||||
let old = std::mem::replace(&mut self.current, prev);
|
||||
self.forward_stack.push(old);
|
||||
Some(&self.current)
|
||||
}
|
||||
|
||||
/// Go forward (redo a pop).
|
||||
///
|
||||
/// Returns `None` if there's nothing to go forward to.
|
||||
pub fn go_forward(&mut self) -> Option<&Screen> {
|
||||
let next = self.forward_stack.pop()?;
|
||||
let old = std::mem::replace(&mut self.current, next);
|
||||
self.back_stack.push(old);
|
||||
Some(&self.current)
|
||||
}
|
||||
|
||||
/// Jump backward through the jump list (vim Ctrl+O).
|
||||
///
|
||||
/// Only visits detail/entity screens. Skips entries matching the
|
||||
/// current screen so the first press always produces a visible change.
|
||||
pub fn jump_back(&mut self) -> Option<&Screen> {
|
||||
while self.jump_index > 0 {
|
||||
self.jump_index -= 1;
|
||||
if let Some(target) = self.jump_list.get(self.jump_index).cloned()
|
||||
&& target != self.current
|
||||
{
|
||||
self.current = target;
|
||||
return Some(&self.current);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Jump forward through the jump list (vim Ctrl+I).
|
||||
///
|
||||
/// Skips entries matching the current screen.
|
||||
pub fn jump_forward(&mut self) -> Option<&Screen> {
|
||||
while self.jump_index < self.jump_list.len() {
|
||||
if let Some(target) = self.jump_list.get(self.jump_index).cloned() {
|
||||
self.jump_index += 1;
|
||||
if target != self.current {
|
||||
self.current = target;
|
||||
return Some(&self.current);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reset to a single screen, clearing all history.
|
||||
pub fn reset_to(&mut self, screen: Screen) {
|
||||
self.current = screen;
|
||||
self.back_stack.clear();
|
||||
self.forward_stack.clear();
|
||||
self.jump_list.clear();
|
||||
self.jump_index = 0;
|
||||
}
|
||||
|
||||
/// Breadcrumb labels for the current navigation path.
|
||||
///
|
||||
/// Returns the back stack labels plus the current screen label.
|
||||
#[must_use]
|
||||
pub fn breadcrumbs(&self) -> Vec<&str> {
|
||||
self.back_stack
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.current))
|
||||
.map(Screen::label)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Navigation depth (1 = at root, 2 = one push deep, etc.).
|
||||
#[must_use]
|
||||
pub fn depth(&self) -> usize {
|
||||
self.back_stack.len() + 1
|
||||
}
|
||||
|
||||
/// Whether there's anything to go back to.
|
||||
#[must_use]
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
!self.back_stack.is_empty()
|
||||
}
|
||||
|
||||
/// Whether there's anything to go forward to.
|
||||
#[must_use]
|
||||
pub fn can_go_forward(&self) -> bool {
|
||||
!self.forward_stack.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NavigationStack {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::EntityKey;
|
||||
|
||||
#[test]
|
||||
fn test_new_starts_at_dashboard() {
|
||||
let nav = NavigationStack::new();
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
assert_eq!(nav.depth(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_pop_preserves_order() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
|
||||
|
||||
assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42))));
|
||||
assert_eq!(nav.depth(), 3);
|
||||
|
||||
nav.pop();
|
||||
assert!(nav.is_at(&Screen::IssueList));
|
||||
|
||||
nav.pop();
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pop_at_root_returns_none() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert!(nav.pop().is_none());
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_stack_cleared_on_new_push() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::Search);
|
||||
nav.pop(); // back to IssueList, Search in forward
|
||||
assert!(nav.can_go_forward());
|
||||
|
||||
nav.push(Screen::Timeline); // new push clears forward
|
||||
assert!(!nav.can_go_forward());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_forward_restores() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::Search);
|
||||
nav.pop(); // back to IssueList
|
||||
|
||||
let screen = nav.go_forward();
|
||||
assert!(screen.is_some());
|
||||
assert!(nav.is_at(&Screen::Search));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_forward_returns_none_when_empty() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert!(nav.go_forward().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jump_list_skips_list_screens() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList); // not a detail — skip
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record
|
||||
nav.push(Screen::MrList); // not a detail — skip
|
||||
nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record
|
||||
|
||||
assert_eq!(nav.jump_list.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jump_back_and_forward() {
|
||||
let mut nav = NavigationStack::new();
|
||||
let issue = Screen::IssueDetail(EntityKey::issue(1, 1));
|
||||
let mr = Screen::MrDetail(EntityKey::mr(1, 2));
|
||||
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(issue.clone());
|
||||
nav.push(Screen::MrList);
|
||||
nav.push(mr.clone());
|
||||
|
||||
// Current is MrDetail. jump_list = [IssueDetail, MrDetail], index = 2.
|
||||
// First jump_back skips MrDetail (== current) and lands on IssueDetail.
|
||||
let prev = nav.jump_back();
|
||||
assert_eq!(prev, Some(&issue));
|
||||
assert!(nav.is_at(&issue));
|
||||
|
||||
// Already at beginning of jump list.
|
||||
assert!(nav.jump_back().is_none());
|
||||
|
||||
// jump_forward skips IssueDetail (== current) and lands on MrDetail.
|
||||
let next = nav.jump_forward();
|
||||
assert_eq!(next, Some(&mr));
|
||||
assert!(nav.is_at(&mr));
|
||||
|
||||
// At end of jump list.
|
||||
assert!(nav.jump_forward().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jump_list_truncates_on_new_push() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
|
||||
|
||||
// jump back twice — lands on issue(1,1), jump_index = 0
|
||||
nav.jump_back();
|
||||
nav.jump_back();
|
||||
|
||||
// new detail push truncates forward entries
|
||||
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
|
||||
|
||||
// should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3)
|
||||
assert_eq!(nav.jump_list.len(), 2);
|
||||
assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_clears_all_history() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::Search);
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
|
||||
|
||||
nav.reset_to(Screen::Dashboard);
|
||||
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
assert_eq!(nav.depth(), 1);
|
||||
assert!(!nav.can_go_back());
|
||||
assert!(!nav.can_go_forward());
|
||||
assert!(nav.jump_list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumbs_reflect_stack() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]);
|
||||
|
||||
nav.push(Screen::IssueList);
|
||||
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]);
|
||||
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
|
||||
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_new() {
|
||||
let nav = NavigationStack::default();
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
assert_eq!(nav.depth(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_go_back_and_forward() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert!(!nav.can_go_back());
|
||||
assert!(!nav.can_go_forward());
|
||||
|
||||
nav.push(Screen::IssueList);
|
||||
assert!(nav.can_go_back());
|
||||
assert!(!nav.can_go_forward());
|
||||
|
||||
nav.pop();
|
||||
assert!(!nav.can_go_back());
|
||||
assert!(nav.can_go_forward());
|
||||
}
|
||||
}
|
||||
587
crates/lore-tui/src/safety.rs
Normal file
587
crates/lore-tui/src/safety.rs
Normal file
@@ -0,0 +1,587 @@
|
||||
//! Terminal safety: sanitize untrusted text, URL policy, credential redaction.
|
||||
//!
|
||||
//! GitLab content can contain ANSI escapes, bidi overrides, OSC hyperlinks,
|
||||
//! and C1 control codes that could corrupt terminal rendering. This module
|
||||
//! strips dangerous sequences while preserving a safe SGR subset for readability.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UrlPolicy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Controls how OSC 8 hyperlinks in input are handled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum UrlPolicy {
|
||||
/// Remove OSC 8 hyperlinks entirely, keeping only the link text.
|
||||
#[default]
|
||||
Strip,
|
||||
/// Convert hyperlinks to numbered footnotes: `text [1]` with URL list appended.
|
||||
Footnote,
|
||||
/// Pass hyperlinks through unchanged (only for trusted content).
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RedactPattern
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Common patterns for PII/secret redaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RedactPattern {
|
||||
patterns: Vec<regex::Regex>,
|
||||
}
|
||||
|
||||
impl RedactPattern {
|
||||
/// Create a default set of redaction patterns (tokens, emails, etc.).
|
||||
#[must_use]
|
||||
pub fn defaults() -> Self {
|
||||
let patterns = vec![
|
||||
// GitLab personal access tokens
|
||||
regex::Regex::new(r"glpat-[A-Za-z0-9_\-]{20,}").expect("valid regex"),
|
||||
// Generic bearer/API tokens (long hex or base64-ish strings after common prefixes)
|
||||
regex::Regex::new(r"(?i)(token|bearer|api[_-]?key)[\s:=]+\S{8,}").expect("valid regex"),
|
||||
// Email addresses
|
||||
regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
|
||||
.expect("valid regex"),
|
||||
];
|
||||
Self { patterns }
|
||||
}
|
||||
|
||||
/// Apply all redaction patterns to the input string.
|
||||
#[must_use]
|
||||
pub fn redact(&self, input: &str) -> String {
|
||||
let mut result = input.to_string();
|
||||
for pattern in &self.patterns {
|
||||
result = pattern.replace_all(&result, "[REDACTED]").into_owned();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitize_for_terminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sanitize untrusted text for safe terminal display.
|
||||
///
|
||||
/// - Strips C1 control codes (0x80-0x9F)
|
||||
/// - Strips OSC sequences (ESC ] ... ST)
|
||||
/// - Strips cursor movement CSI sequences (CSI n A/B/C/D/E/F/G/H/J/K)
|
||||
/// - Strips bidi overrides (U+202A-U+202E, U+2066-U+2069)
|
||||
/// - Preserves safe SGR subset (bold, italic, underline, reset, standard colors)
|
||||
///
|
||||
/// `url_policy` controls handling of OSC 8 hyperlinks.
|
||||
#[must_use]
|
||||
pub fn sanitize_for_terminal(input: &str, url_policy: UrlPolicy) -> String {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut footnotes: Vec<String> = Vec::new();
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0;
|
||||
|
||||
while i < len {
|
||||
let ch = chars[i];
|
||||
|
||||
// --- Bidi overrides ---
|
||||
if is_bidi_override(ch) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- C1 control codes (U+0080-U+009F) ---
|
||||
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- C0 control codes except tab, newline, carriage return ---
|
||||
if ch.is_ascii_control() && ch != '\t' && ch != '\n' && ch != '\r' && ch != '\x1B' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- ESC sequences ---
|
||||
if ch == '\x1B' {
|
||||
if i + 1 < len {
|
||||
match chars[i + 1] {
|
||||
// CSI sequence: ESC [
|
||||
'[' => {
|
||||
let (consumed, safe_seq) = parse_csi(&chars, i);
|
||||
if let Some(seq) = safe_seq {
|
||||
output.push_str(&seq);
|
||||
}
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
// OSC sequence: ESC ]
|
||||
']' => {
|
||||
let (consumed, link_text, link_url) = parse_osc(&chars, i);
|
||||
match url_policy {
|
||||
UrlPolicy::Strip => {
|
||||
if let Some(text) = link_text {
|
||||
output.push_str(&text);
|
||||
}
|
||||
}
|
||||
UrlPolicy::Footnote => {
|
||||
if let (Some(text), Some(url)) = (link_text, link_url) {
|
||||
footnotes.push(url);
|
||||
let _ = write!(output, "{text} [{n}]", n = footnotes.len());
|
||||
}
|
||||
}
|
||||
UrlPolicy::Passthrough => {
|
||||
// Reproduce the raw OSC sequence
|
||||
for &ch_raw in &chars[i..len.min(i + consumed)] {
|
||||
output.push(ch_raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
// Unknown ESC sequence — skip ESC + next char
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Trailing ESC at end of input
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Normal character ---
|
||||
output.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Append footnotes if any
|
||||
if !footnotes.is_empty() {
|
||||
output.push('\n');
|
||||
for (idx, url) in footnotes.iter().enumerate() {
|
||||
let _ = write!(output, "\n[{}] {url}", idx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bidi check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_bidi_override(ch: char) -> bool {
|
||||
matches!(
|
||||
ch,
|
||||
'\u{202A}' // LRE
|
||||
| '\u{202B}' // RLE
|
||||
| '\u{202C}' // PDF
|
||||
| '\u{202D}' // LRO
|
||||
| '\u{202E}' // RLO
|
||||
| '\u{2066}' // LRI
|
||||
| '\u{2067}' // RLI
|
||||
| '\u{2068}' // FSI
|
||||
| '\u{2069}' // PDI
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSI parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a CSI sequence starting at `chars[start]` (which should be ESC).
|
||||
///
|
||||
/// Returns `(chars_consumed, Option<safe_sequence_string>)`.
|
||||
/// If the CSI is a safe SGR, returns the full sequence string to preserve.
|
||||
/// Otherwise returns None (strip it).
|
||||
fn parse_csi(chars: &[char], start: usize) -> (usize, Option<String>) {
|
||||
// Minimum: ESC [ <final_byte>
|
||||
debug_assert!(chars[start] == '\x1B');
|
||||
debug_assert!(start + 1 < chars.len() && chars[start + 1] == '[');
|
||||
|
||||
let mut i = start + 2; // skip ESC [
|
||||
let len = chars.len();
|
||||
|
||||
// Collect parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F)
|
||||
let param_start = i;
|
||||
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x3F {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Collect intermediate bytes
|
||||
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x2F {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Final byte (0x40-0x7E)
|
||||
if i >= len || (chars[i] as u32) < 0x40 || (chars[i] as u32) > 0x7E {
|
||||
// Malformed — consume what we've seen and strip
|
||||
return (i.saturating_sub(start).max(2), None);
|
||||
}
|
||||
|
||||
let final_byte = chars[i];
|
||||
let consumed = i + 1 - start;
|
||||
|
||||
// Only preserve SGR sequences (final byte 'm')
|
||||
if final_byte == 'm' {
|
||||
let param_str: String = chars[param_start..i].iter().collect();
|
||||
if is_safe_sgr(¶m_str) {
|
||||
let full_seq: String = chars[start..start + consumed].iter().collect();
|
||||
return (consumed, Some(full_seq));
|
||||
}
|
||||
}
|
||||
|
||||
// Anything else (cursor movement A-H, erase J/K, etc.) is stripped
|
||||
(consumed, None)
|
||||
}
|
||||
|
||||
/// Check if all SGR parameters in a sequence are in the safe subset.
|
||||
///
|
||||
/// Safe: 0 (reset), 1 (bold), 3 (italic), 4 (underline), 22 (normal intensity),
|
||||
/// 23 (not italic), 24 (not underline), 39 (default fg), 49 (default bg),
|
||||
/// 30-37 (standard fg), 40-47 (standard bg), 90-97 (bright fg), 100-107 (bright bg).
|
||||
fn is_safe_sgr(params: &str) -> bool {
|
||||
if params.is_empty() {
|
||||
return true; // ESC[m is reset
|
||||
}
|
||||
|
||||
for param in params.split(';') {
|
||||
let param = param.trim();
|
||||
if param.is_empty() {
|
||||
continue; // treat empty as 0
|
||||
}
|
||||
let Ok(n) = param.parse::<u32>() else {
|
||||
return false;
|
||||
};
|
||||
if !is_safe_sgr_code(n) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_safe_sgr_code(n: u32) -> bool {
|
||||
matches!(
|
||||
n,
|
||||
0 // reset
|
||||
| 1 // bold
|
||||
| 3 // italic
|
||||
| 4 // underline
|
||||
| 22 // normal intensity (turn off bold)
|
||||
| 23 // not italic
|
||||
| 24 // not underline
|
||||
| 39 // default foreground
|
||||
| 49 // default background
|
||||
| 30..=37 // standard foreground colors
|
||||
| 40..=47 // standard background colors
|
||||
| 90..=97 // bright foreground colors
|
||||
| 100..=107 // bright background colors
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OSC parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an OSC sequence starting at `chars[start]` (ESC ]).
|
||||
///
|
||||
/// Returns `(chars_consumed, link_text, link_url)`.
|
||||
/// For OSC 8 hyperlinks: `ESC ] 8 ; params ; url ST text ESC ] 8 ; ; ST`
|
||||
/// For other OSC: consumed without extracting link data.
|
||||
fn parse_osc(chars: &[char], start: usize) -> (usize, Option<String>, Option<String>) {
|
||||
debug_assert!(chars[start] == '\x1B');
|
||||
debug_assert!(start + 1 < chars.len() && chars[start + 1] == ']');
|
||||
|
||||
let len = chars.len();
|
||||
let i = start + 2; // skip ESC ]
|
||||
|
||||
// Find ST (String Terminator): ESC \ or BEL (0x07)
|
||||
let osc_end = find_st(chars, i);
|
||||
|
||||
// Check if this is OSC 8 (hyperlink)
|
||||
if i < len && chars[i] == '8' && i + 1 < len && chars[i + 1] == ';' {
|
||||
// OSC 8 hyperlink: ESC ] 8 ; params ; url ST ... ESC ] 8 ; ; ST
|
||||
let osc_content: String = chars[i..osc_end.0].iter().collect();
|
||||
let first_consumed = osc_end.1;
|
||||
|
||||
// Extract URL from "8;params;url"
|
||||
let url = extract_osc8_url(&osc_content);
|
||||
|
||||
// Now find the link text (between first ST and second OSC 8)
|
||||
let after_first_st = start + 2 + first_consumed;
|
||||
let mut text = String::new();
|
||||
let mut j = after_first_st;
|
||||
|
||||
// Collect text until we hit the closing OSC 8 or end of input
|
||||
while j < len {
|
||||
if j + 1 < len && chars[j] == '\x1B' && chars[j + 1] == ']' {
|
||||
// Found another OSC — this should be the closing OSC 8
|
||||
let close_end = find_st(chars, j + 2);
|
||||
return (
|
||||
j + close_end.1 - start + 2,
|
||||
Some(text),
|
||||
url.map(String::from),
|
||||
);
|
||||
}
|
||||
text.push(chars[j]);
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Reached end without closing OSC 8
|
||||
return (j - start, Some(text), url.map(String::from));
|
||||
}
|
||||
|
||||
// Non-OSC-8: just consume and strip
|
||||
(osc_end.1 + (start + 2 - start), None, None)
|
||||
}
|
||||
|
||||
/// Find the String Terminator (ST) for an OSC sequence.
|
||||
/// ST is either ESC \ (two chars) or BEL (0x07).
|
||||
/// Returns (content_end_index, total_consumed_from_content_start).
|
||||
fn find_st(chars: &[char], from: usize) -> (usize, usize) {
|
||||
let len = chars.len();
|
||||
let mut i = from;
|
||||
while i < len {
|
||||
if chars[i] == '\x07' {
|
||||
return (i, i - from + 1);
|
||||
}
|
||||
if i + 1 < len && chars[i] == '\x1B' && chars[i + 1] == '\\' {
|
||||
return (i, i - from + 2);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// Unterminated — consume everything
|
||||
(len, len - from)
|
||||
}
|
||||
|
||||
/// Extract URL from OSC 8 content "8;params;url".
|
||||
fn extract_osc8_url(content: &str) -> Option<&str> {
|
||||
// Format: "8;params;url"
|
||||
let rest = content.strip_prefix("8;")?;
|
||||
// Skip params (up to next ;)
|
||||
let url_start = rest.find(';')? + 1;
|
||||
let url = &rest[url_start..];
|
||||
if url.is_empty() { None } else { Some(url) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- CSI / cursor movement ---
|
||||
|
||||
#[test]
|
||||
fn test_strips_cursor_movement() {
|
||||
// CSI 5A = cursor up 5
|
||||
let input = "before\x1B[5Aafter";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "beforeafter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strips_cursor_movement_all_directions() {
|
||||
for dir in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] {
|
||||
let input = format!("x\x1B[3{dir}y");
|
||||
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "xy", "failed for direction {dir}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strips_erase_sequences() {
|
||||
// CSI 2J = erase display
|
||||
let input = "before\x1B[2Jafter";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "beforeafter");
|
||||
}
|
||||
|
||||
// --- SGR preservation ---
|
||||
|
||||
#[test]
|
||||
fn test_preserves_bold_italic_underline_reset() {
|
||||
let input = "\x1B[1mbold\x1B[0m \x1B[3mitalic\x1B[0m \x1B[4munderline\x1B[0m";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserves_standard_colors() {
|
||||
// Red foreground, green background
|
||||
let input = "\x1B[31mred\x1B[42m on green\x1B[0m";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserves_bright_colors() {
|
||||
let input = "\x1B[91mbright red\x1B[0m";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserves_combined_safe_sgr() {
|
||||
// Bold + red foreground in one sequence
|
||||
let input = "\x1B[1;31mbold red\x1B[0m";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strips_unsafe_sgr() {
|
||||
// SGR 8 = hidden text (not in safe list)
|
||||
let input = "\x1B[8mhidden\x1B[0m";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
// SGR 8 stripped, SGR 0 preserved
|
||||
assert_eq!(result, "hidden\x1B[0m");
|
||||
}
|
||||
|
||||
// --- C1 control codes ---
|
||||
|
||||
#[test]
|
||||
fn test_strips_c1_control_codes() {
|
||||
// U+008D = Reverse Index, U+009B = CSI (8-bit)
|
||||
let input = format!("before{}middle{}after", '\u{008D}', '\u{009B}');
|
||||
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "beforemiddleafter");
|
||||
}
|
||||
|
||||
// --- Bidi overrides ---
|
||||
|
||||
#[test]
|
||||
fn test_strips_bidi_overrides() {
|
||||
let input = format!(
|
||||
"normal{}reversed{}end",
|
||||
'\u{202E}', // RLO
|
||||
'\u{202C}' // PDF
|
||||
);
|
||||
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "normalreversedend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strips_all_bidi_chars() {
|
||||
let bidi_chars = [
|
||||
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}',
|
||||
'\u{2068}', '\u{2069}',
|
||||
];
|
||||
for ch in bidi_chars {
|
||||
let input = format!("a{ch}b");
|
||||
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "ab", "failed for U+{:04X}", ch as u32);
|
||||
}
|
||||
}
|
||||
|
||||
// --- OSC sequences ---
|
||||
|
||||
#[test]
|
||||
fn test_strips_osc_sequences() {
|
||||
// OSC 0 (set title): ESC ] 0 ; title BEL
|
||||
let input = "before\x1B]0;My Title\x07after";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "beforeafter");
|
||||
}
|
||||
|
||||
// --- OSC 8 hyperlinks ---
|
||||
|
||||
#[test]
|
||||
fn test_url_policy_strip() {
|
||||
// OSC 8 hyperlink: ESC]8;;url ST text ESC]8;; ST
|
||||
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "click here done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_policy_footnote() {
|
||||
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Footnote);
|
||||
assert!(result.contains("here [1]"));
|
||||
assert!(result.contains("[1] https://example.com"));
|
||||
}
|
||||
|
||||
// --- Redaction ---
|
||||
|
||||
#[test]
|
||||
fn test_redact_gitlab_token() {
|
||||
let redactor = RedactPattern::defaults();
|
||||
let input = "My token is glpat-AbCdEfGhIjKlMnOpQrStUvWx";
|
||||
let result = redactor.redact(input);
|
||||
assert_eq!(result, "My token is [REDACTED]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_email() {
|
||||
let redactor = RedactPattern::defaults();
|
||||
let input = "Contact user@example.com for details";
|
||||
let result = redactor.redact(input);
|
||||
assert_eq!(result, "Contact [REDACTED] for details");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redact_bearer_token() {
|
||||
let redactor = RedactPattern::defaults();
|
||||
let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI";
|
||||
let result = redactor.redact(input);
|
||||
assert!(result.contains("[REDACTED]"));
|
||||
assert!(!result.contains("eyJ"));
|
||||
}
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
assert_eq!(sanitize_for_terminal("", UrlPolicy::Strip), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_safe_content_passthrough() {
|
||||
let input = "Hello, world! This is normal text.\nWith newlines\tand tabs.";
|
||||
assert_eq!(sanitize_for_terminal(input, UrlPolicy::Strip), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trailing_esc() {
|
||||
let input = "text\x1B";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malformed_csi_does_not_eat_text() {
|
||||
// ESC [ without a valid final byte before next printable
|
||||
let input = "a\x1B[b";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
// The malformed CSI is consumed but shouldn't eat "b" as text
|
||||
// ESC[ is start, 'b' is final byte (0x62 is in 0x40-0x7E range)
|
||||
// So this is CSI with final byte 'b' (cursor back) — gets stripped
|
||||
assert_eq!(result, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utf8_adjacent_to_escapes() {
|
||||
let input = "\x1B[1m日本語\x1B[0m text";
|
||||
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
|
||||
assert_eq!(result, "\x1B[1m日本語\x1B[0m text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_no_panic() {
|
||||
// 1000 random-ish byte sequences — must not panic
|
||||
for seed in 0u16..1000 {
|
||||
let mut bytes = Vec::new();
|
||||
for j in 0..50 {
|
||||
bytes.push(((seed.wrapping_mul(31).wrapping_add(j)) & 0xFF) as u8);
|
||||
}
|
||||
// Best-effort UTF-8
|
||||
let input = String::from_utf8_lossy(&bytes);
|
||||
let _ = sanitize_for_terminal(&input, UrlPolicy::Strip);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
crates/lore-tui/src/state/command_palette.rs
Normal file
11
crates/lore-tui/src/state/command_palette.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Command palette state.
|
||||
|
||||
/// State for the command palette overlay.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CommandPaletteState {
|
||||
pub query: String,
|
||||
pub query_focused: bool,
|
||||
pub selected_index: usize,
|
||||
}
|
||||
255
crates/lore-tui/src/state/dashboard.rs
Normal file
255
crates/lore-tui/src/state/dashboard.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Dashboard screen state.
|
||||
//!
|
||||
//! The dashboard is the home screen — entity counts, per-project sync
|
||||
//! status, recent activity, and the last sync summary.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityCounts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregated entity counts from the local database.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct EntityCounts {
|
||||
pub issues_open: u64,
|
||||
pub issues_total: u64,
|
||||
pub mrs_open: u64,
|
||||
pub mrs_total: u64,
|
||||
pub discussions: u64,
|
||||
pub notes_total: u64,
|
||||
/// Percentage of notes that are system-generated (0-100).
|
||||
pub notes_system_pct: u8,
|
||||
pub documents: u64,
|
||||
pub embeddings: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProjectSyncInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Per-project sync freshness.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectSyncInfo {
|
||||
pub path: String,
|
||||
pub minutes_since_sync: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecentActivityItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A recently-updated entity for the activity feed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecentActivityItem {
|
||||
/// "issue" or "mr".
|
||||
pub entity_type: String,
|
||||
pub iid: u64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub minutes_ago: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LastSyncInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Summary of the most recent sync run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LastSyncInfo {
|
||||
pub status: String,
|
||||
/// Milliseconds epoch UTC.
|
||||
pub finished_at: Option<i64>,
|
||||
pub command: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DashboardData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Data returned by the `fetch_dashboard` action.
|
||||
///
|
||||
/// Pure data transfer — no rendering or display logic.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DashboardData {
|
||||
pub counts: EntityCounts,
|
||||
pub projects: Vec<ProjectSyncInfo>,
|
||||
pub recent: Vec<RecentActivityItem>,
|
||||
pub last_sync: Option<LastSyncInfo>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DashboardState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the dashboard summary screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DashboardState {
|
||||
pub counts: EntityCounts,
|
||||
pub projects: Vec<ProjectSyncInfo>,
|
||||
pub recent: Vec<RecentActivityItem>,
|
||||
pub last_sync: Option<LastSyncInfo>,
|
||||
/// Scroll offset for the recent activity list.
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl DashboardState {
|
||||
/// Apply fresh data from a `fetch_dashboard` result.
|
||||
///
|
||||
/// Preserves scroll offset (clamped to new data bounds).
|
||||
pub fn update(&mut self, data: DashboardData) {
|
||||
self.counts = data.counts;
|
||||
self.projects = data.projects;
|
||||
self.last_sync = data.last_sync;
|
||||
self.recent = data.recent;
|
||||
// Clamp scroll offset if the list shrunk.
|
||||
if !self.recent.is_empty() {
|
||||
self.scroll_offset = self.scroll_offset.min(self.recent.len() - 1);
|
||||
} else {
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the recent activity list down by one.
|
||||
pub fn scroll_down(&mut self) {
|
||||
if !self.recent.is_empty() {
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(self.recent.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the recent activity list up by one.
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_default() {
|
||||
let state = DashboardState::default();
|
||||
assert_eq!(state.counts.issues_total, 0);
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
assert!(state.recent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_update_applies_data() {
|
||||
let mut state = DashboardState::default();
|
||||
let data = DashboardData {
|
||||
counts: EntityCounts {
|
||||
issues_open: 3,
|
||||
issues_total: 5,
|
||||
..Default::default()
|
||||
},
|
||||
projects: vec![ProjectSyncInfo {
|
||||
path: "group/project".into(),
|
||||
minutes_since_sync: 42,
|
||||
}],
|
||||
recent: vec![RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: 1,
|
||||
title: "Fix bug".into(),
|
||||
state: "opened".into(),
|
||||
minutes_ago: 10,
|
||||
}],
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
state.update(data);
|
||||
assert_eq!(state.counts.issues_open, 3);
|
||||
assert_eq!(state.counts.issues_total, 5);
|
||||
assert_eq!(state.projects.len(), 1);
|
||||
assert_eq!(state.recent.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_update_clamps_scroll() {
|
||||
let mut state = DashboardState {
|
||||
scroll_offset: 10,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let data = DashboardData {
|
||||
recent: vec![RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: 1,
|
||||
title: "Only item".into(),
|
||||
state: "opened".into(),
|
||||
minutes_ago: 5,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.update(data);
|
||||
assert_eq!(state.scroll_offset, 0); // Clamped to len-1 = 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dashboard_state_update_empty_resets_scroll() {
|
||||
let mut state = DashboardState {
|
||||
scroll_offset: 5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
state.update(DashboardData::default());
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_down_and_up() {
|
||||
let mut state = DashboardState::default();
|
||||
state.recent = (0..5)
|
||||
.map(|i| RecentActivityItem {
|
||||
entity_type: "issue".into(),
|
||||
iid: i,
|
||||
title: format!("Item {i}"),
|
||||
state: "opened".into(),
|
||||
minutes_ago: i,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 1);
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 2);
|
||||
state.scroll_up();
|
||||
assert_eq!(state.scroll_offset, 1);
|
||||
state.scroll_up();
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
state.scroll_up(); // Can't go below 0
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_down_stops_at_end() {
|
||||
let mut state = DashboardState::default();
|
||||
state.recent = vec![RecentActivityItem {
|
||||
entity_type: "mr".into(),
|
||||
iid: 1,
|
||||
title: "Only".into(),
|
||||
state: "merged".into(),
|
||||
minutes_ago: 0,
|
||||
}];
|
||||
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 0); // Can't scroll past single item
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_on_empty_is_noop() {
|
||||
let mut state = DashboardState::default();
|
||||
state.scroll_down();
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
state.scroll_up();
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
}
|
||||
284
crates/lore-tui/src/state/issue_detail.rs
Normal file
284
crates/lore-tui/src/state/issue_detail.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by Issue Detail screen
|
||||
|
||||
//! Issue detail screen state.
|
||||
//!
|
||||
//! Holds metadata, discussions, cross-references, and UI state for
|
||||
//! viewing a single issue. Supports progressive hydration: metadata
|
||||
//! loads first, discussions load async in a second phase.
|
||||
|
||||
use crate::message::EntityKey;
|
||||
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
|
||||
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full metadata for a single issue, fetched from the local DB.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueMetadata {
|
||||
/// Issue IID (project-scoped).
|
||||
pub iid: i64,
|
||||
/// Project path (e.g., "group/project").
|
||||
pub project_path: String,
|
||||
/// Issue title.
|
||||
pub title: String,
|
||||
/// Issue description (markdown).
|
||||
pub description: String,
|
||||
/// Current state: "opened" or "closed".
|
||||
pub state: String,
|
||||
/// Author username.
|
||||
pub author: String,
|
||||
/// Assigned usernames.
|
||||
pub assignees: Vec<String>,
|
||||
/// Label names.
|
||||
pub labels: Vec<String>,
|
||||
/// Milestone title (if set).
|
||||
pub milestone: Option<String>,
|
||||
/// Due date (if set, "YYYY-MM-DD").
|
||||
pub due_date: Option<String>,
|
||||
/// Created timestamp (ms epoch).
|
||||
pub created_at: i64,
|
||||
/// Updated timestamp (ms epoch).
|
||||
pub updated_at: i64,
|
||||
/// GitLab web URL for "open in browser".
|
||||
pub web_url: String,
|
||||
/// Discussion count (for display before discussions load).
|
||||
pub discussion_count: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueDetailData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bundle returned by the metadata fetch action.
|
||||
///
|
||||
/// Metadata + cross-refs load in Phase 1 (fast). Discussions load
|
||||
/// separately in Phase 2.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueDetailData {
|
||||
pub metadata: IssueMetadata,
|
||||
pub cross_refs: Vec<CrossRef>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DetailSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Which section of the detail view has keyboard focus.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DetailSection {
|
||||
/// Description area (scrollable text).
|
||||
#[default]
|
||||
Description,
|
||||
/// Discussion tree.
|
||||
Discussions,
|
||||
/// Cross-references list.
|
||||
CrossRefs,
|
||||
}
|
||||
|
||||
impl DetailSection {
|
||||
/// Cycle to the next section.
|
||||
#[must_use]
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Description => Self::Discussions,
|
||||
Self::Discussions => Self::CrossRefs,
|
||||
Self::CrossRefs => Self::Description,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the previous section.
|
||||
#[must_use]
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Description => Self::CrossRefs,
|
||||
Self::Discussions => Self::Description,
|
||||
Self::CrossRefs => Self::Discussions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueDetailState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the issue detail screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IssueDetailState {
|
||||
/// Entity key for the currently displayed issue.
|
||||
pub current_key: Option<EntityKey>,
|
||||
/// Issue metadata (Phase 1 load).
|
||||
pub metadata: Option<IssueMetadata>,
|
||||
/// Discussion nodes (Phase 2 async load).
|
||||
pub discussions: Vec<DiscussionNode>,
|
||||
/// Whether discussions have finished loading.
|
||||
pub discussions_loaded: bool,
|
||||
/// Cross-references (loaded with metadata in Phase 1).
|
||||
pub cross_refs: Vec<CrossRef>,
|
||||
/// Discussion tree UI state (expand/collapse, selection).
|
||||
pub tree_state: DiscussionTreeState,
|
||||
/// Cross-reference list UI state.
|
||||
pub cross_ref_state: CrossRefState,
|
||||
/// Description scroll offset.
|
||||
pub description_scroll: usize,
|
||||
/// Active section for keyboard focus.
|
||||
pub active_section: DetailSection,
|
||||
}
|
||||
|
||||
impl IssueDetailState {
|
||||
/// Reset state for a new issue.
|
||||
pub fn load_new(&mut self, key: EntityKey) {
|
||||
self.current_key = Some(key);
|
||||
self.metadata = None;
|
||||
self.discussions.clear();
|
||||
self.discussions_loaded = false;
|
||||
self.cross_refs.clear();
|
||||
self.tree_state = DiscussionTreeState::default();
|
||||
self.cross_ref_state = CrossRefState::default();
|
||||
self.description_scroll = 0;
|
||||
self.active_section = DetailSection::Description;
|
||||
}
|
||||
|
||||
/// Apply Phase 1 data (metadata + cross-refs).
|
||||
pub fn apply_metadata(&mut self, data: IssueDetailData) {
|
||||
self.metadata = Some(data.metadata);
|
||||
self.cross_refs = data.cross_refs;
|
||||
}
|
||||
|
||||
/// Apply Phase 2 data (discussions).
|
||||
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
|
||||
self.discussions = discussions;
|
||||
self.discussions_loaded = true;
|
||||
}
|
||||
|
||||
/// Whether we have metadata loaded for the current key.
|
||||
#[must_use]
|
||||
pub fn has_metadata(&self) -> bool {
|
||||
self.metadata.is_some()
|
||||
}
|
||||
|
||||
/// Cycle to the next section.
|
||||
pub fn next_section(&mut self) {
|
||||
self.active_section = self.active_section.next();
|
||||
}
|
||||
|
||||
/// Cycle to the previous section.
|
||||
pub fn prev_section(&mut self) {
|
||||
self.active_section = self.active_section.prev();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::view::common::cross_ref::CrossRefKind;
|
||||
|
||||
#[test]
|
||||
fn test_issue_detail_state_default() {
|
||||
let state = IssueDetailState::default();
|
||||
assert!(state.current_key.is_none());
|
||||
assert!(state.metadata.is_none());
|
||||
assert!(state.discussions.is_empty());
|
||||
assert!(!state.discussions_loaded);
|
||||
assert!(state.cross_refs.is_empty());
|
||||
assert_eq!(state.active_section, DetailSection::Description);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_new_resets_state() {
|
||||
let mut state = IssueDetailState {
|
||||
discussions_loaded: true,
|
||||
description_scroll: 10,
|
||||
active_section: DetailSection::CrossRefs,
|
||||
..IssueDetailState::default()
|
||||
};
|
||||
|
||||
state.load_new(EntityKey::issue(1, 42));
|
||||
assert_eq!(state.current_key, Some(EntityKey::issue(1, 42)));
|
||||
assert!(state.metadata.is_none());
|
||||
assert!(!state.discussions_loaded);
|
||||
assert_eq!(state.description_scroll, 0);
|
||||
assert_eq!(state.active_section, DetailSection::Description);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_metadata() {
|
||||
let mut state = IssueDetailState::default();
|
||||
state.load_new(EntityKey::issue(1, 42));
|
||||
|
||||
let data = IssueDetailData {
|
||||
metadata: IssueMetadata {
|
||||
iid: 42,
|
||||
project_path: "group/proj".into(),
|
||||
title: "Fix auth".into(),
|
||||
description: "Description here".into(),
|
||||
state: "opened".into(),
|
||||
author: "alice".into(),
|
||||
assignees: vec!["bob".into()],
|
||||
labels: vec!["backend".into()],
|
||||
milestone: Some("v1.0".into()),
|
||||
due_date: None,
|
||||
created_at: 1_700_000_000_000,
|
||||
updated_at: 1_700_000_060_000,
|
||||
web_url: "https://gitlab.com/group/proj/-/issues/42".into(),
|
||||
discussion_count: 3,
|
||||
},
|
||||
cross_refs: vec![CrossRef {
|
||||
kind: CrossRefKind::ClosingMr,
|
||||
entity_key: EntityKey::mr(1, 10),
|
||||
label: "Fix auth MR".into(),
|
||||
navigable: true,
|
||||
}],
|
||||
};
|
||||
|
||||
state.apply_metadata(data);
|
||||
assert!(state.has_metadata());
|
||||
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
|
||||
assert_eq!(state.cross_refs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_discussions() {
|
||||
let mut state = IssueDetailState::default();
|
||||
assert!(!state.discussions_loaded);
|
||||
|
||||
let discussions = vec![DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
}];
|
||||
|
||||
state.apply_discussions(discussions);
|
||||
assert!(state.discussions_loaded);
|
||||
assert_eq!(state.discussions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detail_section_cycling() {
|
||||
let section = DetailSection::Description;
|
||||
assert_eq!(section.next(), DetailSection::Discussions);
|
||||
assert_eq!(section.next().next(), DetailSection::CrossRefs);
|
||||
assert_eq!(section.next().next().next(), DetailSection::Description);
|
||||
|
||||
assert_eq!(section.prev(), DetailSection::CrossRefs);
|
||||
assert_eq!(section.prev().prev(), DetailSection::Discussions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_section_next_prev_round_trip() {
|
||||
let mut state = IssueDetailState::default();
|
||||
assert_eq!(state.active_section, DetailSection::Description);
|
||||
|
||||
state.next_section();
|
||||
assert_eq!(state.active_section, DetailSection::Discussions);
|
||||
|
||||
state.prev_section();
|
||||
assert_eq!(state.active_section, DetailSection::Description);
|
||||
}
|
||||
}
|
||||
376
crates/lore-tui/src/state/issue_list.rs
Normal file
376
crates/lore-tui/src/state/issue_list.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/issue_list
|
||||
|
||||
//! Issue list screen state.
|
||||
//!
|
||||
//! Uses keyset pagination with a snapshot fence for stable ordering
|
||||
//! under concurrent sync writes. Filter changes reset the pagination
|
||||
//! cursor and snapshot fence.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor (keyset pagination boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keyset pagination cursor — (updated_at, iid) boundary.
|
||||
///
|
||||
/// The next page query uses `WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid)`
|
||||
/// to avoid OFFSET instability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IssueCursor {
|
||||
pub updated_at: i64,
|
||||
pub iid: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured filter for issue list queries.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct IssueFilter {
|
||||
pub state: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub milestone: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub free_text: Option<String>,
|
||||
pub project_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl IssueFilter {
|
||||
/// Compute a hash for change detection.
|
||||
pub fn hash_value(&self) -> u64 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
self.state.hash(&mut hasher);
|
||||
self.author.hash(&mut hasher);
|
||||
self.assignee.hash(&mut hasher);
|
||||
self.label.hash(&mut hasher);
|
||||
self.milestone.hash(&mut hasher);
|
||||
self.status.hash(&mut hasher);
|
||||
self.free_text.hash(&mut hasher);
|
||||
self.project_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Whether any filter is active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.is_some()
|
||||
|| self.author.is_some()
|
||||
|| self.assignee.is_some()
|
||||
|| self.label.is_some()
|
||||
|| self.milestone.is_some()
|
||||
|| self.status.is_some()
|
||||
|| self.free_text.is_some()
|
||||
|| self.project_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single row in the issue list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueListRow {
|
||||
pub project_path: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author: String,
|
||||
pub labels: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result from a paginated issue list query.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueListPage {
|
||||
pub rows: Vec<IssueListRow>,
|
||||
pub next_cursor: Option<IssueCursor>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fields available for sorting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SortField {
|
||||
#[default]
|
||||
UpdatedAt,
|
||||
Iid,
|
||||
Title,
|
||||
State,
|
||||
Author,
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SortOrder {
|
||||
#[default]
|
||||
Desc,
|
||||
Asc,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IssueListState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the issue list screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IssueListState {
|
||||
/// Current page of issue rows.
|
||||
pub rows: Vec<IssueListRow>,
|
||||
/// Total count of matching issues.
|
||||
pub total_count: u64,
|
||||
/// Selected row index (within current window).
|
||||
pub selected_index: usize,
|
||||
/// Scroll offset for the entity table.
|
||||
pub scroll_offset: usize,
|
||||
/// Cursor for the next page.
|
||||
pub next_cursor: Option<IssueCursor>,
|
||||
/// Whether a prefetch is in flight.
|
||||
pub prefetch_in_flight: bool,
|
||||
/// Current filter.
|
||||
pub filter: IssueFilter,
|
||||
/// Raw filter input text.
|
||||
pub filter_input: String,
|
||||
/// Whether the filter bar has focus.
|
||||
pub filter_focused: bool,
|
||||
/// Sort field.
|
||||
pub sort_field: SortField,
|
||||
/// Sort direction.
|
||||
pub sort_order: SortOrder,
|
||||
/// Snapshot fence: max updated_at from initial load.
|
||||
pub snapshot_fence: Option<i64>,
|
||||
/// Hash of the current filter for change detection.
|
||||
pub filter_hash: u64,
|
||||
/// Whether Quick Peek is visible.
|
||||
pub peek_visible: bool,
|
||||
}
|
||||
|
||||
impl IssueListState {
|
||||
/// Reset pagination state (called when filter changes or on refresh).
|
||||
pub fn reset_pagination(&mut self) {
|
||||
self.rows.clear();
|
||||
self.next_cursor = None;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.snapshot_fence = None;
|
||||
self.total_count = 0;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Apply a new page of results.
|
||||
pub fn apply_page(&mut self, page: IssueListPage) {
|
||||
// Set snapshot fence on first page load.
|
||||
if self.snapshot_fence.is_none() {
|
||||
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
|
||||
}
|
||||
self.rows.extend(page.rows);
|
||||
self.next_cursor = page.next_cursor;
|
||||
self.total_count = page.total_count;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Check if filter changed and reset if needed.
|
||||
pub fn check_filter_change(&mut self) -> bool {
|
||||
let new_hash = self.filter.hash_value();
|
||||
if new_hash != self.filter_hash {
|
||||
self.filter_hash = new_hash;
|
||||
self.reset_pagination();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user has scrolled near the end of current data (80% threshold).
|
||||
pub fn should_prefetch(&self) -> bool {
|
||||
if self.prefetch_in_flight || self.next_cursor.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let threshold = (self.rows.len() * 4) / 5; // 80%
|
||||
self.selected_index >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_page(count: usize, has_next: bool) -> IssueListPage {
|
||||
let rows: Vec<IssueListRow> = (0..count)
|
||||
.map(|i| IssueListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: (count - i) as i64,
|
||||
title: format!("Issue {}", count - i),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
labels: vec![],
|
||||
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if has_next {
|
||||
rows.last().map(|r| IssueCursor {
|
||||
updated_at: r.updated_at,
|
||||
iid: r.iid,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
IssueListPage {
|
||||
rows,
|
||||
next_cursor,
|
||||
total_count: if has_next {
|
||||
(count * 2) as u64
|
||||
} else {
|
||||
count as u64
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_sets_snapshot_fence() {
|
||||
let mut state = IssueListState::default();
|
||||
let page = sample_page(5, false);
|
||||
state.apply_page(page);
|
||||
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
assert!(state.snapshot_fence.is_some());
|
||||
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_appends() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(state.rows.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_pagination_clears_state() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
state.selected_index = 3;
|
||||
|
||||
state.reset_pagination();
|
||||
|
||||
assert!(state.rows.is_empty());
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(state.next_cursor.is_none());
|
||||
assert!(state.snapshot_fence.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_detects_change() {
|
||||
let mut state = IssueListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
|
||||
state.filter.state = Some("opened".into());
|
||||
assert!(state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_no_change() {
|
||||
let mut state = IssueListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
assert!(!state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
|
||||
state.selected_index = 4; // 40% — no prefetch
|
||||
assert!(!state.should_prefetch());
|
||||
|
||||
state.selected_index = 8; // 80% — prefetch
|
||||
assert!(state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_no_next_page() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(10, false));
|
||||
state.selected_index = 9;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_already_in_flight() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
state.selected_index = 9;
|
||||
state.prefetch_in_flight = true;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_filter_is_active() {
|
||||
let empty = IssueFilter::default();
|
||||
assert!(!empty.is_active());
|
||||
|
||||
let active = IssueFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(active.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_filter_hash_deterministic() {
|
||||
let f1 = IssueFilter {
|
||||
state: Some("opened".into()),
|
||||
author: Some("taylor".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = f1.clone();
|
||||
assert_eq!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_filter_hash_differs() {
|
||||
let f1 = IssueFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = IssueFilter {
|
||||
state: Some("closed".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_fence_not_overwritten_on_second_page() {
|
||||
let mut state = IssueListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
let fence = state.snapshot_fence;
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(
|
||||
state.snapshot_fence, fence,
|
||||
"Fence should not change on second page"
|
||||
);
|
||||
}
|
||||
}
|
||||
344
crates/lore-tui/src/state/mod.rs
Normal file
344
crates/lore-tui/src/state/mod.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Top-level state composition for the TUI.
|
||||
//!
|
||||
//! Each screen has its own state struct. State is preserved when
|
||||
//! navigating away — screens are never cleared on pop.
|
||||
//!
|
||||
//! [`LoadState`] enables stale-while-revalidate: screens show the last
|
||||
//! available data during a refresh, with a spinner indicating the load.
|
||||
//!
|
||||
//! [`ScreenIntent`] is the pure return type from state handlers — they
|
||||
//! never spawn async tasks directly. The intent is interpreted by
|
||||
//! [`LoreApp`](crate::app::LoreApp) which dispatches through the
|
||||
//! [`TaskSupervisor`](crate::task_supervisor::TaskSupervisor).
|
||||
|
||||
pub mod command_palette;
|
||||
pub mod dashboard;
|
||||
pub mod issue_detail;
|
||||
pub mod issue_list;
|
||||
pub mod mr_detail;
|
||||
pub mod mr_list;
|
||||
pub mod search;
|
||||
pub mod sync;
|
||||
pub mod timeline;
|
||||
pub mod who;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// Re-export screen states for convenience.
|
||||
pub use command_palette::CommandPaletteState;
|
||||
pub use dashboard::DashboardState;
|
||||
pub use issue_detail::IssueDetailState;
|
||||
pub use issue_list::IssueListState;
|
||||
pub use mr_detail::MrDetailState;
|
||||
pub use mr_list::MrListState;
|
||||
pub use search::SearchState;
|
||||
pub use sync::SyncState;
|
||||
pub use timeline::TimelineState;
|
||||
pub use who::WhoState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoadState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Loading state for a screen's data.
|
||||
///
|
||||
/// Enables stale-while-revalidate: screens render their last data while
|
||||
/// showing a spinner when `Refreshing`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum LoadState {
|
||||
/// No load in progress, data is current (or screen was never loaded).
|
||||
#[default]
|
||||
Idle,
|
||||
/// First load — no data to show yet, display a full-screen spinner.
|
||||
LoadingInitial,
|
||||
/// Background refresh — show existing data with a spinner indicator.
|
||||
Refreshing,
|
||||
/// Load failed — display the error alongside any stale data.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl LoadState {
|
||||
/// Whether data is currently being loaded.
|
||||
#[must_use]
|
||||
pub fn is_loading(&self) -> bool {
|
||||
matches!(self, Self::LoadingInitial | Self::Refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenLoadStateMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tracks per-screen load state.
|
||||
///
|
||||
/// Returns [`LoadState::Idle`] for screens that haven't been tracked.
|
||||
/// Automatically removes entries set to `Idle` to prevent unbounded growth.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ScreenLoadStateMap {
|
||||
map: HashMap<Screen, LoadState>,
|
||||
/// Screens that have had a load state set at least once.
|
||||
visited: HashSet<Screen>,
|
||||
}
|
||||
|
||||
impl ScreenLoadStateMap {
|
||||
/// Get the load state for a screen (defaults to `Idle`).
|
||||
#[must_use]
|
||||
pub fn get(&self, screen: &Screen) -> &LoadState {
|
||||
static IDLE: LoadState = LoadState::Idle;
|
||||
self.map.get(screen).unwrap_or(&IDLE)
|
||||
}
|
||||
|
||||
/// Set the load state for a screen.
|
||||
///
|
||||
/// Setting to `Idle` removes the entry to prevent map growth.
|
||||
pub fn set(&mut self, screen: Screen, state: LoadState) {
|
||||
self.visited.insert(screen.clone());
|
||||
if state == LoadState::Idle {
|
||||
self.map.remove(&screen);
|
||||
} else {
|
||||
self.map.insert(screen, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this screen has ever had a load initiated.
|
||||
#[must_use]
|
||||
pub fn was_visited(&self, screen: &Screen) -> bool {
|
||||
self.visited.contains(screen)
|
||||
}
|
||||
|
||||
/// Whether any screen is currently loading.
|
||||
#[must_use]
|
||||
pub fn any_loading(&self) -> bool {
|
||||
self.map.values().any(LoadState::is_loading)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenIntent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure return type from screen state handlers.
|
||||
///
|
||||
/// State handlers must never spawn async work directly — they return
|
||||
/// an intent that [`LoreApp`] interprets and dispatches through the
|
||||
/// [`TaskSupervisor`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScreenIntent {
|
||||
/// No action needed.
|
||||
None,
|
||||
/// Navigate to a new screen.
|
||||
Navigate(Screen),
|
||||
/// Screen data needs re-querying (e.g., filter changed).
|
||||
RequeryNeeded(Screen),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScopeContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Global scope filters applied across all screens.
|
||||
///
|
||||
/// When a project filter is active, all data queries scope to that
|
||||
/// project. The TUI shows the active scope in the status bar.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ScopeContext {
|
||||
/// Active project filter (project_id).
|
||||
pub project_id: Option<i64>,
|
||||
/// Human-readable project name for display.
|
||||
pub project_name: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level state composition for the TUI.
|
||||
///
|
||||
/// Each field holds one screen's state. State is preserved when
|
||||
/// navigating away and restored on return.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AppState {
|
||||
// Per-screen states.
|
||||
pub dashboard: DashboardState,
|
||||
pub issue_list: IssueListState,
|
||||
pub issue_detail: IssueDetailState,
|
||||
pub mr_list: MrListState,
|
||||
pub mr_detail: MrDetailState,
|
||||
pub search: SearchState,
|
||||
pub timeline: TimelineState,
|
||||
pub who: WhoState,
|
||||
pub sync: SyncState,
|
||||
pub command_palette: CommandPaletteState,
|
||||
|
||||
// Cross-cutting state.
|
||||
pub global_scope: ScopeContext,
|
||||
pub load_state: ScreenLoadStateMap,
|
||||
pub error_toast: Option<String>,
|
||||
pub show_help: bool,
|
||||
pub terminal_size: (u16, u16),
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Set a screen's load state.
|
||||
pub fn set_loading(&mut self, screen: Screen, state: LoadState) {
|
||||
self.load_state.set(screen, state);
|
||||
}
|
||||
|
||||
/// Set the global error toast.
|
||||
pub fn set_error(&mut self, msg: String) {
|
||||
self.error_toast = Some(msg);
|
||||
}
|
||||
|
||||
/// Clear the global error toast.
|
||||
pub fn clear_error(&mut self) {
|
||||
self.error_toast = None;
|
||||
}
|
||||
|
||||
/// Whether any text input is currently focused.
|
||||
#[must_use]
|
||||
pub fn has_text_focus(&self) -> bool {
|
||||
self.issue_list.filter_focused
|
||||
|| self.mr_list.filter_focused
|
||||
|| self.search.query_focused
|
||||
|| self.command_palette.query_focused
|
||||
}
|
||||
|
||||
/// Remove focus from all text inputs.
|
||||
pub fn blur_text_focus(&mut self) {
|
||||
self.issue_list.filter_focused = false;
|
||||
self.mr_list.filter_focused = false;
|
||||
self.search.query_focused = false;
|
||||
self.command_palette.query_focused = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_load_state_default_idle() {
|
||||
let map = ScreenLoadStateMap::default();
|
||||
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
|
||||
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_state_set_and_get() {
|
||||
let mut map = ScreenLoadStateMap::default();
|
||||
map.set(Screen::Dashboard, LoadState::LoadingInitial);
|
||||
assert_eq!(*map.get(&Screen::Dashboard), LoadState::LoadingInitial);
|
||||
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_state_set_idle_removes_entry() {
|
||||
let mut map = ScreenLoadStateMap::default();
|
||||
map.set(Screen::Dashboard, LoadState::Refreshing);
|
||||
assert_eq!(map.map.len(), 1);
|
||||
|
||||
map.set(Screen::Dashboard, LoadState::Idle);
|
||||
assert_eq!(map.map.len(), 0);
|
||||
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_any_loading() {
|
||||
let mut map = ScreenLoadStateMap::default();
|
||||
assert!(!map.any_loading());
|
||||
|
||||
map.set(Screen::Dashboard, LoadState::LoadingInitial);
|
||||
assert!(map.any_loading());
|
||||
|
||||
map.set(Screen::Dashboard, LoadState::Error("oops".into()));
|
||||
assert!(!map.any_loading());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_state_is_loading() {
|
||||
assert!(!LoadState::Idle.is_loading());
|
||||
assert!(LoadState::LoadingInitial.is_loading());
|
||||
assert!(LoadState::Refreshing.is_loading());
|
||||
assert!(!LoadState::Error("x".into()).is_loading());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_default_compiles() {
|
||||
let state = AppState::default();
|
||||
assert!(!state.show_help);
|
||||
assert!(state.error_toast.is_none());
|
||||
assert_eq!(state.terminal_size, (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_set_error_and_clear() {
|
||||
let mut state = AppState::default();
|
||||
state.set_error("db busy".into());
|
||||
assert_eq!(state.error_toast.as_deref(), Some("db busy"));
|
||||
|
||||
state.clear_error();
|
||||
assert!(state.error_toast.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_has_text_focus() {
|
||||
let mut state = AppState::default();
|
||||
assert!(!state.has_text_focus());
|
||||
|
||||
state.search.query_focused = true;
|
||||
assert!(state.has_text_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_blur_text_focus() {
|
||||
let mut state = AppState::default();
|
||||
state.issue_list.filter_focused = true;
|
||||
state.mr_list.filter_focused = true;
|
||||
state.search.query_focused = true;
|
||||
state.command_palette.query_focused = true;
|
||||
|
||||
state.blur_text_focus();
|
||||
|
||||
assert!(!state.has_text_focus());
|
||||
assert!(!state.issue_list.filter_focused);
|
||||
assert!(!state.mr_list.filter_focused);
|
||||
assert!(!state.search.query_focused);
|
||||
assert!(!state.command_palette.query_focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_set_loading() {
|
||||
let mut state = AppState::default();
|
||||
state.set_loading(Screen::IssueList, LoadState::Refreshing);
|
||||
assert_eq!(
|
||||
*state.load_state.get(&Screen::IssueList),
|
||||
LoadState::Refreshing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_intent_variants() {
|
||||
let none = ScreenIntent::None;
|
||||
let nav = ScreenIntent::Navigate(Screen::IssueList);
|
||||
let requery = ScreenIntent::RequeryNeeded(Screen::Search);
|
||||
|
||||
assert_eq!(none, ScreenIntent::None);
|
||||
assert_eq!(nav, ScreenIntent::Navigate(Screen::IssueList));
|
||||
assert_eq!(requery, ScreenIntent::RequeryNeeded(Screen::Search));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_context_default() {
|
||||
let scope = ScopeContext::default();
|
||||
assert!(scope.project_id.is_none());
|
||||
assert!(scope.project_name.is_none());
|
||||
}
|
||||
}
|
||||
387
crates/lore-tui/src/state/mr_detail.rs
Normal file
387
crates/lore-tui/src/state/mr_detail.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by MR Detail screen
|
||||
|
||||
//! Merge request detail screen state.
|
||||
//!
|
||||
//! Holds MR metadata, file changes, discussions, cross-references,
|
||||
//! and UI state. Supports progressive hydration identical to
|
||||
//! Issue Detail: metadata loads first, discussions load async.
|
||||
|
||||
use crate::message::EntityKey;
|
||||
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
|
||||
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MrMetadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full metadata for a single merge request, fetched from the local DB.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrMetadata {
|
||||
/// MR IID (project-scoped).
|
||||
pub iid: i64,
|
||||
/// Project path (e.g., "group/project").
|
||||
pub project_path: String,
|
||||
/// MR title.
|
||||
pub title: String,
|
||||
/// MR description (markdown).
|
||||
pub description: String,
|
||||
/// Current state: "opened", "merged", "closed", "locked".
|
||||
pub state: String,
|
||||
/// Whether this is a draft/WIP MR.
|
||||
pub draft: bool,
|
||||
/// Author username.
|
||||
pub author: String,
|
||||
/// Assigned usernames.
|
||||
pub assignees: Vec<String>,
|
||||
/// Reviewer usernames.
|
||||
pub reviewers: Vec<String>,
|
||||
/// Label names.
|
||||
pub labels: Vec<String>,
|
||||
/// Source branch name.
|
||||
pub source_branch: String,
|
||||
/// Target branch name.
|
||||
pub target_branch: String,
|
||||
/// Detailed merge status (e.g., "mergeable", "checking").
|
||||
pub merge_status: String,
|
||||
/// Created timestamp (ms epoch).
|
||||
pub created_at: i64,
|
||||
/// Updated timestamp (ms epoch).
|
||||
pub updated_at: i64,
|
||||
/// Merged timestamp (ms epoch), if merged.
|
||||
pub merged_at: Option<i64>,
|
||||
/// GitLab web URL.
|
||||
pub web_url: String,
|
||||
/// Discussion count (for display before discussions load).
|
||||
pub discussion_count: usize,
|
||||
/// File change count.
|
||||
pub file_change_count: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileChange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A file changed in the merge request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileChange {
|
||||
/// Previous file path (if renamed).
|
||||
pub old_path: Option<String>,
|
||||
/// New/current file path.
|
||||
pub new_path: String,
|
||||
/// Type of change.
|
||||
pub change_type: FileChangeType,
|
||||
}
|
||||
|
||||
/// The type of file change in an MR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileChangeType {
|
||||
Added,
|
||||
Modified,
|
||||
Deleted,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
impl FileChangeType {
|
||||
/// Short icon for display.
|
||||
#[must_use]
|
||||
pub const fn icon(&self) -> &str {
|
||||
match self {
|
||||
Self::Added => "+",
|
||||
Self::Modified => "~",
|
||||
Self::Deleted => "-",
|
||||
Self::Renamed => "R",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse from DB string.
|
||||
#[must_use]
|
||||
pub fn parse_db(s: &str) -> Self {
|
||||
match s {
|
||||
"added" => Self::Added,
|
||||
"deleted" => Self::Deleted,
|
||||
"renamed" => Self::Renamed,
|
||||
_ => Self::Modified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MrDetailData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bundle returned by the metadata fetch action.
|
||||
///
|
||||
/// Metadata + cross-refs + file changes load in Phase 1 (fast).
|
||||
/// Discussions load separately in Phase 2.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrDetailData {
|
||||
pub metadata: MrMetadata,
|
||||
pub cross_refs: Vec<CrossRef>,
|
||||
pub file_changes: Vec<FileChange>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MrTab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Active tab in the MR detail view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MrTab {
|
||||
/// Overview: description + cross-refs.
|
||||
#[default]
|
||||
Overview,
|
||||
/// File changes list.
|
||||
Files,
|
||||
/// Discussions (general + diff).
|
||||
Discussions,
|
||||
}
|
||||
|
||||
impl MrTab {
|
||||
/// Cycle to the next tab.
|
||||
#[must_use]
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Overview => Self::Files,
|
||||
Self::Files => Self::Discussions,
|
||||
Self::Discussions => Self::Overview,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the previous tab.
|
||||
#[must_use]
|
||||
pub fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Overview => Self::Discussions,
|
||||
Self::Files => Self::Overview,
|
||||
Self::Discussions => Self::Files,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label.
|
||||
#[must_use]
|
||||
pub const fn label(&self) -> &str {
|
||||
match self {
|
||||
Self::Overview => "Overview",
|
||||
Self::Files => "Files",
|
||||
Self::Discussions => "Discussions",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MrDetailState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the MR detail screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MrDetailState {
|
||||
/// Entity key for the currently displayed MR.
|
||||
pub current_key: Option<EntityKey>,
|
||||
/// MR metadata (Phase 1 load).
|
||||
pub metadata: Option<MrMetadata>,
|
||||
/// File changes (loaded with metadata in Phase 1).
|
||||
pub file_changes: Vec<FileChange>,
|
||||
/// Discussion nodes (Phase 2 async load).
|
||||
pub discussions: Vec<DiscussionNode>,
|
||||
/// Whether discussions have finished loading.
|
||||
pub discussions_loaded: bool,
|
||||
/// Cross-references (loaded with metadata in Phase 1).
|
||||
pub cross_refs: Vec<CrossRef>,
|
||||
/// Discussion tree UI state.
|
||||
pub tree_state: DiscussionTreeState,
|
||||
/// Cross-reference list UI state.
|
||||
pub cross_ref_state: CrossRefState,
|
||||
/// Description scroll offset.
|
||||
pub description_scroll: usize,
|
||||
/// File list selected index.
|
||||
pub file_selected: usize,
|
||||
/// File list scroll offset.
|
||||
pub file_scroll: usize,
|
||||
/// Active tab.
|
||||
pub active_tab: MrTab,
|
||||
}
|
||||
|
||||
impl MrDetailState {
|
||||
/// Reset state for a new MR.
|
||||
pub fn load_new(&mut self, key: EntityKey) {
|
||||
self.current_key = Some(key);
|
||||
self.metadata = None;
|
||||
self.file_changes.clear();
|
||||
self.discussions.clear();
|
||||
self.discussions_loaded = false;
|
||||
self.cross_refs.clear();
|
||||
self.tree_state = DiscussionTreeState::default();
|
||||
self.cross_ref_state = CrossRefState::default();
|
||||
self.description_scroll = 0;
|
||||
self.file_selected = 0;
|
||||
self.file_scroll = 0;
|
||||
self.active_tab = MrTab::Overview;
|
||||
}
|
||||
|
||||
/// Apply Phase 1 data (metadata + cross-refs + file changes).
|
||||
pub fn apply_metadata(&mut self, data: MrDetailData) {
|
||||
self.metadata = Some(data.metadata);
|
||||
self.cross_refs = data.cross_refs;
|
||||
self.file_changes = data.file_changes;
|
||||
}
|
||||
|
||||
/// Apply Phase 2 data (discussions).
|
||||
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
|
||||
self.discussions = discussions;
|
||||
self.discussions_loaded = true;
|
||||
}
|
||||
|
||||
/// Whether we have metadata loaded.
|
||||
#[must_use]
|
||||
pub fn has_metadata(&self) -> bool {
|
||||
self.metadata.is_some()
|
||||
}
|
||||
|
||||
/// Switch to the next tab.
|
||||
pub fn next_tab(&mut self) {
|
||||
self.active_tab = self.active_tab.next();
|
||||
}
|
||||
|
||||
/// Switch to the previous tab.
|
||||
pub fn prev_tab(&mut self) {
|
||||
self.active_tab = self.active_tab.prev();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::view::common::cross_ref::CrossRefKind;
|
||||
|
||||
#[test]
|
||||
fn test_mr_detail_state_default() {
|
||||
let state = MrDetailState::default();
|
||||
assert!(state.current_key.is_none());
|
||||
assert!(state.metadata.is_none());
|
||||
assert!(state.discussions.is_empty());
|
||||
assert!(!state.discussions_loaded);
|
||||
assert!(state.file_changes.is_empty());
|
||||
assert_eq!(state.active_tab, MrTab::Overview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_new_resets_state() {
|
||||
let mut state = MrDetailState {
|
||||
discussions_loaded: true,
|
||||
description_scroll: 10,
|
||||
active_tab: MrTab::Files,
|
||||
..MrDetailState::default()
|
||||
};
|
||||
|
||||
state.load_new(EntityKey::mr(1, 42));
|
||||
assert_eq!(state.current_key, Some(EntityKey::mr(1, 42)));
|
||||
assert!(state.metadata.is_none());
|
||||
assert!(!state.discussions_loaded);
|
||||
assert_eq!(state.description_scroll, 0);
|
||||
assert_eq!(state.active_tab, MrTab::Overview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_metadata() {
|
||||
let mut state = MrDetailState::default();
|
||||
state.load_new(EntityKey::mr(1, 42));
|
||||
|
||||
let data = MrDetailData {
|
||||
metadata: MrMetadata {
|
||||
iid: 42,
|
||||
project_path: "group/proj".into(),
|
||||
title: "Fix auth".into(),
|
||||
description: "MR description".into(),
|
||||
state: "opened".into(),
|
||||
draft: false,
|
||||
author: "alice".into(),
|
||||
assignees: vec!["bob".into()],
|
||||
reviewers: vec!["carol".into()],
|
||||
labels: vec!["backend".into()],
|
||||
source_branch: "fix-auth".into(),
|
||||
target_branch: "main".into(),
|
||||
merge_status: "mergeable".into(),
|
||||
created_at: 1_700_000_000_000,
|
||||
updated_at: 1_700_000_060_000,
|
||||
merged_at: None,
|
||||
web_url: "https://gitlab.com/group/proj/-/merge_requests/42".into(),
|
||||
discussion_count: 2,
|
||||
file_change_count: 3,
|
||||
},
|
||||
cross_refs: vec![CrossRef {
|
||||
kind: CrossRefKind::RelatedIssue,
|
||||
entity_key: EntityKey::issue(1, 10),
|
||||
label: "Related issue".into(),
|
||||
navigable: true,
|
||||
}],
|
||||
file_changes: vec![FileChange {
|
||||
old_path: None,
|
||||
new_path: "src/auth.rs".into(),
|
||||
change_type: FileChangeType::Modified,
|
||||
}],
|
||||
};
|
||||
|
||||
state.apply_metadata(data);
|
||||
assert!(state.has_metadata());
|
||||
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
|
||||
assert_eq!(state.cross_refs.len(), 1);
|
||||
assert_eq!(state.file_changes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_cycling() {
|
||||
let tab = MrTab::Overview;
|
||||
assert_eq!(tab.next(), MrTab::Files);
|
||||
assert_eq!(tab.next().next(), MrTab::Discussions);
|
||||
assert_eq!(tab.next().next().next(), MrTab::Overview);
|
||||
|
||||
assert_eq!(tab.prev(), MrTab::Discussions);
|
||||
assert_eq!(tab.prev().prev(), MrTab::Files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tab_labels() {
|
||||
assert_eq!(MrTab::Overview.label(), "Overview");
|
||||
assert_eq!(MrTab::Files.label(), "Files");
|
||||
assert_eq!(MrTab::Discussions.label(), "Discussions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_change_type_icon() {
|
||||
assert_eq!(FileChangeType::Added.icon(), "+");
|
||||
assert_eq!(FileChangeType::Modified.icon(), "~");
|
||||
assert_eq!(FileChangeType::Deleted.icon(), "-");
|
||||
assert_eq!(FileChangeType::Renamed.icon(), "R");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_change_type_parse_db() {
|
||||
assert_eq!(FileChangeType::parse_db("added"), FileChangeType::Added);
|
||||
assert_eq!(FileChangeType::parse_db("deleted"), FileChangeType::Deleted);
|
||||
assert_eq!(FileChangeType::parse_db("renamed"), FileChangeType::Renamed);
|
||||
assert_eq!(
|
||||
FileChangeType::parse_db("modified"),
|
||||
FileChangeType::Modified
|
||||
);
|
||||
assert_eq!(
|
||||
FileChangeType::parse_db("unknown"),
|
||||
FileChangeType::Modified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_prev_tab_on_state() {
|
||||
let mut state = MrDetailState::default();
|
||||
assert_eq!(state.active_tab, MrTab::Overview);
|
||||
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab, MrTab::Files);
|
||||
|
||||
state.prev_tab();
|
||||
assert_eq!(state.active_tab, MrTab::Overview);
|
||||
}
|
||||
}
|
||||
422
crates/lore-tui/src/state/mr_list.rs
Normal file
422
crates/lore-tui/src/state/mr_list.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/mr_list
|
||||
|
||||
//! Merge request list screen state.
|
||||
//!
|
||||
//! Mirrors the issue list pattern with MR-specific filter fields
|
||||
//! (draft, reviewer, target/source branch). Uses the same keyset
|
||||
//! pagination with snapshot fence for stable ordering.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor (keyset pagination boundary)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keyset pagination cursor — (updated_at, iid) boundary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MrCursor {
|
||||
pub updated_at: i64,
|
||||
pub iid: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Structured filter for MR list queries.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MrFilter {
|
||||
pub state: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub reviewer: Option<String>,
|
||||
pub target_branch: Option<String>,
|
||||
pub source_branch: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub draft: Option<bool>,
|
||||
pub free_text: Option<String>,
|
||||
pub project_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl MrFilter {
|
||||
/// Compute a hash for change detection.
|
||||
pub fn hash_value(&self) -> u64 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
self.state.hash(&mut hasher);
|
||||
self.author.hash(&mut hasher);
|
||||
self.reviewer.hash(&mut hasher);
|
||||
self.target_branch.hash(&mut hasher);
|
||||
self.source_branch.hash(&mut hasher);
|
||||
self.label.hash(&mut hasher);
|
||||
self.draft.hash(&mut hasher);
|
||||
self.free_text.hash(&mut hasher);
|
||||
self.project_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Whether any filter is active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.is_some()
|
||||
|| self.author.is_some()
|
||||
|| self.reviewer.is_some()
|
||||
|| self.target_branch.is_some()
|
||||
|| self.source_branch.is_some()
|
||||
|| self.label.is_some()
|
||||
|| self.draft.is_some()
|
||||
|| self.free_text.is_some()
|
||||
|| self.project_id.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single row in the MR list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrListRow {
|
||||
pub project_path: String,
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author: String,
|
||||
pub target_branch: String,
|
||||
pub labels: Vec<String>,
|
||||
pub updated_at: i64,
|
||||
pub draft: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result from a paginated MR list query.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MrListPage {
|
||||
pub rows: Vec<MrListRow>,
|
||||
pub next_cursor: Option<MrCursor>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fields available for sorting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MrSortField {
|
||||
#[default]
|
||||
UpdatedAt,
|
||||
Iid,
|
||||
Title,
|
||||
State,
|
||||
Author,
|
||||
TargetBranch,
|
||||
}
|
||||
|
||||
/// Sort direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MrSortOrder {
|
||||
#[default]
|
||||
Desc,
|
||||
Asc,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MrListState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the MR list screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MrListState {
|
||||
/// Current page of MR rows.
|
||||
pub rows: Vec<MrListRow>,
|
||||
/// Total count of matching MRs.
|
||||
pub total_count: u64,
|
||||
/// Selected row index (within current window).
|
||||
pub selected_index: usize,
|
||||
/// Scroll offset for the entity table.
|
||||
pub scroll_offset: usize,
|
||||
/// Cursor for the next page.
|
||||
pub next_cursor: Option<MrCursor>,
|
||||
/// Whether a prefetch is in flight.
|
||||
pub prefetch_in_flight: bool,
|
||||
/// Current filter.
|
||||
pub filter: MrFilter,
|
||||
/// Raw filter input text.
|
||||
pub filter_input: String,
|
||||
/// Whether the filter bar has focus.
|
||||
pub filter_focused: bool,
|
||||
/// Sort field.
|
||||
pub sort_field: MrSortField,
|
||||
/// Sort direction.
|
||||
pub sort_order: MrSortOrder,
|
||||
/// Snapshot fence: max updated_at from initial load.
|
||||
pub snapshot_fence: Option<i64>,
|
||||
/// Hash of the current filter for change detection.
|
||||
pub filter_hash: u64,
|
||||
/// Whether Quick Peek is visible.
|
||||
pub peek_visible: bool,
|
||||
}
|
||||
|
||||
impl MrListState {
|
||||
/// Reset pagination state (called when filter changes or on refresh).
|
||||
pub fn reset_pagination(&mut self) {
|
||||
self.rows.clear();
|
||||
self.next_cursor = None;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.snapshot_fence = None;
|
||||
self.total_count = 0;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Apply a new page of results.
|
||||
pub fn apply_page(&mut self, page: MrListPage) {
|
||||
// Set snapshot fence on first page load.
|
||||
if self.snapshot_fence.is_none() {
|
||||
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
|
||||
}
|
||||
self.rows.extend(page.rows);
|
||||
self.next_cursor = page.next_cursor;
|
||||
self.total_count = page.total_count;
|
||||
self.prefetch_in_flight = false;
|
||||
}
|
||||
|
||||
/// Check if filter changed and reset if needed.
|
||||
pub fn check_filter_change(&mut self) -> bool {
|
||||
let new_hash = self.filter.hash_value();
|
||||
if new_hash != self.filter_hash {
|
||||
self.filter_hash = new_hash;
|
||||
self.reset_pagination();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user has scrolled near the end of current data (80% threshold).
|
||||
pub fn should_prefetch(&self) -> bool {
|
||||
if self.prefetch_in_flight || self.next_cursor.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.rows.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let threshold = (self.rows.len() * 4) / 5; // 80%
|
||||
self.selected_index >= threshold
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_page(count: usize, has_next: bool) -> MrListPage {
|
||||
let rows: Vec<MrListRow> = (0..count)
|
||||
.map(|i| MrListRow {
|
||||
project_path: "group/project".into(),
|
||||
iid: (count - i) as i64,
|
||||
title: format!("MR {}", count - i),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: vec![],
|
||||
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||
draft: i % 3 == 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if has_next {
|
||||
rows.last().map(|r| MrCursor {
|
||||
updated_at: r.updated_at,
|
||||
iid: r.iid,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
MrListPage {
|
||||
rows,
|
||||
next_cursor,
|
||||
total_count: if has_next {
|
||||
(count * 2) as u64
|
||||
} else {
|
||||
count as u64
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_sets_snapshot_fence() {
|
||||
let mut state = MrListState::default();
|
||||
let page = sample_page(5, false);
|
||||
state.apply_page(page);
|
||||
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
assert!(state.snapshot_fence.is_some());
|
||||
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_page_appends() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
assert_eq!(state.rows.len(), 5);
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(state.rows.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_pagination_clears_state() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
state.selected_index = 3;
|
||||
|
||||
state.reset_pagination();
|
||||
|
||||
assert!(state.rows.is_empty());
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(state.next_cursor.is_none());
|
||||
assert!(state.snapshot_fence.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_detects_change() {
|
||||
let mut state = MrListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
|
||||
state.filter.state = Some("opened".into());
|
||||
assert!(state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_filter_change_no_change() {
|
||||
let mut state = MrListState::default();
|
||||
state.filter_hash = state.filter.hash_value();
|
||||
assert!(!state.check_filter_change());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
|
||||
state.selected_index = 4; // 40% -- no prefetch
|
||||
assert!(!state.should_prefetch());
|
||||
|
||||
state.selected_index = 8; // 80% -- prefetch
|
||||
assert!(state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_no_next_page() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(10, false));
|
||||
state.selected_index = 9;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_prefetch_already_in_flight() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(10, true));
|
||||
state.selected_index = 9;
|
||||
state.prefetch_in_flight = true;
|
||||
assert!(!state.should_prefetch());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_is_active() {
|
||||
let empty = MrFilter::default();
|
||||
assert!(!empty.is_active());
|
||||
|
||||
let active = MrFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(active.is_active());
|
||||
|
||||
let draft_active = MrFilter {
|
||||
draft: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(draft_active.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_hash_deterministic() {
|
||||
let f1 = MrFilter {
|
||||
state: Some("opened".into()),
|
||||
author: Some("taylor".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = f1.clone();
|
||||
assert_eq!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_hash_differs() {
|
||||
let f1 = MrFilter {
|
||||
state: Some("opened".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let f2 = MrFilter {
|
||||
state: Some("merged".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(f1.hash_value(), f2.hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_fence_not_overwritten_on_second_page() {
|
||||
let mut state = MrListState::default();
|
||||
state.apply_page(sample_page(5, true));
|
||||
let fence = state.snapshot_fence;
|
||||
|
||||
state.apply_page(sample_page(3, false));
|
||||
assert_eq!(
|
||||
state.snapshot_fence, fence,
|
||||
"Fence should not change on second page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_reviewer_field() {
|
||||
let f = MrFilter {
|
||||
reviewer: Some("alice".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(f.is_active());
|
||||
assert_ne!(f.hash_value(), MrFilter::default().hash_value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_filter_target_branch_field() {
|
||||
let f = MrFilter {
|
||||
target_branch: Some("main".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(f.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mr_list_row_draft_field() {
|
||||
let row = MrListRow {
|
||||
project_path: "g/p".into(),
|
||||
iid: 1,
|
||||
title: "Draft MR".into(),
|
||||
state: "opened".into(),
|
||||
author: "taylor".into(),
|
||||
target_branch: "main".into(),
|
||||
labels: vec![],
|
||||
updated_at: 0,
|
||||
draft: true,
|
||||
};
|
||||
assert!(row.draft);
|
||||
}
|
||||
}
|
||||
14
crates/lore-tui/src/state/search.rs
Normal file
14
crates/lore-tui/src/state/search.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Search screen state.
|
||||
|
||||
use crate::message::SearchResult;
|
||||
|
||||
/// State for the search screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchState {
|
||||
pub query: String,
|
||||
pub query_focused: bool,
|
||||
pub results: Vec<SearchResult>,
|
||||
pub selected_index: usize,
|
||||
}
|
||||
15
crates/lore-tui/src/state/sync.rs
Normal file
15
crates/lore-tui/src/state/sync.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Sync screen state.
|
||||
|
||||
/// State for the sync progress/summary screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncState {
|
||||
pub stage: String,
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
pub log_lines: Vec<String>,
|
||||
pub completed: bool,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
12
crates/lore-tui/src/state/timeline.rs
Normal file
12
crates/lore-tui/src/state/timeline.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Timeline screen state.
|
||||
|
||||
use crate::message::TimelineEvent;
|
||||
|
||||
/// State for the timeline screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TimelineState {
|
||||
pub events: Vec<TimelineEvent>,
|
||||
pub scroll_offset: u16,
|
||||
}
|
||||
12
crates/lore-tui/src/state/who.rs
Normal file
12
crates/lore-tui/src/state/who.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Who (people intelligence) screen state.
|
||||
|
||||
use crate::message::WhoResult;
|
||||
|
||||
/// State for the who/people screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WhoState {
|
||||
pub result: Option<WhoResult>,
|
||||
pub scroll_offset: u16,
|
||||
}
|
||||
380
crates/lore-tui/src/task_supervisor.rs
Normal file
380
crates/lore-tui/src/task_supervisor.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Centralized background task management with dedup and cancellation.
|
||||
//!
|
||||
//! All background work (DB queries, sync, search) flows through
|
||||
//! [`TaskSupervisor`]. Submitting a task with a key that already has an
|
||||
//! active handle cancels the previous task via its [`CancelToken`] and
|
||||
//! bumps the generation counter.
|
||||
//!
|
||||
//! Generation IDs enable stale-result detection: when an async result
|
||||
//! arrives, [`is_current`] checks whether the result's generation
|
||||
//! matches the latest submission for that key.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Deduplication key for background tasks.
|
||||
///
|
||||
/// Two tasks with the same key cannot run concurrently — submitting a
|
||||
/// new task with an existing key cancels the previous one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum TaskKey {
|
||||
/// Load data for a specific screen.
|
||||
LoadScreen(Screen),
|
||||
/// Global search query.
|
||||
Search,
|
||||
/// Sync stream (only one at a time).
|
||||
SyncStream,
|
||||
/// Re-query after filter change on a specific screen.
|
||||
FilterRequery(Screen),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskPriority
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Priority levels for task scheduling.
|
||||
///
|
||||
/// Lower numeric value = higher priority.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TaskPriority {
|
||||
/// User-initiated input (highest priority).
|
||||
Input = 0,
|
||||
/// Navigation-triggered data load.
|
||||
Navigation = 1,
|
||||
/// Background refresh / prefetch (lowest priority).
|
||||
Background = 2,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CancelToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Thread-safe cooperative cancellation flag.
|
||||
///
|
||||
/// Background tasks poll [`is_cancelled`] periodically and exit early
|
||||
/// when it returns `true`.
|
||||
#[derive(Debug)]
|
||||
pub struct CancelToken {
|
||||
cancelled: AtomicBool,
|
||||
}
|
||||
|
||||
impl CancelToken {
|
||||
/// Create a new, non-cancelled token.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancelled: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal cancellation.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Check whether cancellation has been requested.
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CancelToken {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InterruptHandle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opaque handle for interrupting a rusqlite operation.
|
||||
///
|
||||
/// Wraps the rusqlite `InterruptHandle` so the supervisor can cancel
|
||||
/// long-running queries. This is only set for tasks that lease a reader
|
||||
/// connection from [`DbManager`](crate::db::DbManager).
|
||||
pub struct InterruptHandle {
|
||||
handle: rusqlite::InterruptHandle,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for InterruptHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("InterruptHandle").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl InterruptHandle {
|
||||
/// Wrap a rusqlite interrupt handle.
|
||||
#[must_use]
|
||||
pub fn new(handle: rusqlite::InterruptHandle) -> Self {
|
||||
Self { handle }
|
||||
}
|
||||
|
||||
/// Interrupt the associated SQLite operation.
|
||||
pub fn interrupt(&self) {
|
||||
self.handle.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskHandle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle returned when a task is submitted.
|
||||
///
|
||||
/// Callers use this to pass the generation ID into async work so
|
||||
/// results can be tagged and checked for staleness.
|
||||
#[derive(Debug)]
|
||||
pub struct TaskHandle {
|
||||
/// Dedup key for this task.
|
||||
pub key: TaskKey,
|
||||
/// Monotonically increasing generation for stale detection.
|
||||
pub generation: u64,
|
||||
/// Cooperative cancellation token (shared with the supervisor).
|
||||
pub cancel: Arc<CancelToken>,
|
||||
/// Optional SQLite interrupt handle for long queries.
|
||||
pub interrupt: Option<InterruptHandle>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskSupervisor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages background tasks with deduplication and cancellation.
|
||||
///
|
||||
/// Only one task per [`TaskKey`] can be active. Submitting a new task
|
||||
/// with an existing key cancels the previous one (via its cancel token
|
||||
/// and optional interrupt handle) before registering the new handle.
|
||||
pub struct TaskSupervisor {
|
||||
active: HashMap<TaskKey, TaskHandle>,
|
||||
next_generation: AtomicU64,
|
||||
}
|
||||
|
||||
impl TaskSupervisor {
|
||||
/// Create a new supervisor with no active tasks.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: HashMap::new(),
|
||||
next_generation: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a new task, cancelling any existing task with the same key.
|
||||
///
|
||||
/// Returns a [`TaskHandle`] with a fresh generation ID and a shared
|
||||
/// cancel token. The caller clones the `Arc<CancelToken>` and passes
|
||||
/// it into the async work.
|
||||
pub fn submit(&mut self, key: TaskKey) -> &TaskHandle {
|
||||
// Cancel existing task with this key, if any.
|
||||
if let Some(old) = self.active.remove(&key) {
|
||||
old.cancel.cancel();
|
||||
if let Some(interrupt) = &old.interrupt {
|
||||
interrupt.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let cancel = Arc::new(CancelToken::new());
|
||||
|
||||
let handle = TaskHandle {
|
||||
key: key.clone(),
|
||||
generation,
|
||||
cancel,
|
||||
interrupt: None,
|
||||
};
|
||||
|
||||
self.active.insert(key.clone(), handle);
|
||||
self.active.get(&key).expect("just inserted")
|
||||
}
|
||||
|
||||
/// Check whether a generation is current for a given key.
|
||||
///
|
||||
/// Returns `true` only if the key has an active handle with the
|
||||
/// specified generation.
|
||||
#[must_use]
|
||||
pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool {
|
||||
self.active
|
||||
.get(key)
|
||||
.is_some_and(|h| h.generation == generation)
|
||||
}
|
||||
|
||||
/// Mark a task as complete, removing its handle.
|
||||
///
|
||||
/// Only removes the handle if the generation matches the active one.
|
||||
/// This prevents a late-arriving completion from removing a newer
|
||||
/// task's handle.
|
||||
pub fn complete(&mut self, key: &TaskKey, generation: u64) {
|
||||
if self.is_current(key, generation) {
|
||||
self.active.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all active tasks.
|
||||
///
|
||||
/// Used during shutdown to ensure background work stops promptly.
|
||||
pub fn cancel_all(&mut self) {
|
||||
for (_, handle) in self.active.drain() {
|
||||
handle.cancel.cancel();
|
||||
if let Some(interrupt) = &handle.interrupt {
|
||||
interrupt.interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently active tasks.
|
||||
#[must_use]
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.active.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskSupervisor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_submit_cancels_previous() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let gen1 = sup.submit(TaskKey::Search).generation;
|
||||
let cancel1 = sup.active.get(&TaskKey::Search).unwrap().cancel.clone();
|
||||
|
||||
let gen2 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
// First task's token should be cancelled.
|
||||
assert!(cancel1.is_cancelled());
|
||||
// Second task should have a different (higher) generation.
|
||||
assert!(gen2 > gen1);
|
||||
// Only one active task for this key.
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_current_after_supersede() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let gen1 = sup.submit(TaskKey::Search).generation;
|
||||
let gen2 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
assert!(!sup.is_current(&TaskKey::Search, gen1));
|
||||
assert!(sup.is_current(&TaskKey::Search, gen2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_removes_handle() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
let generation = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
sup.complete(&TaskKey::Search, generation);
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_ignores_stale() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let gen1 = sup.submit(TaskKey::Search).generation;
|
||||
let gen2 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
// Completing with old generation should NOT remove the newer handle.
|
||||
sup.complete(&TaskKey::Search, gen1);
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
assert!(sup.is_current(&TaskKey::Search, gen2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generation_monotonic() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let g1 = sup.submit(TaskKey::Search).generation;
|
||||
let g2 = sup.submit(TaskKey::SyncStream).generation;
|
||||
let g3 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
assert!(g1 < g2);
|
||||
assert!(g2 < g3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_keys_coexist() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
sup.submit(TaskKey::Search);
|
||||
sup.submit(TaskKey::SyncStream);
|
||||
sup.submit(TaskKey::LoadScreen(Screen::Dashboard));
|
||||
|
||||
assert_eq!(sup.active_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_all() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let cancel_search = {
|
||||
sup.submit(TaskKey::Search);
|
||||
sup.active.get(&TaskKey::Search).unwrap().cancel.clone()
|
||||
};
|
||||
let cancel_sync = {
|
||||
sup.submit(TaskKey::SyncStream);
|
||||
sup.active.get(&TaskKey::SyncStream).unwrap().cancel.clone()
|
||||
};
|
||||
|
||||
sup.cancel_all();
|
||||
|
||||
assert!(cancel_search.is_cancelled());
|
||||
assert!(cancel_sync.is_cancelled());
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_token_default_is_not_cancelled() {
|
||||
let token = CancelToken::new();
|
||||
assert!(!token.is_cancelled());
|
||||
token.cancel();
|
||||
assert!(token.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_token_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<CancelToken>();
|
||||
assert_send_sync::<Arc<CancelToken>>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_supervisor_default() {
|
||||
let sup = TaskSupervisor::default();
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_requery_key_distinct_per_screen() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
sup.submit(TaskKey::FilterRequery(Screen::IssueList));
|
||||
sup.submit(TaskKey::FilterRequery(Screen::MrList));
|
||||
|
||||
assert_eq!(sup.active_count(), 2);
|
||||
}
|
||||
}
|
||||
251
crates/lore-tui/src/theme.rs
Normal file
251
crates/lore-tui/src/theme.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
||||
|
||||
//! Flexoki-based theme for the lore TUI.
|
||||
//!
|
||||
//! Uses FrankenTUI's `AdaptiveColor::adaptive(light, dark)` for automatic
|
||||
//! light/dark mode switching. The palette is [Flexoki](https://stephango.com/flexoki)
|
||||
//! by Steph Ango, designed in Oklab perceptual color space for balanced contrast.
|
||||
|
||||
use ftui::{AdaptiveColor, Color, PackedRgba, Style, Theme};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flexoki palette constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Base tones
|
||||
const PAPER: Color = Color::rgb(0xFF, 0xFC, 0xF0);
|
||||
const BASE_50: Color = Color::rgb(0xF2, 0xF0, 0xE5);
|
||||
const BASE_100: Color = Color::rgb(0xE6, 0xE4, 0xD9);
|
||||
const BASE_200: Color = Color::rgb(0xCE, 0xCD, 0xC3);
|
||||
const BASE_300: Color = Color::rgb(0xB7, 0xB5, 0xAC);
|
||||
const BASE_400: Color = Color::rgb(0x9F, 0x9D, 0x96);
|
||||
const BASE_500: Color = Color::rgb(0x87, 0x85, 0x80);
|
||||
const BASE_600: Color = Color::rgb(0x6F, 0x6E, 0x69);
|
||||
const BASE_700: Color = Color::rgb(0x57, 0x56, 0x53);
|
||||
const BASE_800: Color = Color::rgb(0x40, 0x3E, 0x3C);
|
||||
const BASE_850: Color = Color::rgb(0x34, 0x33, 0x31);
|
||||
const BASE_900: Color = Color::rgb(0x28, 0x27, 0x26);
|
||||
const BLACK: Color = Color::rgb(0x10, 0x0F, 0x0F);
|
||||
|
||||
// Accent colors — light-600 (for light mode)
|
||||
const RED_600: Color = Color::rgb(0xAF, 0x30, 0x29);
|
||||
const ORANGE_600: Color = Color::rgb(0xBC, 0x52, 0x15);
|
||||
const YELLOW_600: Color = Color::rgb(0xAD, 0x83, 0x01);
|
||||
const GREEN_600: Color = Color::rgb(0x66, 0x80, 0x0B);
|
||||
const CYAN_600: Color = Color::rgb(0x24, 0x83, 0x7B);
|
||||
const BLUE_600: Color = Color::rgb(0x20, 0x5E, 0xA6);
|
||||
const PURPLE_600: Color = Color::rgb(0x5E, 0x40, 0x9D);
|
||||
|
||||
// Accent colors — dark-400 (for dark mode)
|
||||
const RED_400: Color = Color::rgb(0xD1, 0x4D, 0x41);
|
||||
const ORANGE_400: Color = Color::rgb(0xDA, 0x70, 0x2C);
|
||||
const YELLOW_400: Color = Color::rgb(0xD0, 0xA2, 0x15);
|
||||
const GREEN_400: Color = Color::rgb(0x87, 0x9A, 0x39);
|
||||
const CYAN_400: Color = Color::rgb(0x3A, 0xA9, 0x9F);
|
||||
const BLUE_400: Color = Color::rgb(0x43, 0x85, 0xBE);
|
||||
const PURPLE_400: Color = Color::rgb(0x8B, 0x7E, 0xC8);
|
||||
const MAGENTA_400: Color = Color::rgb(0xCE, 0x5D, 0x97);
|
||||
|
||||
// Muted fallback as PackedRgba (for Style::fg)
|
||||
const MUTED_PACKED: PackedRgba = PackedRgba::rgb(0x87, 0x85, 0x80);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_theme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the lore TUI theme with Flexoki adaptive colors.
|
||||
///
|
||||
/// Each of the 19 semantic slots gets an `AdaptiveColor::adaptive(light, dark)`
|
||||
/// pair. FrankenTUI detects the terminal background and resolves accordingly.
|
||||
#[must_use]
|
||||
pub fn build_theme() -> Theme {
|
||||
Theme::builder()
|
||||
.primary(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
||||
.secondary(AdaptiveColor::adaptive(CYAN_600, CYAN_400))
|
||||
.accent(AdaptiveColor::adaptive(PURPLE_600, PURPLE_400))
|
||||
.background(AdaptiveColor::adaptive(PAPER, BLACK))
|
||||
.surface(AdaptiveColor::adaptive(BASE_50, BASE_900))
|
||||
.overlay(AdaptiveColor::adaptive(BASE_100, BASE_850))
|
||||
.text(AdaptiveColor::adaptive(BASE_700, BASE_200))
|
||||
.text_muted(AdaptiveColor::adaptive(BASE_500, BASE_500))
|
||||
.text_subtle(AdaptiveColor::adaptive(BASE_400, BASE_600))
|
||||
.success(AdaptiveColor::adaptive(GREEN_600, GREEN_400))
|
||||
.warning(AdaptiveColor::adaptive(YELLOW_600, YELLOW_400))
|
||||
.error(AdaptiveColor::adaptive(RED_600, RED_400))
|
||||
.info(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
||||
.border(AdaptiveColor::adaptive(BASE_300, BASE_700))
|
||||
.border_focused(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
||||
.selection_bg(AdaptiveColor::adaptive(BASE_100, BASE_800))
|
||||
.selection_fg(AdaptiveColor::adaptive(BASE_700, BASE_100))
|
||||
.scrollbar_track(AdaptiveColor::adaptive(BASE_50, BASE_900))
|
||||
.scrollbar_thumb(AdaptiveColor::adaptive(BASE_300, BASE_700))
|
||||
.build()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map a GitLab entity state to a display color.
|
||||
///
|
||||
/// Returns fixed (non-adaptive) colors — state indicators should be
|
||||
/// consistent regardless of light/dark mode.
|
||||
#[must_use]
|
||||
pub fn state_color(state: &str) -> Color {
|
||||
match state {
|
||||
"opened" => GREEN_400,
|
||||
"closed" => RED_400,
|
||||
"merged" => PURPLE_400,
|
||||
"locked" => YELLOW_400,
|
||||
_ => BASE_500,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event type colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Map a timeline event type to a display color.
|
||||
#[must_use]
|
||||
pub fn event_color(event_type: &str) -> Color {
|
||||
match event_type {
|
||||
"created" => GREEN_400,
|
||||
"updated" => BLUE_400,
|
||||
"closed" => RED_400,
|
||||
"merged" => PURPLE_400,
|
||||
"commented" => CYAN_400,
|
||||
"labeled" => ORANGE_400,
|
||||
"milestoned" => YELLOW_400,
|
||||
_ => BASE_500,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label styling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert a GitLab label hex color (e.g., "#FF0000" or "FF0000") to a Style.
|
||||
///
|
||||
/// Falls back to muted text color if the hex string is invalid.
|
||||
#[must_use]
|
||||
pub fn label_style(hex_color: &str) -> Style {
|
||||
let packed = parse_hex_to_packed(hex_color).unwrap_or(MUTED_PACKED);
|
||||
Style::default().fg(packed)
|
||||
}
|
||||
|
||||
/// Parse a hex color string like "#RRGGBB" or "RRGGBB" into a `PackedRgba`.
|
||||
fn parse_hex_to_packed(s: &str) -> Option<PackedRgba> {
|
||||
let hex = s.strip_prefix('#').unwrap_or(s);
|
||||
if hex.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
Some(PackedRgba::rgb(r, g, b))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_theme_compiles() {
|
||||
let theme = build_theme();
|
||||
// Resolve for dark mode — primary should be Blue-400
|
||||
let resolved = theme.resolve(true);
|
||||
assert_eq!(resolved.primary, BLUE_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_theme_light_mode() {
|
||||
let theme = build_theme();
|
||||
let resolved = theme.resolve(false);
|
||||
assert_eq!(resolved.primary, BLUE_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_theme_all_slots_differ_between_modes() {
|
||||
let theme = build_theme();
|
||||
let dark = theme.resolve(true);
|
||||
let light = theme.resolve(false);
|
||||
// Background should differ (Paper vs Black)
|
||||
assert_ne!(dark.background, light.background);
|
||||
// Text should differ
|
||||
assert_ne!(dark.text, light.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_color_opened_is_green() {
|
||||
assert_eq!(state_color("opened"), GREEN_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_color_closed_is_red() {
|
||||
assert_eq!(state_color("closed"), RED_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_color_merged_is_purple() {
|
||||
assert_eq!(state_color("merged"), PURPLE_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_color_unknown_returns_muted() {
|
||||
assert_eq!(state_color("unknown"), BASE_500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_color_created_is_green() {
|
||||
assert_eq!(event_color("created"), GREEN_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_color_unknown_returns_muted() {
|
||||
assert_eq!(event_color("whatever"), BASE_500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_style_valid_hex_with_hash() {
|
||||
let style = label_style("#FF0000");
|
||||
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_style_valid_hex_without_hash() {
|
||||
let style = label_style("00FF00");
|
||||
assert_eq!(style.fg, Some(PackedRgba::rgb(0x00, 0xFF, 0x00)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_style_lowercase_hex() {
|
||||
let style = label_style("#ff0000");
|
||||
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_style_invalid_hex_fallback() {
|
||||
let style = label_style("invalid");
|
||||
assert_eq!(style.fg, Some(MUTED_PACKED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_style_empty_fallback() {
|
||||
let style = label_style("");
|
||||
assert_eq!(style.fg, Some(MUTED_PACKED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hex_short_string() {
|
||||
assert!(parse_hex_to_packed("#FFF").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hex_non_hex_chars() {
|
||||
assert!(parse_hex_to_packed("#GGHHII").is_none());
|
||||
}
|
||||
}
|
||||
208
crates/lore-tui/src/view/common/breadcrumb.rs
Normal file
208
crates/lore-tui/src/view/common/breadcrumb.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! Navigation breadcrumb trail ("Dashboard > Issues > #42").
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::navigation::NavigationStack;
|
||||
|
||||
/// Render the navigation breadcrumb trail.
|
||||
///
|
||||
/// Shows "Dashboard > Issues > Issue" with " > " separators. When the
|
||||
/// trail exceeds the available width, entries are truncated from the left
|
||||
/// with a leading "...".
|
||||
pub fn render_breadcrumb(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
nav: &NavigationStack,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
) {
|
||||
if area.height == 0 || area.width < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let crumbs = nav.breadcrumbs();
|
||||
let separator = " > ";
|
||||
|
||||
// Build the full breadcrumb string and calculate width.
|
||||
let full: String = crumbs.join(separator);
|
||||
let max_width = area.width as usize;
|
||||
|
||||
let display = if full.len() <= max_width {
|
||||
full
|
||||
} else {
|
||||
// Truncate from the left: show "... > last_crumbs"
|
||||
truncate_breadcrumb_left(&crumbs, separator, max_width)
|
||||
};
|
||||
|
||||
let base = Cell {
|
||||
fg: text_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted = Cell {
|
||||
fg: muted_color,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
// Render each segment with separators in muted color.
|
||||
let mut x = area.x;
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
if let Some(rest) = display.strip_prefix("...") {
|
||||
// Render ellipsis in muted, then the rest
|
||||
x = frame.print_text_clipped(x, area.y, "...", muted, max_x);
|
||||
if !rest.is_empty() {
|
||||
render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x);
|
||||
}
|
||||
} else {
|
||||
render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render breadcrumb text with separators in muted color.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_crumb_segments(
|
||||
frame: &mut Frame<'_>,
|
||||
start_x: u16,
|
||||
y: u16,
|
||||
text: &str,
|
||||
separator: &str,
|
||||
base: Cell,
|
||||
muted: Cell,
|
||||
max_x: u16,
|
||||
) {
|
||||
let mut x = start_x;
|
||||
let parts: Vec<&str> = text.split(separator).collect();
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
x = frame.print_text_clipped(x, y, separator, muted, max_x);
|
||||
}
|
||||
x = frame.print_text_clipped(x, y, part, base, max_x);
|
||||
if x >= max_x {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate breadcrumb from the left to fit within max_width.
|
||||
fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String {
|
||||
let ellipsis = "...";
|
||||
|
||||
// Try showing progressively fewer crumbs from the right.
|
||||
for skip in 1..crumbs.len() {
|
||||
let tail = &crumbs[skip..];
|
||||
let tail_str: String = tail.join(separator);
|
||||
let candidate = format!("{ellipsis}{separator}{tail_str}");
|
||||
if candidate.len() <= max_width {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: just the current screen truncated.
|
||||
let last = crumbs.last().unwrap_or(&"");
|
||||
if last.len() + ellipsis.len() <= max_width {
|
||||
return format!("{ellipsis}{last}");
|
||||
}
|
||||
|
||||
// Truly tiny terminal: just ellipsis.
|
||||
ellipsis.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::Screen;
|
||||
use crate::navigation::NavigationStack;
|
||||
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 white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn gray() -> PackedRgba {
|
||||
PackedRgba::rgb(0x80, 0x80, 0x80)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_single_screen() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let nav = NavigationStack::new();
|
||||
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
|
||||
|
||||
let cell = frame.buffer.get(0, 0).unwrap();
|
||||
assert!(
|
||||
cell.content.as_char() == Some('D'),
|
||||
"Expected 'D' at (0,0), got {:?}",
|
||||
cell.content.as_char()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_multi_screen() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
|
||||
|
||||
let d = frame.buffer.get(0, 0).unwrap();
|
||||
assert_eq!(d.content.as_char(), Some('D'));
|
||||
|
||||
// "Dashboard > Issues" = 'I' at 12
|
||||
let i_cell = frame.buffer.get(12, 0).unwrap();
|
||||
assert_eq!(i_cell.content.as_char(), Some('I'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_truncation() {
|
||||
let crumbs = vec!["Dashboard", "Issues", "Issue"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 20);
|
||||
assert!(
|
||||
result.starts_with("..."),
|
||||
"Expected ellipsis prefix, got: {result}"
|
||||
);
|
||||
assert!(result.len() <= 20, "Result too long: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_zero_height_noop() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let nav = NavigationStack::new();
|
||||
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_breadcrumb_fits() {
|
||||
let crumbs = vec!["A", "B"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 100);
|
||||
assert!(result.contains("..."), "Should always add ellipsis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_breadcrumb_single_entry() {
|
||||
let crumbs = vec!["Dashboard"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 5);
|
||||
assert_eq!(result, "...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_breadcrumb_shows_last_entries() {
|
||||
let crumbs = vec!["Dashboard", "Issues", "Issue Detail"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 30);
|
||||
assert!(result.starts_with("..."));
|
||||
assert!(result.contains("Issue Detail"));
|
||||
}
|
||||
}
|
||||
410
crates/lore-tui/src/view/common/cross_ref.rs
Normal file
410
crates/lore-tui/src/view/common/cross_ref.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
|
||||
|
||||
//! Cross-reference widget for entity detail screens.
|
||||
//!
|
||||
//! Renders a list of linked entities (closing MRs, related issues, mentions)
|
||||
//! as navigable items. Used in both Issue Detail and MR Detail views.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::message::EntityKey;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrossRefKind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The relationship type between two entities.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CrossRefKind {
|
||||
/// MR that closes this issue when merged.
|
||||
ClosingMr,
|
||||
/// Issue related via GitLab link.
|
||||
RelatedIssue,
|
||||
/// Entity mentioned in a note or description.
|
||||
MentionedIn,
|
||||
}
|
||||
|
||||
impl CrossRefKind {
|
||||
/// Short icon/prefix for display.
|
||||
#[must_use]
|
||||
pub const fn icon(&self) -> &str {
|
||||
match self {
|
||||
Self::ClosingMr => "MR",
|
||||
Self::RelatedIssue => "REL",
|
||||
Self::MentionedIn => "REF",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CrossRefKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ClosingMr => write!(f, "Closing MR"),
|
||||
Self::RelatedIssue => write!(f, "Related Issue"),
|
||||
Self::MentionedIn => write!(f, "Mentioned In"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrossRef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single cross-reference to another entity.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CrossRef {
|
||||
/// Relationship type.
|
||||
pub kind: CrossRefKind,
|
||||
/// Target entity identity.
|
||||
pub entity_key: EntityKey,
|
||||
/// Human-readable label (e.g., "Fix authentication flow").
|
||||
pub label: String,
|
||||
/// Whether this ref points to an entity in the local DB (navigable).
|
||||
pub navigable: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrossRefState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rendering state for the cross-reference list.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CrossRefState {
|
||||
/// Index of the selected cross-reference.
|
||||
pub selected: usize,
|
||||
/// First visible item index.
|
||||
pub scroll_offset: usize,
|
||||
}
|
||||
|
||||
impl CrossRefState {
|
||||
/// Move selection down.
|
||||
pub fn select_next(&mut self, total: usize) {
|
||||
if total > 0 && self.selected < total - 1 {
|
||||
self.selected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Color scheme for cross-reference rendering.
|
||||
pub struct CrossRefColors {
|
||||
/// Foreground for the kind icon/badge.
|
||||
pub kind_fg: PackedRgba,
|
||||
/// Foreground for the label text.
|
||||
pub label_fg: PackedRgba,
|
||||
/// Muted foreground for non-navigable refs.
|
||||
pub muted_fg: PackedRgba,
|
||||
/// Selected item foreground.
|
||||
pub selected_fg: PackedRgba,
|
||||
/// Selected item background.
|
||||
pub selected_bg: PackedRgba,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a list of cross-references within the given area.
|
||||
///
|
||||
/// Returns the number of rows consumed.
|
||||
///
|
||||
/// Layout per row:
|
||||
/// ```text
|
||||
/// [MR] !42 Fix authentication flow
|
||||
/// [REL] #15 Related auth issue
|
||||
/// [REF] !99 Mentioned in pipeline MR
|
||||
/// ```
|
||||
pub fn render_cross_refs(
|
||||
frame: &mut Frame<'_>,
|
||||
refs: &[CrossRef],
|
||||
state: &CrossRefState,
|
||||
area: Rect,
|
||||
colors: &CrossRefColors,
|
||||
) -> u16 {
|
||||
if refs.is_empty() || area.height == 0 || area.width < 10 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let visible_count = (area.height as usize).min(refs.len().saturating_sub(state.scroll_offset));
|
||||
|
||||
for i in 0..visible_count {
|
||||
let idx = state.scroll_offset + i;
|
||||
let Some(cr) = refs.get(idx) else { break };
|
||||
|
||||
let y = area.y + i as u16;
|
||||
let is_selected = idx == state.selected;
|
||||
|
||||
// Background fill for selected row.
|
||||
if is_selected {
|
||||
frame.draw_rect_filled(
|
||||
Rect::new(area.x, y, area.width, 1),
|
||||
Cell {
|
||||
fg: colors.selected_fg,
|
||||
bg: colors.selected_bg,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut x = area.x;
|
||||
|
||||
// Kind badge: [MR], [REL], [REF]
|
||||
let badge = format!("[{}]", cr.kind.icon());
|
||||
let badge_style = if is_selected {
|
||||
Cell {
|
||||
fg: colors.selected_fg,
|
||||
bg: colors.selected_bg,
|
||||
..Cell::default()
|
||||
}
|
||||
} else {
|
||||
Cell {
|
||||
fg: colors.kind_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
};
|
||||
x = frame.print_text_clipped(x, y, &badge, badge_style, max_x);
|
||||
|
||||
// Spacing
|
||||
x = frame.print_text_clipped(x, y, " ", badge_style, max_x);
|
||||
|
||||
// Entity prefix + label
|
||||
let prefix = match cr.kind {
|
||||
CrossRefKind::ClosingMr | CrossRefKind::MentionedIn => {
|
||||
format!("!{} ", cr.entity_key.iid)
|
||||
}
|
||||
CrossRefKind::RelatedIssue => {
|
||||
format!("#{} ", cr.entity_key.iid)
|
||||
}
|
||||
};
|
||||
|
||||
let label_style = if is_selected {
|
||||
Cell {
|
||||
fg: colors.selected_fg,
|
||||
bg: colors.selected_bg,
|
||||
..Cell::default()
|
||||
}
|
||||
} else if cr.navigable {
|
||||
Cell {
|
||||
fg: colors.label_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
} else {
|
||||
Cell {
|
||||
fg: colors.muted_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
};
|
||||
|
||||
x = frame.print_text_clipped(x, y, &prefix, label_style, max_x);
|
||||
let _ = frame.print_text_clipped(x, y, &cr.label, label_style, max_x);
|
||||
}
|
||||
|
||||
visible_count as u16
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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_refs() -> Vec<CrossRef> {
|
||||
vec![
|
||||
CrossRef {
|
||||
kind: CrossRefKind::ClosingMr,
|
||||
entity_key: EntityKey::mr(1, 42),
|
||||
label: "Fix authentication flow".into(),
|
||||
navigable: true,
|
||||
},
|
||||
CrossRef {
|
||||
kind: CrossRefKind::RelatedIssue,
|
||||
entity_key: EntityKey::issue(1, 15),
|
||||
label: "Related auth issue".into(),
|
||||
navigable: true,
|
||||
},
|
||||
CrossRef {
|
||||
kind: CrossRefKind::MentionedIn,
|
||||
entity_key: EntityKey::mr(2, 99),
|
||||
label: "Pipeline improvements".into(),
|
||||
navigable: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn test_colors() -> CrossRefColors {
|
||||
CrossRefColors {
|
||||
kind_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
|
||||
label_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
muted_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
}
|
||||
}
|
||||
|
||||
// TDD anchor test from bead spec.
|
||||
#[test]
|
||||
fn test_cross_ref_entity_key() {
|
||||
let cr = CrossRef {
|
||||
kind: CrossRefKind::ClosingMr,
|
||||
entity_key: EntityKey::mr(1, 42),
|
||||
label: "Fix auth".into(),
|
||||
navigable: true,
|
||||
};
|
||||
assert_eq!(cr.kind, CrossRefKind::ClosingMr);
|
||||
assert_eq!(cr.entity_key, EntityKey::mr(1, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_ref_kind_display() {
|
||||
assert_eq!(CrossRefKind::ClosingMr.to_string(), "Closing MR");
|
||||
assert_eq!(CrossRefKind::RelatedIssue.to_string(), "Related Issue");
|
||||
assert_eq!(CrossRefKind::MentionedIn.to_string(), "Mentioned In");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_ref_kind_icon() {
|
||||
assert_eq!(CrossRefKind::ClosingMr.icon(), "MR");
|
||||
assert_eq!(CrossRefKind::RelatedIssue.icon(), "REL");
|
||||
assert_eq!(CrossRefKind::MentionedIn.icon(), "REF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_ref_state_navigation() {
|
||||
let mut state = CrossRefState::default();
|
||||
assert_eq!(state.selected, 0);
|
||||
|
||||
state.select_next(3);
|
||||
assert_eq!(state.selected, 1);
|
||||
|
||||
state.select_next(3);
|
||||
assert_eq!(state.selected, 2);
|
||||
|
||||
// Can't go past end.
|
||||
state.select_next(3);
|
||||
assert_eq!(state.selected, 2);
|
||||
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 1);
|
||||
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 0);
|
||||
|
||||
// Can't go before start.
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_cross_refs_no_panic() {
|
||||
with_frame!(80, 10, |frame| {
|
||||
let refs = sample_refs();
|
||||
let state = CrossRefState::default();
|
||||
let rows = render_cross_refs(
|
||||
&mut frame,
|
||||
&refs,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 10),
|
||||
&test_colors(),
|
||||
);
|
||||
assert_eq!(rows, 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_cross_refs_empty() {
|
||||
with_frame!(80, 10, |frame| {
|
||||
let state = CrossRefState::default();
|
||||
let rows = render_cross_refs(
|
||||
&mut frame,
|
||||
&[],
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 10),
|
||||
&test_colors(),
|
||||
);
|
||||
assert_eq!(rows, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_cross_refs_tiny_area() {
|
||||
with_frame!(5, 1, |frame| {
|
||||
let refs = sample_refs();
|
||||
let state = CrossRefState::default();
|
||||
let rows = render_cross_refs(
|
||||
&mut frame,
|
||||
&refs,
|
||||
&state,
|
||||
Rect::new(0, 0, 5, 1),
|
||||
&test_colors(),
|
||||
);
|
||||
// Too narrow (< 10), should bail.
|
||||
assert_eq!(rows, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_cross_refs_with_scroll() {
|
||||
with_frame!(80, 2, |frame| {
|
||||
let refs = sample_refs();
|
||||
let state = CrossRefState {
|
||||
selected: 2,
|
||||
scroll_offset: 1,
|
||||
};
|
||||
let rows = render_cross_refs(
|
||||
&mut frame,
|
||||
&refs,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 2),
|
||||
&test_colors(),
|
||||
);
|
||||
// 2 visible (indices 1 and 2).
|
||||
assert_eq!(rows, 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_cross_refs_non_navigable() {
|
||||
with_frame!(80, 5, |frame| {
|
||||
let refs = vec![CrossRef {
|
||||
kind: CrossRefKind::MentionedIn,
|
||||
entity_key: EntityKey::mr(2, 99),
|
||||
label: "Non-local entity".into(),
|
||||
navigable: false,
|
||||
}];
|
||||
let state = CrossRefState::default();
|
||||
let rows = render_cross_refs(
|
||||
&mut frame,
|
||||
&refs,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 5),
|
||||
&test_colors(),
|
||||
);
|
||||
assert_eq!(rows, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
979
crates/lore-tui/src/view/common/discussion_tree.rs
Normal file
979
crates/lore-tui/src/view/common/discussion_tree.rs
Normal file
@@ -0,0 +1,979 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
|
||||
|
||||
//! Discussion tree widget for entity detail screens.
|
||||
//!
|
||||
//! Renders threaded conversations from GitLab issues/MRs. Discussions are
|
||||
//! top-level expandable nodes, with notes as children. Supports expand/collapse
|
||||
//! persistence, system note styling, and diff note file path rendering.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::clock::Clock;
|
||||
use crate::safety::{UrlPolicy, sanitize_for_terminal};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single discussion thread (top-level node).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscussionNode {
|
||||
/// GitLab discussion ID (used as expand/collapse key).
|
||||
pub discussion_id: String,
|
||||
/// Notes within this discussion, ordered by position.
|
||||
pub notes: Vec<NoteNode>,
|
||||
/// Whether this discussion is resolvable (MR discussions only).
|
||||
pub resolvable: bool,
|
||||
/// Whether this discussion has been resolved.
|
||||
pub resolved: bool,
|
||||
}
|
||||
|
||||
impl DiscussionNode {
|
||||
/// Summary line for collapsed display.
|
||||
fn summary(&self) -> String {
|
||||
let first = self.notes.first();
|
||||
let author = first.map_or("unknown", |n| n.author.as_str());
|
||||
let note_count = self.notes.len();
|
||||
let resolved_tag = if self.resolved { " [resolved]" } else { "" };
|
||||
|
||||
if note_count == 1 {
|
||||
format!("{author}{resolved_tag}")
|
||||
} else {
|
||||
format!("{author} ({note_count} notes){resolved_tag}")
|
||||
}
|
||||
}
|
||||
|
||||
/// First line of the first note body, sanitized and truncated.
|
||||
fn preview(&self, max_chars: usize) -> String {
|
||||
self.notes
|
||||
.first()
|
||||
.and_then(|n| n.body.lines().next())
|
||||
.map(|line| {
|
||||
let sanitized = sanitize_for_terminal(line, UrlPolicy::Strip);
|
||||
if sanitized.len() > max_chars {
|
||||
let trunc = max_chars.saturating_sub(3);
|
||||
// Find the last valid char boundary at or before `trunc`
|
||||
// to avoid panicking on multi-byte UTF-8 (emoji, CJK).
|
||||
let safe_end = sanitized
|
||||
.char_indices()
|
||||
.take_while(|&(i, _)| i <= trunc)
|
||||
.last()
|
||||
.map_or(0, |(i, c)| i + c.len_utf8());
|
||||
format!("{}...", &sanitized[..safe_end])
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single note within a discussion.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoteNode {
|
||||
/// Author username.
|
||||
pub author: String,
|
||||
/// Note body (markdown text from GitLab).
|
||||
pub body: String,
|
||||
/// Creation timestamp in milliseconds since epoch.
|
||||
pub created_at: i64,
|
||||
/// Whether this is a system-generated note.
|
||||
pub is_system: bool,
|
||||
/// Whether this is a diff/code review note.
|
||||
pub is_diff_note: bool,
|
||||
/// File path for diff notes.
|
||||
pub diff_file_path: Option<String>,
|
||||
/// New line number for diff notes.
|
||||
pub diff_new_line: Option<i64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rendering state for the discussion tree.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiscussionTreeState {
|
||||
/// Index of the selected discussion (0-based).
|
||||
pub selected: usize,
|
||||
/// First visible row index for scrolling.
|
||||
pub scroll_offset: usize,
|
||||
/// Set of expanded discussion IDs.
|
||||
pub expanded: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DiscussionTreeState {
|
||||
/// Move selection down.
|
||||
pub fn select_next(&mut self, total: usize) {
|
||||
if total > 0 && self.selected < total - 1 {
|
||||
self.selected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Toggle expand/collapse for the selected discussion.
|
||||
pub fn toggle_selected(&mut self, discussions: &[DiscussionNode]) {
|
||||
if let Some(d) = discussions.get(self.selected) {
|
||||
let id = &d.discussion_id;
|
||||
if self.expanded.contains(id) {
|
||||
self.expanded.remove(id);
|
||||
} else {
|
||||
self.expanded.insert(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a discussion is expanded.
|
||||
#[must_use]
|
||||
pub fn is_expanded(&self, discussion_id: &str) -> bool {
|
||||
self.expanded.contains(discussion_id)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Color scheme for discussion tree rendering.
|
||||
pub struct DiscussionTreeColors {
|
||||
/// Author name foreground.
|
||||
pub author_fg: PackedRgba,
|
||||
/// Timestamp foreground.
|
||||
pub timestamp_fg: PackedRgba,
|
||||
/// Note body foreground.
|
||||
pub body_fg: PackedRgba,
|
||||
/// System note foreground (muted).
|
||||
pub system_fg: PackedRgba,
|
||||
/// Diff file path foreground.
|
||||
pub diff_path_fg: PackedRgba,
|
||||
/// Resolved indicator foreground.
|
||||
pub resolved_fg: PackedRgba,
|
||||
/// Tree guide characters.
|
||||
pub guide_fg: PackedRgba,
|
||||
/// Selected discussion background.
|
||||
pub selected_fg: PackedRgba,
|
||||
/// Selected discussion background.
|
||||
pub selected_bg: PackedRgba,
|
||||
/// Expand/collapse indicator.
|
||||
pub expand_fg: PackedRgba,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relative time formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a timestamp as a human-readable relative time string.
|
||||
///
|
||||
/// Uses the provided `Clock` for deterministic rendering in tests.
|
||||
#[must_use]
|
||||
pub fn format_relative_time(epoch_ms: i64, clock: &dyn Clock) -> String {
|
||||
let now_ms = clock.now_ms();
|
||||
let diff_ms = now_ms.saturating_sub(epoch_ms);
|
||||
|
||||
if diff_ms < 0 {
|
||||
return "just now".to_string();
|
||||
}
|
||||
|
||||
let seconds = diff_ms / 1_000;
|
||||
let minutes = seconds / 60;
|
||||
let hours = minutes / 60;
|
||||
let days = hours / 24;
|
||||
let weeks = days / 7;
|
||||
let months = days / 30;
|
||||
|
||||
if seconds < 60 {
|
||||
"just now".to_string()
|
||||
} else if minutes < 60 {
|
||||
format!("{minutes}m ago")
|
||||
} else if hours < 24 {
|
||||
format!("{hours}h ago")
|
||||
} else if days < 7 {
|
||||
format!("{days}d ago")
|
||||
} else if weeks < 4 {
|
||||
format!("{weeks}w ago")
|
||||
} else {
|
||||
format!("{months}mo ago")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Maximum indent depth for nested content (notes within discussions).
|
||||
const INDENT: u16 = 4;
|
||||
|
||||
/// Render a discussion tree within the given area.
|
||||
///
|
||||
/// Returns the number of rows consumed.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// > alice (3 notes) [resolved] <- collapsed discussion
|
||||
/// First line of note body preview...
|
||||
///
|
||||
/// v bob (2 notes) <- expanded discussion
|
||||
/// | bob · 3h ago
|
||||
/// | This is the first note body...
|
||||
/// |
|
||||
/// | alice · 1h ago <- diff note
|
||||
/// | diff src/auth.rs:42
|
||||
/// | Code review comment about...
|
||||
/// ```
|
||||
pub fn render_discussion_tree(
|
||||
frame: &mut Frame<'_>,
|
||||
discussions: &[DiscussionNode],
|
||||
state: &DiscussionTreeState,
|
||||
area: Rect,
|
||||
colors: &DiscussionTreeColors,
|
||||
clock: &dyn Clock,
|
||||
) -> u16 {
|
||||
if discussions.is_empty() || area.height == 0 || area.width < 15 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
let y_max = area.y.saturating_add(area.height);
|
||||
|
||||
// Pre-compute all visual rows to support scroll offset.
|
||||
let rows = compute_visual_rows_with_clock(
|
||||
discussions,
|
||||
state,
|
||||
max_x.saturating_sub(area.x) as usize,
|
||||
clock,
|
||||
);
|
||||
|
||||
// Apply scroll offset.
|
||||
let visible_rows = rows
|
||||
.iter()
|
||||
.skip(state.scroll_offset)
|
||||
.take(area.height as usize);
|
||||
|
||||
for row in visible_rows {
|
||||
if y >= y_max {
|
||||
break;
|
||||
}
|
||||
|
||||
match row {
|
||||
VisualRow::DiscussionHeader {
|
||||
disc_idx,
|
||||
expanded,
|
||||
summary,
|
||||
preview,
|
||||
} => {
|
||||
let is_selected = *disc_idx == state.selected;
|
||||
|
||||
// Background fill for selected.
|
||||
if is_selected {
|
||||
frame.draw_rect_filled(
|
||||
Rect::new(area.x, y, area.width, 1),
|
||||
Cell {
|
||||
fg: colors.selected_fg,
|
||||
bg: colors.selected_bg,
|
||||
..Cell::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let style = if is_selected {
|
||||
Cell {
|
||||
fg: colors.selected_fg,
|
||||
bg: colors.selected_bg,
|
||||
..Cell::default()
|
||||
}
|
||||
} else {
|
||||
Cell {
|
||||
fg: colors.author_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
};
|
||||
|
||||
let indicator = if *expanded { "v " } else { "> " };
|
||||
let mut x = frame.print_text_clipped(area.x, y, indicator, style, max_x);
|
||||
x = frame.print_text_clipped(x, y, summary, style, max_x);
|
||||
|
||||
// Show preview on same line for collapsed.
|
||||
if !expanded && !preview.is_empty() {
|
||||
let preview_style = if is_selected {
|
||||
style
|
||||
} else {
|
||||
Cell {
|
||||
fg: colors.timestamp_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
};
|
||||
x = frame.print_text_clipped(x, y, " - ", preview_style, max_x);
|
||||
let _ = frame.print_text_clipped(x, y, preview, preview_style, max_x);
|
||||
}
|
||||
|
||||
y += 1;
|
||||
}
|
||||
|
||||
VisualRow::NoteHeader {
|
||||
author,
|
||||
relative_time,
|
||||
is_system,
|
||||
..
|
||||
} => {
|
||||
let style = if *is_system {
|
||||
Cell {
|
||||
fg: colors.system_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
} else {
|
||||
Cell {
|
||||
fg: colors.author_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
};
|
||||
|
||||
let guide_style = Cell {
|
||||
fg: colors.guide_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let indent_x = area.x.saturating_add(INDENT);
|
||||
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
|
||||
x = frame.print_text_clipped(x.max(indent_x), y, author, style, max_x);
|
||||
|
||||
let time_style = Cell {
|
||||
fg: colors.timestamp_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
x = frame.print_text_clipped(x, y, " · ", time_style, max_x);
|
||||
let _ = frame.print_text_clipped(x, y, relative_time, time_style, max_x);
|
||||
|
||||
y += 1;
|
||||
}
|
||||
|
||||
VisualRow::DiffPath { file_path, line } => {
|
||||
let guide_style = Cell {
|
||||
fg: colors.guide_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let path_style = Cell {
|
||||
fg: colors.diff_path_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
|
||||
let indent_x = area.x.saturating_add(INDENT);
|
||||
x = x.max(indent_x);
|
||||
|
||||
let location = match line {
|
||||
Some(l) => format!("diff {file_path}:{l}"),
|
||||
None => format!("diff {file_path}"),
|
||||
};
|
||||
let _ = frame.print_text_clipped(x, y, &location, path_style, max_x);
|
||||
|
||||
y += 1;
|
||||
}
|
||||
|
||||
VisualRow::BodyLine { text, is_system } => {
|
||||
let guide_style = Cell {
|
||||
fg: colors.guide_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let body_style = if *is_system {
|
||||
Cell {
|
||||
fg: colors.system_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
} else {
|
||||
Cell {
|
||||
fg: colors.body_fg,
|
||||
..Cell::default()
|
||||
}
|
||||
};
|
||||
|
||||
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
|
||||
let indent_x = area.x.saturating_add(INDENT);
|
||||
x = x.max(indent_x);
|
||||
let _ = frame.print_text_clipped(x, y, text, body_style, max_x);
|
||||
|
||||
y += 1;
|
||||
}
|
||||
|
||||
VisualRow::Separator => {
|
||||
let guide_style = Cell {
|
||||
fg: colors.guide_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x, y, " |", guide_style, max_x);
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
y.saturating_sub(area.y)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual row computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pre-computed visual row for the discussion tree.
|
||||
///
|
||||
/// We flatten the tree into rows to support scroll offset correctly.
|
||||
#[derive(Debug)]
|
||||
enum VisualRow {
|
||||
/// Discussion header (collapsed or expanded).
|
||||
DiscussionHeader {
|
||||
disc_idx: usize,
|
||||
expanded: bool,
|
||||
summary: String,
|
||||
preview: String,
|
||||
},
|
||||
/// Note author + timestamp line.
|
||||
NoteHeader {
|
||||
author: String,
|
||||
relative_time: String,
|
||||
is_system: bool,
|
||||
},
|
||||
/// Diff note file path line.
|
||||
DiffPath {
|
||||
file_path: String,
|
||||
line: Option<i64>,
|
||||
},
|
||||
/// Note body text line.
|
||||
BodyLine { text: String, is_system: bool },
|
||||
/// Blank separator between notes.
|
||||
Separator,
|
||||
}
|
||||
|
||||
/// Maximum body lines shown per note to prevent one huge note from
|
||||
/// consuming the entire viewport.
|
||||
const MAX_BODY_LINES: usize = 10;
|
||||
|
||||
/// Compute visual rows with relative timestamps from the clock.
|
||||
fn compute_visual_rows_with_clock(
|
||||
discussions: &[DiscussionNode],
|
||||
state: &DiscussionTreeState,
|
||||
available_width: usize,
|
||||
clock: &dyn Clock,
|
||||
) -> Vec<VisualRow> {
|
||||
let mut rows = Vec::new();
|
||||
let preview_max = available_width.saturating_sub(40).max(20);
|
||||
|
||||
for (idx, disc) in discussions.iter().enumerate() {
|
||||
let expanded = state.is_expanded(&disc.discussion_id);
|
||||
|
||||
rows.push(VisualRow::DiscussionHeader {
|
||||
disc_idx: idx,
|
||||
expanded,
|
||||
summary: disc.summary(),
|
||||
preview: if expanded {
|
||||
String::new()
|
||||
} else {
|
||||
disc.preview(preview_max)
|
||||
},
|
||||
});
|
||||
|
||||
if expanded {
|
||||
for (note_idx, note) in disc.notes.iter().enumerate() {
|
||||
if note_idx > 0 {
|
||||
rows.push(VisualRow::Separator);
|
||||
}
|
||||
|
||||
rows.push(VisualRow::NoteHeader {
|
||||
author: note.author.clone(),
|
||||
relative_time: format_relative_time(note.created_at, clock),
|
||||
is_system: note.is_system,
|
||||
});
|
||||
|
||||
if note.is_diff_note
|
||||
&& let Some(ref path) = note.diff_file_path
|
||||
{
|
||||
rows.push(VisualRow::DiffPath {
|
||||
file_path: path.clone(),
|
||||
line: note.diff_new_line,
|
||||
});
|
||||
}
|
||||
|
||||
let sanitized = sanitize_for_terminal(¬e.body, UrlPolicy::Strip);
|
||||
for (line_idx, line) in sanitized.lines().enumerate() {
|
||||
if line_idx >= MAX_BODY_LINES {
|
||||
rows.push(VisualRow::BodyLine {
|
||||
text: "...".to_string(),
|
||||
is_system: note.is_system,
|
||||
});
|
||||
break;
|
||||
}
|
||||
rows.push(VisualRow::BodyLine {
|
||||
text: line.to_string(),
|
||||
is_system: note.is_system,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::FakeClock;
|
||||
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_note(author: &str, body: &str, created_at: i64) -> NoteNode {
|
||||
NoteNode {
|
||||
author: author.into(),
|
||||
body: body.into(),
|
||||
created_at,
|
||||
is_system: false,
|
||||
is_diff_note: false,
|
||||
diff_file_path: None,
|
||||
diff_new_line: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn system_note(body: &str, created_at: i64) -> NoteNode {
|
||||
NoteNode {
|
||||
author: "system".into(),
|
||||
body: body.into(),
|
||||
created_at,
|
||||
is_system: true,
|
||||
is_diff_note: false,
|
||||
diff_file_path: None,
|
||||
diff_new_line: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_note(author: &str, body: &str, path: &str, line: i64, created_at: i64) -> NoteNode {
|
||||
NoteNode {
|
||||
author: author.into(),
|
||||
body: body.into(),
|
||||
created_at,
|
||||
is_system: false,
|
||||
is_diff_note: true,
|
||||
diff_file_path: Some(path.into()),
|
||||
diff_new_line: Some(line),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_discussions() -> Vec<DiscussionNode> {
|
||||
vec![
|
||||
DiscussionNode {
|
||||
discussion_id: "disc-1".into(),
|
||||
notes: vec![
|
||||
sample_note("alice", "This looks good overall", 1_700_000_000_000),
|
||||
sample_note("bob", "Agreed, but one concern", 1_700_000_060_000),
|
||||
],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
},
|
||||
DiscussionNode {
|
||||
discussion_id: "disc-2".into(),
|
||||
notes: vec![diff_note(
|
||||
"charlie",
|
||||
"This function needs error handling",
|
||||
"src/auth.rs",
|
||||
42,
|
||||
1_700_000_120_000,
|
||||
)],
|
||||
resolvable: true,
|
||||
resolved: true,
|
||||
},
|
||||
DiscussionNode {
|
||||
discussion_id: "disc-3".into(),
|
||||
notes: vec![system_note("changed the description", 1_700_000_180_000)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn test_colors() -> DiscussionTreeColors {
|
||||
DiscussionTreeColors {
|
||||
author_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
timestamp_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||
body_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
system_fg: PackedRgba::rgb(0x6F, 0x6E, 0x69),
|
||||
diff_path_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
|
||||
resolved_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
|
||||
guide_fg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||
expand_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
|
||||
}
|
||||
}
|
||||
|
||||
// Clock set to 1h after the last sample note.
|
||||
fn test_clock() -> FakeClock {
|
||||
FakeClock::from_ms(1_700_000_180_000 + 3_600_000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time_just_now() {
|
||||
let clock = FakeClock::from_ms(1_000_000);
|
||||
assert_eq!(format_relative_time(1_000_000, &clock), "just now");
|
||||
assert_eq!(format_relative_time(999_990, &clock), "just now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time_minutes() {
|
||||
let clock = FakeClock::from_ms(1_000_000 + 5 * 60 * 1_000);
|
||||
assert_eq!(format_relative_time(1_000_000, &clock), "5m ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time_hours() {
|
||||
let clock = FakeClock::from_ms(1_000_000 + 3 * 3_600 * 1_000);
|
||||
assert_eq!(format_relative_time(1_000_000, &clock), "3h ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time_days() {
|
||||
let clock = FakeClock::from_ms(1_000_000 + 2 * 86_400 * 1_000);
|
||||
assert_eq!(format_relative_time(1_000_000, &clock), "2d ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time_weeks() {
|
||||
let clock = FakeClock::from_ms(1_000_000 + 14 * 86_400 * 1_000);
|
||||
assert_eq!(format_relative_time(1_000_000, &clock), "2w ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time_months() {
|
||||
let clock = FakeClock::from_ms(1_000_000 + 60 * 86_400 * 1_000);
|
||||
assert_eq!(format_relative_time(1_000_000, &clock), "2mo ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discussion_node_summary() {
|
||||
let disc = DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![
|
||||
sample_note("alice", "body", 0),
|
||||
sample_note("bob", "reply", 1000),
|
||||
],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
};
|
||||
assert_eq!(disc.summary(), "alice (2 notes)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discussion_node_summary_single() {
|
||||
let disc = DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![sample_note("alice", "body", 0)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
};
|
||||
assert_eq!(disc.summary(), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discussion_node_summary_resolved() {
|
||||
let disc = DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![sample_note("alice", "body", 0)],
|
||||
resolvable: true,
|
||||
resolved: true,
|
||||
};
|
||||
assert_eq!(disc.summary(), "alice [resolved]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discussion_node_preview() {
|
||||
let disc = DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![sample_note("alice", "First line\nSecond line", 0)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
};
|
||||
assert_eq!(disc.preview(50), "First line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discussion_tree_state_navigation() {
|
||||
let mut state = DiscussionTreeState::default();
|
||||
assert_eq!(state.selected, 0);
|
||||
|
||||
state.select_next(3);
|
||||
assert_eq!(state.selected, 1);
|
||||
|
||||
state.select_next(3);
|
||||
assert_eq!(state.selected, 2);
|
||||
|
||||
state.select_next(3);
|
||||
assert_eq!(state.selected, 2);
|
||||
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 1);
|
||||
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 0);
|
||||
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discussion_tree_state_toggle() {
|
||||
let discussions = sample_discussions();
|
||||
let mut state = DiscussionTreeState::default();
|
||||
|
||||
assert!(!state.is_expanded("disc-1"));
|
||||
|
||||
state.toggle_selected(&discussions);
|
||||
assert!(state.is_expanded("disc-1"));
|
||||
|
||||
state.toggle_selected(&discussions);
|
||||
assert!(!state.is_expanded("disc-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_discussion_tree_collapsed_no_panic() {
|
||||
with_frame!(80, 20, |frame| {
|
||||
let discussions = sample_discussions();
|
||||
let state = DiscussionTreeState::default();
|
||||
let clock = test_clock();
|
||||
let rows = render_discussion_tree(
|
||||
&mut frame,
|
||||
&discussions,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 20),
|
||||
&test_colors(),
|
||||
&clock,
|
||||
);
|
||||
// 3 discussions, all collapsed = 3 rows.
|
||||
assert_eq!(rows, 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_discussion_tree_expanded_no_panic() {
|
||||
with_frame!(80, 30, |frame| {
|
||||
let discussions = sample_discussions();
|
||||
let mut state = DiscussionTreeState::default();
|
||||
state.expanded.insert("disc-1".into());
|
||||
let clock = test_clock();
|
||||
let rows = render_discussion_tree(
|
||||
&mut frame,
|
||||
&discussions,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 30),
|
||||
&test_colors(),
|
||||
&clock,
|
||||
);
|
||||
// disc-1 expanded: header + 2 notes (each: header + body line) + separator between
|
||||
// = 1 + (1+1) + 1 + (1+1) = 6 rows from disc-1
|
||||
// disc-2 collapsed: 1 row
|
||||
// disc-3 collapsed: 1 row
|
||||
// Total: 8
|
||||
assert!(rows >= 6); // At least disc-1 content + 2 collapsed.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_discussion_tree_empty() {
|
||||
with_frame!(80, 20, |frame| {
|
||||
let state = DiscussionTreeState::default();
|
||||
let clock = test_clock();
|
||||
let rows = render_discussion_tree(
|
||||
&mut frame,
|
||||
&[],
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 20),
|
||||
&test_colors(),
|
||||
&clock,
|
||||
);
|
||||
assert_eq!(rows, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_discussion_tree_tiny_area() {
|
||||
with_frame!(10, 2, |frame| {
|
||||
let discussions = sample_discussions();
|
||||
let state = DiscussionTreeState::default();
|
||||
let clock = test_clock();
|
||||
let rows = render_discussion_tree(
|
||||
&mut frame,
|
||||
&discussions,
|
||||
&state,
|
||||
Rect::new(0, 0, 10, 2),
|
||||
&test_colors(),
|
||||
&clock,
|
||||
);
|
||||
// Too narrow (< 15), should bail.
|
||||
assert_eq!(rows, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_discussion_tree_with_diff_note() {
|
||||
with_frame!(80, 30, |frame| {
|
||||
let discussions = vec![DiscussionNode {
|
||||
discussion_id: "diff-disc".into(),
|
||||
notes: vec![diff_note(
|
||||
"reviewer",
|
||||
"Add error handling here",
|
||||
"src/main.rs",
|
||||
42,
|
||||
1_700_000_000_000,
|
||||
)],
|
||||
resolvable: true,
|
||||
resolved: false,
|
||||
}];
|
||||
let mut state = DiscussionTreeState::default();
|
||||
state.expanded.insert("diff-disc".into());
|
||||
let clock = test_clock();
|
||||
let rows = render_discussion_tree(
|
||||
&mut frame,
|
||||
&discussions,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 30),
|
||||
&test_colors(),
|
||||
&clock,
|
||||
);
|
||||
// header + note header + diff path + body line = 4
|
||||
assert!(rows >= 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_discussion_tree_system_note() {
|
||||
with_frame!(80, 20, |frame| {
|
||||
let discussions = vec![DiscussionNode {
|
||||
discussion_id: "sys-disc".into(),
|
||||
notes: vec![system_note("changed the description", 1_700_000_000_000)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
}];
|
||||
let mut state = DiscussionTreeState::default();
|
||||
state.expanded.insert("sys-disc".into());
|
||||
let clock = test_clock();
|
||||
let rows = render_discussion_tree(
|
||||
&mut frame,
|
||||
&discussions,
|
||||
&state,
|
||||
Rect::new(0, 0, 80, 20),
|
||||
&test_colors(),
|
||||
&clock,
|
||||
);
|
||||
assert!(rows >= 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_visual_rows_collapsed() {
|
||||
let discussions = sample_discussions();
|
||||
let state = DiscussionTreeState::default();
|
||||
let clock = test_clock();
|
||||
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
|
||||
|
||||
// 3 collapsed headers.
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert!(matches!(
|
||||
rows[0],
|
||||
VisualRow::DiscussionHeader {
|
||||
expanded: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_visual_rows_expanded() {
|
||||
let discussions = sample_discussions();
|
||||
let mut state = DiscussionTreeState::default();
|
||||
state.expanded.insert("disc-1".into());
|
||||
let clock = test_clock();
|
||||
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
|
||||
|
||||
// disc-1: header + note1 (header + body) + separator + note2 (header + body) = 6
|
||||
// disc-2: 1 header
|
||||
// disc-3: 1 header
|
||||
// Total: 8
|
||||
assert!(rows.len() >= 6);
|
||||
assert!(matches!(
|
||||
rows[0],
|
||||
VisualRow::DiscussionHeader { expanded: true, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_body_truncation() {
|
||||
let long_body = (0..20)
|
||||
.map(|i| format!("Line {i} of a very long discussion note"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let discussions = vec![DiscussionNode {
|
||||
discussion_id: "long".into(),
|
||||
notes: vec![sample_note("alice", &long_body, 1_700_000_000_000)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
}];
|
||||
let mut state = DiscussionTreeState::default();
|
||||
state.expanded.insert("long".into());
|
||||
let clock = test_clock();
|
||||
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
|
||||
|
||||
// Header + note header + MAX_BODY_LINES + 1 ("...") = 1 + 1 + 10 + 1 = 13
|
||||
let body_lines: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|r| matches!(r, VisualRow::BodyLine { .. }))
|
||||
.collect();
|
||||
// Should cap at MAX_BODY_LINES + 1 (for the "..." truncation line).
|
||||
assert!(body_lines.len() <= MAX_BODY_LINES + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_multibyte_utf8_no_panic() {
|
||||
// Emoji are 4 bytes each. Truncating at a byte boundary that falls
|
||||
// inside a multi-byte char must not panic.
|
||||
let disc = DiscussionNode {
|
||||
discussion_id: "d-utf8".into(),
|
||||
notes: vec![sample_note(
|
||||
"alice",
|
||||
"Hello 🌍🌎🌏 world of emoji 🎉🎊🎈",
|
||||
0,
|
||||
)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
};
|
||||
// max_chars=10 would land inside the first emoji's bytes.
|
||||
let preview = disc.preview(10);
|
||||
assert!(preview.ends_with("..."));
|
||||
assert!(preview.len() <= 20); // char-bounded + "..."
|
||||
|
||||
// Edge: max_chars smaller than a single multi-byte char.
|
||||
let disc2 = DiscussionNode {
|
||||
discussion_id: "d-utf8-2".into(),
|
||||
notes: vec![sample_note("bob", "🌍🌎🌏", 0)],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
};
|
||||
let preview2 = disc2.preview(3);
|
||||
assert!(preview2.ends_with("..."));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
132
crates/lore-tui/src/view/common/error_toast.rs
Normal file
132
crates/lore-tui/src/view/common/error_toast.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! Floating error toast at bottom-right.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
/// Render a floating error toast at the bottom-right of the area.
|
||||
///
|
||||
/// The toast has a colored background and truncates long messages.
|
||||
pub fn render_error_toast(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
msg: &str,
|
||||
error_bg: PackedRgba,
|
||||
error_fg: PackedRgba,
|
||||
) {
|
||||
if area.height < 3 || area.width < 10 || msg.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toast dimensions: message + padding, max 60 chars or half screen.
|
||||
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;
|
||||
// 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} ")
|
||||
};
|
||||
let toast_width = toast_text.len() as u16;
|
||||
let toast_height: u16 = 1;
|
||||
|
||||
// Position: bottom-right with 1-cell margin.
|
||||
let x = area.right().saturating_sub(toast_width + 1);
|
||||
let y = area.bottom().saturating_sub(toast_height + 1);
|
||||
|
||||
let toast_rect = Rect::new(x, y, toast_width, toast_height);
|
||||
|
||||
// Fill background.
|
||||
let bg_cell = Cell {
|
||||
bg: error_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_rect_filled(toast_rect, bg_cell);
|
||||
|
||||
// Render text.
|
||||
let text_cell = Cell {
|
||||
fg: error_fg,
|
||||
bg: error_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, &toast_text, text_cell, area.right());
|
||||
}
|
||||
|
||||
#[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 white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn red_bg() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0x00, 0x00)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_toast_renders() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_error_toast(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
"Database is busy",
|
||||
red_bg(),
|
||||
white(),
|
||||
);
|
||||
|
||||
let y = 22u16;
|
||||
let has_content = (40..80u16).any(|x| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
});
|
||||
assert!(has_content, "Expected error toast at bottom-right");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_toast_empty_message_noop() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white());
|
||||
|
||||
let has_content = (0..80u16).any(|x| {
|
||||
(0..24u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(!has_content, "Empty message should render nothing");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_toast_truncates_long_message() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let long_msg = "A".repeat(200);
|
||||
render_error_toast(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&long_msg,
|
||||
red_bg(),
|
||||
white(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
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 田
|
||||
}
|
||||
}
|
||||
173
crates/lore-tui/src/view/common/help_overlay.rs
Normal file
173
crates/lore-tui/src/view/common/help_overlay.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Centered modal listing keybindings for the current screen.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::commands::CommandRegistry;
|
||||
use crate::message::Screen;
|
||||
|
||||
/// Render a centered help overlay listing keybindings for the current screen.
|
||||
///
|
||||
/// The overlay is a bordered modal that lists all commands from the
|
||||
/// registry that are available on the current screen.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_help_overlay(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
registry: &CommandRegistry,
|
||||
screen: &Screen,
|
||||
border_color: PackedRgba,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
if area.height < 5 || area.width < 20 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Overlay dimensions: 60% of screen, capped.
|
||||
let overlay_width = (area.width * 3 / 5).clamp(30, 70);
|
||||
let overlay_height = (area.height * 3 / 5).clamp(8, 30);
|
||||
|
||||
let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2;
|
||||
let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2;
|
||||
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
|
||||
|
||||
// Draw border.
|
||||
let border_cell = Cell {
|
||||
fg: border_color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_border(
|
||||
overlay_rect,
|
||||
ftui::render::drawing::BorderChars::ROUNDED,
|
||||
border_cell,
|
||||
);
|
||||
|
||||
// Title.
|
||||
let title = " Help (? to close) ";
|
||||
let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2;
|
||||
let title_cell = Cell {
|
||||
fg: border_color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right());
|
||||
|
||||
// Inner content area (inside border).
|
||||
let inner = Rect::new(
|
||||
overlay_x + 2,
|
||||
overlay_y + 1,
|
||||
overlay_width.saturating_sub(4),
|
||||
overlay_height.saturating_sub(2),
|
||||
);
|
||||
|
||||
// Get commands for this screen.
|
||||
let commands = registry.help_entries(screen);
|
||||
let visible_lines = inner.height as usize;
|
||||
|
||||
let key_cell = Cell {
|
||||
fg: text_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let desc_cell = Cell {
|
||||
fg: muted_color,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() {
|
||||
if i >= visible_lines {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + i as u16;
|
||||
|
||||
// Key binding label (left).
|
||||
let key_label = cmd
|
||||
.keybinding
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |kb| kb.display());
|
||||
let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right());
|
||||
|
||||
// Spacer + description (right).
|
||||
let desc_x = label_end.saturating_add(2);
|
||||
if desc_x < inner.right() {
|
||||
frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right());
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator if needed.
|
||||
if commands.len() > visible_lines + scroll_offset {
|
||||
let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len());
|
||||
let ind_x = inner.right().saturating_sub(indicator.len() as u16);
|
||||
let ind_y = overlay_rect.bottom().saturating_sub(1);
|
||||
frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::build_registry;
|
||||
use crate::message::Screen;
|
||||
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 white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn gray() -> PackedRgba {
|
||||
PackedRgba::rgb(0x80, 0x80, 0x80)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_overlay_renders_border() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let registry = build_registry();
|
||||
render_help_overlay(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
// The overlay should have non-empty cells in the center area.
|
||||
let has_content = (20..60u16).any(|x| {
|
||||
(8..16u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(has_content, "Expected help overlay in center area");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_overlay_tiny_terminal_noop() {
|
||||
with_frame!(15, 4, |frame| {
|
||||
let registry = build_registry();
|
||||
render_help_overlay(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 15, 4),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
179
crates/lore-tui/src/view/common/loading.rs
Normal file
179
crates/lore-tui/src/view/common/loading.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Loading spinner indicators (full-screen and corner).
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::state::LoadState;
|
||||
|
||||
/// Braille spinner frames for loading animation.
|
||||
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
/// Select spinner frame from tick count.
|
||||
#[must_use]
|
||||
pub(crate) fn spinner_char(tick: u64) -> char {
|
||||
SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
/// Render a loading indicator.
|
||||
///
|
||||
/// - `LoadingInitial`: centered full-screen spinner with "Loading..."
|
||||
/// - `Refreshing`: subtle spinner in top-right corner
|
||||
/// - Other states: no-op
|
||||
pub fn render_loading(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
load_state: &LoadState,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
tick: u64,
|
||||
) {
|
||||
match load_state {
|
||||
LoadState::LoadingInitial => {
|
||||
render_centered_spinner(frame, area, "Loading...", text_color, tick);
|
||||
}
|
||||
LoadState::Refreshing => {
|
||||
render_corner_spinner(frame, area, muted_color, tick);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a centered spinner with message.
|
||||
fn render_centered_spinner(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
message: &str,
|
||||
color: PackedRgba,
|
||||
tick: u64,
|
||||
) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let spinner = spinner_char(tick);
|
||||
let text = format!("{spinner} {message}");
|
||||
let text_len = text.len() as u16;
|
||||
|
||||
// Center horizontally and vertically.
|
||||
let x = area
|
||||
.x
|
||||
.saturating_add(area.width.saturating_sub(text_len) / 2);
|
||||
let y = area.y.saturating_add(area.height / 2);
|
||||
|
||||
let cell = Cell {
|
||||
fg: color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, &text, cell, area.right());
|
||||
}
|
||||
|
||||
/// Render a subtle spinner in the top-right corner.
|
||||
fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) {
|
||||
if area.width < 2 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let spinner = spinner_char(tick);
|
||||
let x = area.right().saturating_sub(2);
|
||||
let y = area.y;
|
||||
|
||||
let cell = Cell {
|
||||
fg: color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right());
|
||||
}
|
||||
|
||||
#[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 white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn gray() -> PackedRgba {
|
||||
PackedRgba::rgb(0x80, 0x80, 0x80)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_initial_renders_spinner() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_loading(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&LoadState::LoadingInitial,
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
let center_y = 12u16;
|
||||
let has_content = (0..80u16).any(|x| {
|
||||
let cell = frame.buffer.get(x, center_y).unwrap();
|
||||
!cell.is_empty()
|
||||
});
|
||||
assert!(has_content, "Expected loading spinner at center row");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_refreshing_renders_corner() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_loading(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&LoadState::Refreshing,
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
let cell = frame.buffer.get(78, 0).unwrap();
|
||||
assert!(!cell.is_empty(), "Expected corner spinner");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_idle_noop() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_loading(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&LoadState::Idle,
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
let has_content = (0..80u16).any(|x| {
|
||||
(0..24u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(!has_content, "Idle state should render nothing");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spinner_animation_cycles() {
|
||||
let frame0 = spinner_char(0);
|
||||
let frame1 = spinner_char(1);
|
||||
let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64);
|
||||
|
||||
assert_ne!(frame0, frame1, "Adjacent frames should differ");
|
||||
assert_eq!(frame0, frame_wrap, "Should wrap around");
|
||||
}
|
||||
}
|
||||
28
crates/lore-tui/src/view/common/mod.rs
Normal file
28
crates/lore-tui/src/view/common/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Common widgets shared across all TUI screens.
|
||||
//!
|
||||
//! Each widget is a pure rendering function — writes directly into the
|
||||
//! [`Frame`] buffer using ftui's `Draw` trait. No state mutation,
|
||||
//! no side effects.
|
||||
|
||||
mod breadcrumb;
|
||||
pub mod cross_ref;
|
||||
pub mod discussion_tree;
|
||||
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 cross_ref::{CrossRef, CrossRefColors, CrossRefKind, CrossRefState, render_cross_refs};
|
||||
pub use discussion_tree::{
|
||||
DiscussionNode, DiscussionTreeColors, DiscussionTreeState, NoteNode, format_relative_time,
|
||||
render_discussion_tree,
|
||||
};
|
||||
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;
|
||||
173
crates/lore-tui/src/view/common/status_bar.rs
Normal file
173
crates/lore-tui/src/view/common/status_bar.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Bottom status bar with key hints and mode indicator.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::commands::CommandRegistry;
|
||||
use crate::message::{InputMode, Screen};
|
||||
|
||||
/// Render the bottom status bar with key hints and mode indicator.
|
||||
///
|
||||
/// Layout: `[mode] ─── [key hints]`
|
||||
///
|
||||
/// Key hints are sourced from the [`CommandRegistry`] filtered to the
|
||||
/// current screen, showing only the most important bindings.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_status_bar(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
registry: &CommandRegistry,
|
||||
screen: &Screen,
|
||||
mode: &InputMode,
|
||||
bar_bg: PackedRgba,
|
||||
text_color: PackedRgba,
|
||||
accent_color: PackedRgba,
|
||||
) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill the bar background.
|
||||
let bg_cell = Cell {
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_rect_filled(area, bg_cell);
|
||||
|
||||
let mode_label = match mode {
|
||||
InputMode::Normal => "NORMAL",
|
||||
InputMode::Text => "INPUT",
|
||||
InputMode::Palette => "PALETTE",
|
||||
InputMode::GoPrefix { .. } => "g...",
|
||||
};
|
||||
|
||||
// Left side: mode indicator.
|
||||
let mode_cell = Cell {
|
||||
fg: accent_color,
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let mut x = frame.print_text_clipped(
|
||||
area.x.saturating_add(1),
|
||||
area.y,
|
||||
mode_label,
|
||||
mode_cell,
|
||||
area.right(),
|
||||
);
|
||||
|
||||
// Spacer.
|
||||
x = x.saturating_add(2);
|
||||
|
||||
// Right side: key hints from registry (formatted as "key:action").
|
||||
let hints = registry.status_hints(screen);
|
||||
let hint_cell = Cell {
|
||||
fg: text_color,
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let key_cell = Cell {
|
||||
fg: accent_color,
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
for hint in &hints {
|
||||
if x >= area.right().saturating_sub(1) {
|
||||
break;
|
||||
}
|
||||
// Split "q:quit" into key part and description part.
|
||||
if let Some((key_part, desc_part)) = hint.split_once(':') {
|
||||
x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right());
|
||||
x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right());
|
||||
x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right());
|
||||
} else {
|
||||
x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right());
|
||||
}
|
||||
x = x.saturating_add(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::build_registry;
|
||||
use crate::message::Screen;
|
||||
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 white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn gray() -> PackedRgba {
|
||||
PackedRgba::rgb(0x80, 0x80, 0x80)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_renders_mode() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let registry = build_registry();
|
||||
render_status_bar(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 1),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
|
||||
let n_cell = frame.buffer.get(1, 0).unwrap();
|
||||
assert_eq!(n_cell.content.as_char(), Some('N'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_text_mode() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let registry = build_registry();
|
||||
render_status_bar(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 1),
|
||||
®istry,
|
||||
&Screen::Search,
|
||||
&InputMode::Text,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
|
||||
let i_cell = frame.buffer.get(1, 0).unwrap();
|
||||
assert_eq!(i_cell.content.as_char(), Some('I'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_narrow_terminal() {
|
||||
with_frame!(4, 1, |frame| {
|
||||
let registry = build_registry();
|
||||
render_status_bar(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 4, 1),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
let cell = frame.buffer.get(0, 0).unwrap();
|
||||
assert!(cell.is_empty());
|
||||
});
|
||||
}
|
||||
}
|
||||
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)");
|
||||
});
|
||||
}
|
||||
}
|
||||
626
crates/lore-tui/src/view/issue_detail.rs
Normal file
626
crates/lore-tui/src/view/issue_detail.rs
Normal file
@@ -0,0 +1,626 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||
|
||||
//! Issue detail screen view.
|
||||
//!
|
||||
//! Composes metadata header, description, discussion tree, and
|
||||
//! cross-references into a scrollable detail layout. Supports
|
||||
//! progressive hydration: metadata renders immediately while
|
||||
//! discussions load async in Phase 2.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::clock::Clock;
|
||||
use crate::safety::{UrlPolicy, sanitize_for_terminal};
|
||||
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
|
||||
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
|
||||
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
||||
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
|
||||
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
|
||||
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg
|
||||
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn discussion_colors() -> DiscussionTreeColors {
|
||||
DiscussionTreeColors {
|
||||
author_fg: CYAN,
|
||||
timestamp_fg: TEXT_MUTED,
|
||||
body_fg: TEXT,
|
||||
system_fg: TEXT_MUTED,
|
||||
diff_path_fg: GREEN,
|
||||
resolved_fg: TEXT_MUTED,
|
||||
guide_fg: BORDER,
|
||||
selected_fg: SELECTED_FG,
|
||||
selected_bg: SELECTED_BG,
|
||||
expand_fg: ACCENT,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_ref_colors() -> CrossRefColors {
|
||||
CrossRefColors {
|
||||
kind_fg: ACCENT,
|
||||
label_fg: TEXT,
|
||||
muted_fg: TEXT_MUTED,
|
||||
selected_fg: SELECTED_FG,
|
||||
selected_bg: SELECTED_BG,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the full issue detail screen.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// Row 0: #42 Fix authentication flow (title bar)
|
||||
/// Row 1: opened | alice | backend, security (metadata row)
|
||||
/// Row 2: Milestone: v1.0 | Due: 2026-03-01 (optional)
|
||||
/// Row 3: ─────────────────────────────────── (separator)
|
||||
/// Row 4..N: Description text... (scrollable)
|
||||
/// ─────────────────────────────────── (separator)
|
||||
/// Discussions (3) (section header)
|
||||
/// ▶ alice: Fixed the login flow... (collapsed)
|
||||
/// ▼ bob: I think we should also... (expanded)
|
||||
/// bob: body line 1...
|
||||
/// ─────────────────────────────────── (separator)
|
||||
/// Cross References (section header)
|
||||
/// [MR] !10 Fix authentication MR
|
||||
/// ```
|
||||
pub fn render_issue_detail(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &IssueDetailState,
|
||||
area: Rect,
|
||||
clock: &dyn Clock,
|
||||
) {
|
||||
if area.height < 3 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref meta) = state.metadata else {
|
||||
// No metadata yet — the loading spinner handles this.
|
||||
return;
|
||||
};
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
|
||||
// --- Title bar ---
|
||||
y = render_title_bar(frame, meta, area.x, y, max_x);
|
||||
|
||||
// --- Metadata row ---
|
||||
y = render_metadata_row(frame, meta, area.x, y, max_x);
|
||||
|
||||
// --- Optional milestone / due date row ---
|
||||
if meta.milestone.is_some() || meta.due_date.is_some() {
|
||||
y = render_milestone_row(frame, meta, area.x, y, max_x);
|
||||
}
|
||||
|
||||
// --- Separator ---
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
if y >= bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remaining space is split between description, discussions, and cross-refs.
|
||||
let remaining = bottom.saturating_sub(y);
|
||||
|
||||
// Compute section heights based on content.
|
||||
let desc_lines = count_description_lines(meta, area.width);
|
||||
let disc_count = state.discussions.len();
|
||||
let xref_count = state.cross_refs.len();
|
||||
|
||||
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count);
|
||||
|
||||
// --- Description section ---
|
||||
if desc_h > 0 {
|
||||
let desc_area = Rect::new(area.x, y, area.width, desc_h);
|
||||
let is_focused = state.active_section == DetailSection::Description;
|
||||
render_description(frame, meta, state.description_scroll, desc_area, is_focused);
|
||||
y += desc_h;
|
||||
}
|
||||
|
||||
// --- Separator before discussions ---
|
||||
if (disc_h > 0 || xref_h > 0) && y < bottom {
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
}
|
||||
|
||||
// --- Discussions section ---
|
||||
if disc_h > 0 && y < bottom {
|
||||
let header_h = 1;
|
||||
let is_focused = state.active_section == DetailSection::Discussions;
|
||||
|
||||
// Section header.
|
||||
render_section_header(
|
||||
frame,
|
||||
&format!("Discussions ({})", state.discussions.len()),
|
||||
area.x,
|
||||
y,
|
||||
max_x,
|
||||
is_focused,
|
||||
);
|
||||
y += header_h;
|
||||
|
||||
if !state.discussions_loaded {
|
||||
// Still loading.
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "Loading discussions...", style, max_x);
|
||||
y += 1;
|
||||
} else if state.discussions.is_empty() {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "No discussions", style, max_x);
|
||||
y += 1;
|
||||
} else {
|
||||
let tree_height = disc_h.saturating_sub(header_h);
|
||||
if tree_height > 0 {
|
||||
let tree_area = Rect::new(area.x, y, area.width, tree_height);
|
||||
let rendered = render_discussion_tree(
|
||||
frame,
|
||||
&state.discussions,
|
||||
&state.tree_state,
|
||||
tree_area,
|
||||
&discussion_colors(),
|
||||
clock,
|
||||
);
|
||||
y += rendered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Separator before cross-refs ---
|
||||
if xref_h > 0 && y < bottom {
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
}
|
||||
|
||||
// --- Cross-references section ---
|
||||
if xref_h > 0 && y < bottom {
|
||||
let is_focused = state.active_section == DetailSection::CrossRefs;
|
||||
|
||||
render_section_header(
|
||||
frame,
|
||||
&format!("Cross References ({})", state.cross_refs.len()),
|
||||
area.x,
|
||||
y,
|
||||
max_x,
|
||||
is_focused,
|
||||
);
|
||||
y += 1;
|
||||
|
||||
if state.cross_refs.is_empty() {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "No cross-references", style, max_x);
|
||||
} else {
|
||||
let refs_height = xref_h.saturating_sub(1); // minus header
|
||||
if refs_height > 0 {
|
||||
let refs_area = Rect::new(area.x, y, area.width, refs_height);
|
||||
let _ = render_cross_refs(
|
||||
frame,
|
||||
&state.cross_refs,
|
||||
&state.cross_ref_state,
|
||||
refs_area,
|
||||
&cross_ref_colors(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the issue title bar: `#42 Fix authentication flow`
|
||||
fn render_title_bar(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let iid_text = format!("#{} ", meta.iid);
|
||||
let iid_style = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let title_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
|
||||
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
|
||||
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render the metadata row: `opened | alice | backend, security`
|
||||
fn render_metadata_row(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let state_fg = match meta.state.as_str() {
|
||||
"opened" => GREEN,
|
||||
"closed" => RED,
|
||||
_ => TEXT_MUTED,
|
||||
};
|
||||
let state_style = Cell {
|
||||
fg: state_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted_style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let author_style = Cell {
|
||||
fg: CYAN,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
|
||||
|
||||
if !meta.labels.is_empty() {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
|
||||
let labels_text = meta.labels.join(", ");
|
||||
let _ = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
|
||||
}
|
||||
|
||||
if !meta.assignees.is_empty() {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
|
||||
let assignees_text = format!("-> {}", meta.assignees.join(", "));
|
||||
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x);
|
||||
}
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render optional milestone / due date row.
|
||||
fn render_milestone_row(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let mut cx = x;
|
||||
|
||||
if let Some(ref ms) = meta.milestone {
|
||||
cx = frame.print_text_clipped(cx, y, "Milestone: ", muted, max_x);
|
||||
let val_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
cx = frame.print_text_clipped(cx, y, ms, val_style, max_x);
|
||||
}
|
||||
|
||||
if let Some(ref due) = meta.due_date {
|
||||
if cx > x {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
|
||||
}
|
||||
cx = frame.print_text_clipped(cx, y, "Due: ", muted, max_x);
|
||||
let val_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(cx, y, due, val_style, max_x);
|
||||
}
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render a horizontal separator line.
|
||||
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
|
||||
let sep_style = Cell {
|
||||
fg: BORDER,
|
||||
..Cell::default()
|
||||
};
|
||||
let line: String = "\u{2500}".repeat(width as usize);
|
||||
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render a section header with focus indicator.
|
||||
fn render_section_header(
|
||||
frame: &mut Frame<'_>,
|
||||
label: &str,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
is_focused: bool,
|
||||
) {
|
||||
if is_focused {
|
||||
let style = Cell {
|
||||
fg: SELECTED_FG,
|
||||
bg: SELECTED_BG,
|
||||
..Cell::default()
|
||||
};
|
||||
// Fill the row with selected background.
|
||||
frame.draw_rect_filled(Rect::new(x, y, max_x.saturating_sub(x), 1), style);
|
||||
let _ = frame.print_text_clipped(x, y, label, style, max_x);
|
||||
} else {
|
||||
let style = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(x, y, label, style, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the description section.
|
||||
fn render_description(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &IssueMetadata,
|
||||
scroll: usize,
|
||||
area: Rect,
|
||||
_is_focused: bool,
|
||||
) {
|
||||
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
|
||||
let lines: Vec<&str> = safe_desc.lines().collect();
|
||||
|
||||
let text_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
for (i, line) in lines
|
||||
.iter()
|
||||
.skip(scroll)
|
||||
.take(area.height as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let y = area.y + i as u16;
|
||||
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the number of visible description lines for layout allocation.
|
||||
fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize {
|
||||
if meta.description.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
// Rough estimate: count newlines. Proper word-wrap would need unicode width.
|
||||
meta.description.lines().count().max(1)
|
||||
}
|
||||
|
||||
/// Allocate vertical space between description, discussions, and cross-refs.
|
||||
///
|
||||
/// Priority: description gets min(content, 40%), discussions get most of the
|
||||
/// remaining space, cross-refs get a fixed portion at the bottom.
|
||||
fn allocate_sections(
|
||||
available: u16,
|
||||
desc_lines: usize,
|
||||
_disc_count: usize,
|
||||
xref_count: usize,
|
||||
) -> (u16, u16, u16) {
|
||||
if available == 0 {
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
let total = available as usize;
|
||||
|
||||
// Cross-refs: 1 header + count, max 25% of space.
|
||||
let xref_need = if xref_count > 0 {
|
||||
(1 + xref_count).min(total / 4)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let after_xref = total.saturating_sub(xref_need);
|
||||
|
||||
// Description: up to 40% of remaining, but at least the content lines.
|
||||
let desc_max = after_xref * 2 / 5;
|
||||
let desc_alloc = desc_lines.min(desc_max).min(after_xref);
|
||||
|
||||
// Discussions: everything else.
|
||||
let disc_alloc = after_xref.saturating_sub(desc_alloc);
|
||||
|
||||
(desc_alloc as u16, disc_alloc as u16, xref_need as u16)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::FakeClock;
|
||||
use crate::message::EntityKey;
|
||||
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
|
||||
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
|
||||
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
|
||||
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_metadata() -> IssueMetadata {
|
||||
IssueMetadata {
|
||||
iid: 42,
|
||||
project_path: "group/project".into(),
|
||||
title: "Fix authentication flow".into(),
|
||||
description: "The login page has a bug.\nSteps to reproduce:\n1. Go to /login\n2. Enter credentials\n3. Click submit".into(),
|
||||
state: "opened".into(),
|
||||
author: "alice".into(),
|
||||
assignees: vec!["bob".into()],
|
||||
labels: vec!["backend".into(), "security".into()],
|
||||
milestone: Some("v1.0".into()),
|
||||
due_date: Some("2026-03-01".into()),
|
||||
created_at: 1_700_000_000_000,
|
||||
updated_at: 1_700_000_060_000,
|
||||
web_url: "https://gitlab.com/group/project/-/issues/42".into(),
|
||||
discussion_count: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_state_with_metadata() -> IssueDetailState {
|
||||
let mut state = IssueDetailState::default();
|
||||
state.load_new(EntityKey::issue(1, 42));
|
||||
state.apply_metadata(IssueDetailData {
|
||||
metadata: sample_metadata(),
|
||||
cross_refs: vec![CrossRef {
|
||||
kind: CrossRefKind::ClosingMr,
|
||||
entity_key: EntityKey::mr(1, 10),
|
||||
label: "Fix auth MR".into(),
|
||||
navigable: true,
|
||||
}],
|
||||
});
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_no_metadata_no_panic() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = IssueDetailState::default();
|
||||
let clock = FakeClock::from_ms(1_700_000_000_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_with_metadata_no_panic() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_tiny_area() {
|
||||
with_frame!(5, 2, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 5, 2), &clock);
|
||||
// Should bail early, no panic.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_with_discussions() {
|
||||
with_frame!(80, 40, |frame| {
|
||||
let mut state = sample_state_with_metadata();
|
||||
state.apply_discussions(vec![DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![NoteNode {
|
||||
author: "alice".into(),
|
||||
body: "I found the bug".into(),
|
||||
created_at: 1_700_000_000_000,
|
||||
is_system: false,
|
||||
is_diff_note: false,
|
||||
diff_file_path: None,
|
||||
diff_new_line: None,
|
||||
}],
|
||||
resolvable: false,
|
||||
resolved: false,
|
||||
}]);
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 40), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_discussions_loading() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
// discussions_loaded is false by default after load_new.
|
||||
assert!(!state.discussions_loaded);
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_issue_detail_narrow_terminal() {
|
||||
with_frame!(30, 10, |frame| {
|
||||
let state = sample_state_with_metadata();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_empty() {
|
||||
assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_balanced() {
|
||||
let (d, disc, x) = allocate_sections(20, 5, 3, 2);
|
||||
assert!(d > 0);
|
||||
assert!(disc > 0);
|
||||
assert!(x > 0);
|
||||
assert_eq!(d + disc + x, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_no_xrefs() {
|
||||
let (d, disc, x) = allocate_sections(20, 5, 3, 0);
|
||||
assert_eq!(x, 0);
|
||||
assert_eq!(d + disc, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allocate_sections_no_discussions() {
|
||||
let (d, disc, x) = allocate_sections(20, 5, 0, 2);
|
||||
assert!(d > 0);
|
||||
assert_eq!(d + disc + x, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_description_lines() {
|
||||
let meta = sample_metadata();
|
||||
let lines = count_description_lines(&meta, 80);
|
||||
assert_eq!(lines, 5); // 5 lines in the sample description
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_description_lines_empty() {
|
||||
let mut meta = sample_metadata();
|
||||
meta.description = String::new();
|
||||
assert_eq!(count_description_lines(&meta, 80), 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);
|
||||
}
|
||||
}
|
||||
202
crates/lore-tui/src/view/mod.rs
Normal file
202
crates/lore-tui/src/view/mod.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
#![allow(dead_code)] // Phase 1: screen content renders added in Phase 2+
|
||||
|
||||
//! Top-level view dispatch for the lore TUI.
|
||||
//!
|
||||
//! [`render_screen`] is the entry point called from `LoreApp::view()`.
|
||||
//! It composes the layout: breadcrumb bar, screen content area, status
|
||||
//! bar, and optional overlays (help, error toast).
|
||||
|
||||
pub mod common;
|
||||
pub mod dashboard;
|
||||
pub mod issue_detail;
|
||||
pub mod issue_list;
|
||||
pub mod mr_detail;
|
||||
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_detail::render_issue_detail;
|
||||
use issue_list::render_issue_list;
|
||||
use mr_detail::render_mr_detail;
|
||||
use mr_list::render_mr_list;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
||||
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level view dispatch: composes breadcrumb + content + status bar + overlays.
|
||||
///
|
||||
/// Called from `LoreApp::view()`. The layout is:
|
||||
/// ```text
|
||||
/// +-----------------------------------+
|
||||
/// | Breadcrumb (1 row) |
|
||||
/// +-----------------------------------+
|
||||
/// | |
|
||||
/// | Screen content (fill) |
|
||||
/// | |
|
||||
/// +-----------------------------------+
|
||||
/// | Status bar (1 row) |
|
||||
/// +-----------------------------------+
|
||||
/// ```
|
||||
///
|
||||
/// Overlays (help, error toast) render on top of existing content.
|
||||
pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
|
||||
let bounds = frame.bounds();
|
||||
if bounds.width < 3 || bounds.height < 3 {
|
||||
return; // Terminal too small to render anything useful.
|
||||
}
|
||||
|
||||
// Split vertically: breadcrumb (1) | content (fill) | status bar (1).
|
||||
let regions = Flex::vertical()
|
||||
.constraints([
|
||||
Constraint::Fixed(1), // breadcrumb
|
||||
Constraint::Fill, // content
|
||||
Constraint::Fixed(1), // status bar
|
||||
])
|
||||
.split(bounds);
|
||||
|
||||
let breadcrumb_area = regions[0];
|
||||
let content_area = regions[1];
|
||||
let status_area = regions[2];
|
||||
|
||||
let screen = app.navigation.current();
|
||||
|
||||
// --- Breadcrumb ---
|
||||
render_breadcrumb(frame, breadcrumb_area, &app.navigation, TEXT, TEXT_MUTED);
|
||||
|
||||
// --- Screen content ---
|
||||
let load_state = app.state.load_state.get(screen);
|
||||
// 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 (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);
|
||||
} else if matches!(screen, Screen::IssueDetail(_)) {
|
||||
render_issue_detail(frame, &app.state.issue_detail, content_area, &*app.clock);
|
||||
} else if matches!(screen, Screen::MrDetail(_)) {
|
||||
render_mr_detail(frame, &app.state.mr_detail, content_area, &*app.clock);
|
||||
}
|
||||
|
||||
// --- Status bar ---
|
||||
render_status_bar(
|
||||
frame,
|
||||
status_area,
|
||||
&app.command_registry,
|
||||
screen,
|
||||
&app.input_mode,
|
||||
BG_SURFACE,
|
||||
TEXT,
|
||||
ACCENT,
|
||||
);
|
||||
|
||||
// --- Overlays (render last, on top of everything) ---
|
||||
|
||||
// Error toast.
|
||||
if let Some(ref error_msg) = app.state.error_toast {
|
||||
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
|
||||
}
|
||||
|
||||
// Help overlay.
|
||||
if app.state.show_help {
|
||||
render_help_overlay(
|
||||
frame,
|
||||
bounds,
|
||||
&app.command_registry,
|
||||
screen,
|
||||
BORDER,
|
||||
TEXT,
|
||||
TEXT_MUTED,
|
||||
0, // scroll_offset — tracked in future phase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::LoreApp;
|
||||
use ftui::render::grapheme_pool::GraphemePool;
|
||||
|
||||
macro_rules! with_frame {
|
||||
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||
let mut pool = GraphemePool::new();
|
||||
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||
$body
|
||||
}};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_does_not_panic() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let app = LoreApp::new();
|
||||
render_screen(&mut frame, &app);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_tiny_terminal_noop() {
|
||||
with_frame!(2, 2, |frame| {
|
||||
let app = LoreApp::new();
|
||||
render_screen(&mut frame, &app);
|
||||
// Should not panic — early return for tiny terminals.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_with_error_toast() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut app = LoreApp::new();
|
||||
app.state.set_error("test error".into());
|
||||
render_screen(&mut frame, &app);
|
||||
// Should render without panicking.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_with_help_overlay() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut app = LoreApp::new();
|
||||
app.state.show_help = true;
|
||||
render_screen(&mut frame, &app);
|
||||
// Should render without panicking.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_narrow_terminal() {
|
||||
with_frame!(20, 5, |frame| {
|
||||
let app = LoreApp::new();
|
||||
render_screen(&mut frame, &app);
|
||||
});
|
||||
}
|
||||
}
|
||||
635
crates/lore-tui/src/view/mr_detail.rs
Normal file
635
crates/lore-tui/src/view/mr_detail.rs
Normal file
@@ -0,0 +1,635 @@
|
||||
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||
|
||||
//! Merge request detail screen view.
|
||||
//!
|
||||
//! Composes metadata header, tab bar (Overview / Files / Discussions),
|
||||
//! and tab content. Supports progressive hydration: metadata + file
|
||||
//! changes render immediately while discussions load async.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::clock::Clock;
|
||||
use crate::safety::{UrlPolicy, sanitize_for_terminal};
|
||||
use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab};
|
||||
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
|
||||
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (Flexoki palette)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
|
||||
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
|
||||
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C);
|
||||
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
|
||||
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29);
|
||||
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F);
|
||||
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
|
||||
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
|
||||
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F);
|
||||
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn discussion_colors() -> DiscussionTreeColors {
|
||||
DiscussionTreeColors {
|
||||
author_fg: CYAN,
|
||||
timestamp_fg: TEXT_MUTED,
|
||||
body_fg: TEXT,
|
||||
system_fg: TEXT_MUTED,
|
||||
diff_path_fg: GREEN,
|
||||
resolved_fg: TEXT_MUTED,
|
||||
guide_fg: BORDER,
|
||||
selected_fg: SELECTED_FG,
|
||||
selected_bg: SELECTED_BG,
|
||||
expand_fg: ACCENT,
|
||||
}
|
||||
}
|
||||
|
||||
fn cross_ref_colors() -> CrossRefColors {
|
||||
CrossRefColors {
|
||||
kind_fg: ACCENT,
|
||||
label_fg: TEXT,
|
||||
muted_fg: TEXT_MUTED,
|
||||
selected_fg: SELECTED_FG,
|
||||
selected_bg: SELECTED_BG,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the full MR detail screen.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// Row 0: !10 Fix auth flow (title bar)
|
||||
/// Row 1: opened | alice | fix-auth -> main (metadata row)
|
||||
/// Row 2: [Overview] [Files (3)] [Discussions] (tab bar)
|
||||
/// Row 3: ──────────────────────────────────── (separator)
|
||||
/// Row 4..N: Tab-specific content
|
||||
/// ```
|
||||
pub fn render_mr_detail(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &MrDetailState,
|
||||
area: Rect,
|
||||
clock: &dyn Clock,
|
||||
) {
|
||||
if area.height < 4 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(ref meta) = state.metadata else {
|
||||
return;
|
||||
};
|
||||
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
|
||||
// --- Title bar ---
|
||||
y = render_title_bar(frame, meta, area.x, y, max_x);
|
||||
|
||||
// --- Metadata row ---
|
||||
y = render_metadata_row(frame, meta, area.x, y, max_x);
|
||||
|
||||
// --- Tab bar ---
|
||||
y = render_tab_bar(frame, state, area.x, y, max_x);
|
||||
|
||||
// --- Separator ---
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
if y >= bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
let content_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
|
||||
|
||||
match state.active_tab {
|
||||
MrTab::Overview => render_overview_tab(frame, state, meta, content_area, clock),
|
||||
MrTab::Files => render_files_tab(frame, state, content_area),
|
||||
MrTab::Discussions => render_discussions_tab(frame, state, content_area, clock),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render `!10 Fix auth flow` (or `!10 [Draft] Fix auth flow`).
|
||||
fn render_title_bar(frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, max_x: u16) -> u16 {
|
||||
let iid_text = format!("!{} ", meta.iid);
|
||||
let iid_style = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let mut cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
|
||||
|
||||
if meta.draft {
|
||||
let draft_style = Cell {
|
||||
fg: YELLOW,
|
||||
..Cell::default()
|
||||
};
|
||||
cx = frame.print_text_clipped(cx, y, "[Draft] ", draft_style, max_x);
|
||||
}
|
||||
|
||||
let title_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
|
||||
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render `opened | alice | fix-auth -> main | mergeable`.
|
||||
fn render_metadata_row(
|
||||
frame: &mut Frame<'_>,
|
||||
meta: &MrMetadata,
|
||||
x: u16,
|
||||
y: u16,
|
||||
max_x: u16,
|
||||
) -> u16 {
|
||||
let state_fg = match meta.state.as_str() {
|
||||
"opened" => GREEN,
|
||||
"merged" => CYAN,
|
||||
"closed" => RED,
|
||||
_ => TEXT_MUTED,
|
||||
};
|
||||
let state_style = Cell {
|
||||
fg: state_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let author_style = Cell {
|
||||
fg: CYAN,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
|
||||
|
||||
let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
|
||||
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x);
|
||||
|
||||
if !meta.merge_status.is_empty() {
|
||||
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
|
||||
let status_fg = if meta.merge_status == "mergeable" {
|
||||
GREEN
|
||||
} else {
|
||||
YELLOW
|
||||
};
|
||||
let status_style = Cell {
|
||||
fg: status_fg,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(cx, y, &meta.merge_status, status_style, max_x);
|
||||
}
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render tab bar: `[Overview] [Files (3)] [Discussions (2)]`.
|
||||
fn render_tab_bar(frame: &mut Frame<'_>, state: &MrDetailState, x: u16, y: u16, max_x: u16) -> u16 {
|
||||
let tabs = [
|
||||
(MrTab::Overview, "Overview".to_string()),
|
||||
(
|
||||
MrTab::Files,
|
||||
format!("Files ({})", state.file_changes.len()),
|
||||
),
|
||||
(
|
||||
MrTab::Discussions,
|
||||
format!("Discussions ({})", state.discussions.len()),
|
||||
),
|
||||
];
|
||||
|
||||
let mut cx = x;
|
||||
for (tab, label) in &tabs {
|
||||
if *tab == state.active_tab {
|
||||
let style = Cell {
|
||||
fg: SELECTED_FG,
|
||||
bg: SELECTED_BG,
|
||||
..Cell::default()
|
||||
};
|
||||
let text = format!(" {label} ");
|
||||
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
|
||||
} else {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let text = format!(" {label} ");
|
||||
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
|
||||
}
|
||||
// Tab separator.
|
||||
let sep = Cell {
|
||||
fg: BORDER,
|
||||
..Cell::default()
|
||||
};
|
||||
cx = frame.print_text_clipped(cx, y, " ", sep, max_x);
|
||||
}
|
||||
|
||||
y + 1
|
||||
}
|
||||
|
||||
/// Render horizontal separator.
|
||||
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
|
||||
let sep_style = Cell {
|
||||
fg: BORDER,
|
||||
..Cell::default()
|
||||
};
|
||||
let line: String = "\u{2500}".repeat(width as usize);
|
||||
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
|
||||
y + 1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab content renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Overview tab: description + cross-references.
|
||||
fn render_overview_tab(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &MrDetailState,
|
||||
meta: &MrMetadata,
|
||||
area: Rect,
|
||||
_clock: &dyn Clock,
|
||||
) {
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
|
||||
// --- Description ---
|
||||
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
|
||||
let lines: Vec<&str> = safe_desc.lines().collect();
|
||||
let text_style = Cell {
|
||||
fg: TEXT,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
for line in lines
|
||||
.iter()
|
||||
.skip(state.description_scroll)
|
||||
.take((bottom.saturating_sub(y)) as usize)
|
||||
{
|
||||
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
|
||||
y += 1;
|
||||
}
|
||||
|
||||
if y >= bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Separator ---
|
||||
y = render_separator(frame, area.x, y, area.width);
|
||||
if y >= bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Cross-references ---
|
||||
if !state.cross_refs.is_empty() {
|
||||
let header_style = Cell {
|
||||
fg: ACCENT,
|
||||
..Cell::default()
|
||||
};
|
||||
let header = format!("Cross References ({})", state.cross_refs.len());
|
||||
let _ = frame.print_text_clipped(area.x, y, &header, header_style, max_x);
|
||||
y += 1;
|
||||
|
||||
if y < bottom {
|
||||
let refs_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
|
||||
let _ = render_cross_refs(
|
||||
frame,
|
||||
&state.cross_refs,
|
||||
&state.cross_ref_state,
|
||||
refs_area,
|
||||
&cross_ref_colors(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files tab: list of changed files with change type indicators.
|
||||
fn render_files_tab(frame: &mut Frame<'_>, state: &MrDetailState, area: Rect) {
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
let mut y = area.y;
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
|
||||
if state.file_changes.is_empty() {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, y, "No file changes", style, max_x);
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, fc) in state
|
||||
.file_changes
|
||||
.iter()
|
||||
.skip(state.file_scroll)
|
||||
.take((bottom.saturating_sub(y)) as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let is_selected = i + state.file_scroll == state.file_selected;
|
||||
|
||||
let (fg, bg) = if is_selected {
|
||||
(SELECTED_FG, SELECTED_BG)
|
||||
} else {
|
||||
(TEXT, PackedRgba::TRANSPARENT)
|
||||
};
|
||||
|
||||
if is_selected {
|
||||
let sel_cell = Cell {
|
||||
fg,
|
||||
bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_rect_filled(Rect::new(area.x, y, area.width, 1), sel_cell);
|
||||
}
|
||||
|
||||
// Change type icon.
|
||||
let icon_fg = match fc.change_type {
|
||||
FileChangeType::Added => GREEN,
|
||||
FileChangeType::Deleted => RED,
|
||||
FileChangeType::Modified => YELLOW,
|
||||
FileChangeType::Renamed => CYAN,
|
||||
};
|
||||
let icon_style = Cell {
|
||||
fg: if is_selected { fg } else { icon_fg },
|
||||
bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let mut cx = frame.print_text_clipped(area.x, y, fc.change_type.icon(), icon_style, max_x);
|
||||
cx = frame.print_text_clipped(cx, y, " ", icon_style, max_x);
|
||||
|
||||
// File path.
|
||||
let path_style = Cell {
|
||||
fg,
|
||||
bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let display_path = if fc.change_type == FileChangeType::Renamed {
|
||||
if let Some(ref old) = fc.old_path {
|
||||
format!("{old} -> {}", fc.new_path)
|
||||
} else {
|
||||
fc.new_path.clone()
|
||||
}
|
||||
} else {
|
||||
fc.new_path.clone()
|
||||
};
|
||||
let _ = frame.print_text_clipped(cx, y, &display_path, path_style, max_x);
|
||||
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Discussions tab: all discussions rendered via the tree widget.
|
||||
fn render_discussions_tab(
|
||||
frame: &mut Frame<'_>,
|
||||
state: &MrDetailState,
|
||||
area: Rect,
|
||||
clock: &dyn Clock,
|
||||
) {
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
if !state.discussions_loaded {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ =
|
||||
frame.print_text_clipped(area.x + 1, area.y, "Loading discussions...", style, max_x);
|
||||
return;
|
||||
}
|
||||
|
||||
if state.discussions.is_empty() {
|
||||
let style = Cell {
|
||||
fg: TEXT_MUTED,
|
||||
..Cell::default()
|
||||
};
|
||||
let _ = frame.print_text_clipped(area.x + 1, area.y, "No discussions", style, max_x);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = render_discussion_tree(
|
||||
frame,
|
||||
&state.discussions,
|
||||
&state.tree_state,
|
||||
area,
|
||||
&discussion_colors(),
|
||||
clock,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::FakeClock;
|
||||
use crate::message::EntityKey;
|
||||
use crate::state::mr_detail::{FileChange, FileChangeType, MrDetailData, MrMetadata, MrTab};
|
||||
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
|
||||
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
|
||||
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_mr_metadata() -> MrMetadata {
|
||||
MrMetadata {
|
||||
iid: 10,
|
||||
project_path: "group/project".into(),
|
||||
title: "Fix authentication flow".into(),
|
||||
description: "This MR fixes the login bug.\nSee issue #42.".into(),
|
||||
state: "opened".into(),
|
||||
draft: false,
|
||||
author: "alice".into(),
|
||||
assignees: vec!["bob".into()],
|
||||
reviewers: vec!["carol".into()],
|
||||
labels: vec!["backend".into()],
|
||||
source_branch: "fix-auth".into(),
|
||||
target_branch: "main".into(),
|
||||
merge_status: "mergeable".into(),
|
||||
created_at: 1_700_000_000_000,
|
||||
updated_at: 1_700_000_060_000,
|
||||
merged_at: None,
|
||||
web_url: "https://gitlab.com/group/project/-/merge_requests/10".into(),
|
||||
discussion_count: 1,
|
||||
file_change_count: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_mr_state() -> MrDetailState {
|
||||
let mut state = MrDetailState::default();
|
||||
state.load_new(EntityKey::mr(1, 10));
|
||||
state.apply_metadata(MrDetailData {
|
||||
metadata: sample_mr_metadata(),
|
||||
cross_refs: vec![CrossRef {
|
||||
kind: CrossRefKind::ClosingMr,
|
||||
entity_key: EntityKey::issue(1, 42),
|
||||
label: "Auth bug".into(),
|
||||
navigable: true,
|
||||
}],
|
||||
file_changes: vec![
|
||||
FileChange {
|
||||
old_path: None,
|
||||
new_path: "src/auth.rs".into(),
|
||||
change_type: FileChangeType::Modified,
|
||||
},
|
||||
FileChange {
|
||||
old_path: None,
|
||||
new_path: "src/lib.rs".into(),
|
||||
change_type: FileChangeType::Added,
|
||||
},
|
||||
],
|
||||
});
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_no_metadata() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = MrDetailState::default();
|
||||
let clock = FakeClock::from_ms(1_700_000_000_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_overview_tab() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let state = sample_mr_state();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_files_tab() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut state = sample_mr_state();
|
||||
state.active_tab = MrTab::Files;
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_discussions_tab_loading() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut state = sample_mr_state();
|
||||
state.active_tab = MrTab::Discussions;
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_discussions_tab_with_data() {
|
||||
with_frame!(80, 30, |frame| {
|
||||
let mut state = sample_mr_state();
|
||||
state.active_tab = MrTab::Discussions;
|
||||
state.apply_discussions(vec![DiscussionNode {
|
||||
discussion_id: "d1".into(),
|
||||
notes: vec![NoteNode {
|
||||
author: "alice".into(),
|
||||
body: "Looks good".into(),
|
||||
created_at: 1_700_000_020_000,
|
||||
is_system: false,
|
||||
is_diff_note: true,
|
||||
diff_file_path: Some("src/auth.rs".into()),
|
||||
diff_new_line: Some(42),
|
||||
}],
|
||||
resolvable: true,
|
||||
resolved: false,
|
||||
}]);
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 30), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_draft() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut state = sample_mr_state();
|
||||
state.metadata.as_mut().unwrap().draft = true;
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_tiny_area() {
|
||||
with_frame!(5, 3, |frame| {
|
||||
let state = sample_mr_state();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 5, 3), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_mr_detail_narrow_terminal() {
|
||||
with_frame!(30, 10, |frame| {
|
||||
let state = sample_mr_state();
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_files_empty() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut state = MrDetailState::default();
|
||||
state.load_new(EntityKey::mr(1, 10));
|
||||
state.apply_metadata(MrDetailData {
|
||||
metadata: sample_mr_metadata(),
|
||||
cross_refs: vec![],
|
||||
file_changes: vec![],
|
||||
});
|
||||
state.active_tab = MrTab::Files;
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_files_with_rename() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut state = MrDetailState::default();
|
||||
state.load_new(EntityKey::mr(1, 10));
|
||||
state.apply_metadata(MrDetailData {
|
||||
metadata: sample_mr_metadata(),
|
||||
cross_refs: vec![],
|
||||
file_changes: vec![FileChange {
|
||||
old_path: Some("src/old.rs".into()),
|
||||
new_path: "src/new.rs".into(),
|
||||
change_type: FileChangeType::Renamed,
|
||||
}],
|
||||
});
|
||||
state.active_tab = MrTab::Files;
|
||||
let clock = FakeClock::from_ms(1_700_000_060_000);
|
||||
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
|
||||
});
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
41
migrations/027_tui_list_indexes.sql
Normal file
41
migrations/027_tui_list_indexes.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Covering indexes for TUI list screen keyset pagination.
|
||||
-- These supplement existing indexes from earlier migrations to
|
||||
-- enable efficient ORDER BY ... LIMIT queries without temp B-tree sorts.
|
||||
|
||||
-- Issue list: default sort (updated_at DESC, iid DESC) with state filter.
|
||||
-- Covers: WHERE project_id = ? AND state = ? ORDER BY updated_at DESC, iid DESC
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_tui_list
|
||||
ON issues(project_id, state, updated_at DESC, iid DESC);
|
||||
|
||||
-- MR list: default sort (updated_at DESC, iid DESC) with state filter.
|
||||
CREATE INDEX IF NOT EXISTS idx_mrs_tui_list
|
||||
ON merge_requests(project_id, state, updated_at DESC, iid DESC);
|
||||
|
||||
-- Discussion list for entity detail screens: ordered by first note timestamp.
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_issue_ordered
|
||||
ON discussions(issue_id, first_note_at DESC)
|
||||
WHERE issue_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discussions_mr_ordered
|
||||
ON discussions(merge_request_id, first_note_at DESC)
|
||||
WHERE merge_request_id IS NOT NULL;
|
||||
|
||||
-- Notes within a discussion: chronological order for detail views.
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_discussion_ordered
|
||||
ON notes(discussion_id, created_at ASC);
|
||||
|
||||
-- Filter-path indexes for TUI filter bar queries.
|
||||
-- Issues: author filter with state (covers WHERE author_username = ? AND state = ?).
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_author_state
|
||||
ON issues(author_username, state);
|
||||
|
||||
-- MRs: author filter with state.
|
||||
CREATE INDEX IF NOT EXISTS idx_mrs_author_state
|
||||
ON merge_requests(author_username, state);
|
||||
|
||||
-- MRs: target branch filter with state.
|
||||
CREATE INDEX IF NOT EXISTS idx_mrs_target_branch_state
|
||||
ON merge_requests(target_branch, state);
|
||||
|
||||
INSERT INTO schema_version (version, applied_at, description)
|
||||
VALUES (27, strftime('%s', 'now') * 1000, 'TUI list screen covering indexes');
|
||||
@@ -89,6 +89,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"026",
|
||||
include_str!("../../migrations/026_scoring_indexes.sql"),
|
||||
),
|
||||
(
|
||||
"027",
|
||||
include_str!("../../migrations/027_tui_list_indexes.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||
|
||||
@@ -136,6 +136,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_knn_k_never_exceeds_sqlite_vec_limit() {
|
||||
// Brute-force: no combination of valid inputs should exceed 4096.
|
||||
for limit in [1, 10, 50, 100, 500, 1000, 1500, 2000, 5000] {
|
||||
for max_chunks in [1, 2, 5, 10, 50, 100, 200, 500, 1000] {
|
||||
let k = compute_knn_k(limit, max_chunks);
|
||||
@@ -149,33 +150,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_knn_k_reproduces_original_bug_scenario() {
|
||||
// The original bug: limit=1500 (RECALL_CAP) with multiplier >= 8
|
||||
// produced k=10_000 which exceeded sqlite-vec's 4096 cap.
|
||||
let k = compute_knn_k(1500, 1);
|
||||
assert!(
|
||||
k <= SQLITE_VEC_KNN_MAX,
|
||||
"k={k} exceeded 4096 at RECALL_CAP with 1 chunk"
|
||||
);
|
||||
assert!(k <= SQLITE_VEC_KNN_MAX, "k={k} exceeded 4096 at RECALL_CAP with 1 chunk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_knn_k_small_limit_uses_minimum_multiplier() {
|
||||
// With 1 chunk, multiplier clamps to minimum of 8.
|
||||
let k = compute_knn_k(10, 1);
|
||||
assert_eq!(k, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_knn_k_high_chunks_caps_multiplier() {
|
||||
// With 200 chunks, multiplier = (200*3/2 + 1) = 301 → clamped to 200.
|
||||
// limit=10 * 200 = 2000 < 4096.
|
||||
let k = compute_knn_k(10, 200);
|
||||
assert_eq!(k, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_knn_k_zero_max_chunks_treated_as_one() {
|
||||
// max_chunks_per_doc=0 → unsigned_abs().max(1) = 1
|
||||
let k = compute_knn_k(10, 0);
|
||||
assert_eq!(k, 80);
|
||||
assert_eq!(k, 80); // multiplier clamp(8, 200) → 8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_knn_k_negative_max_chunks_uses_absolute() {
|
||||
// Defensive: negative values use unsigned_abs
|
||||
let k = compute_knn_k(10, -5);
|
||||
assert_eq!(k, compute_knn_k(10, 5));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user