feat(tui): Phase 2 Issue List + MR List screens
Implement state, action, and view layers for both list screens: - Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation - MR List: mirrors Issue pattern with draft/reviewer/target branch filters - Migration 027: covering indexes for TUI list screen queries - Updated Msg types to use typed Page structs instead of raw Vec<Row> - 303 tests passing, clippy clean Beads: bd-3ei1, bd-2kr0, bd-3pm2
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-2cbw
|
bd-2kr0
|
||||||
|
|||||||
109
crates/lore-tui/Cargo.lock
generated
109
crates/lore-tui/Cargo.lock
generated
@@ -171,6 +171,23 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charmed-lipgloss"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"colored",
|
||||||
|
"crossterm 0.29.0",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
@@ -241,14 +258,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "comfy-table"
|
name = "colored"
|
||||||
version = "7.2.2"
|
version = "2.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm 0.29.0",
|
"lazy_static",
|
||||||
"unicode-segmentation",
|
"windows-sys 0.59.0",
|
||||||
"unicode-width",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -260,7 +276,7 @@ dependencies = [
|
|||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -603,7 +619,7 @@ dependencies = [
|
|||||||
"signal-hook 0.4.3",
|
"signal-hook 0.4.3",
|
||||||
"unicode-display-width",
|
"unicode-display-width",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -642,7 +658,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"unicode-display-width",
|
"unicode-display-width",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -660,7 +676,7 @@ dependencies = [
|
|||||||
"ftui-text",
|
"ftui-text",
|
||||||
"tracing",
|
"tracing",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -704,7 +720,7 @@ dependencies = [
|
|||||||
"ftui-style",
|
"ftui-style",
|
||||||
"ftui-text",
|
"ftui-text",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1145,7 +1161,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"console",
|
"console",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
"unit-prefix",
|
"unit-prefix",
|
||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
@@ -1287,13 +1303,13 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lore"
|
name = "lore"
|
||||||
version = "0.7.0"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
"charmed-lipgloss",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"comfy-table",
|
|
||||||
"console",
|
"console",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -1964,6 +1980,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -2317,6 +2342,47 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -2481,6 +2547,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2967,6 +3039,15 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
1628
crates/lore-tui/src/action.rs
Normal file
1628
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));
|
||||||
|
}
|
||||||
@@ -1,73 +1,19 @@
|
|||||||
#![allow(dead_code)] // Phase 1: methods consumed as screens are implemented
|
//! Model trait impl and key dispatch for LoreApp.
|
||||||
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use chrono::TimeDelta;
|
use chrono::TimeDelta;
|
||||||
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
|
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
|
||||||
|
|
||||||
use crate::clock::{Clock, SystemClock};
|
use crate::crash_context::CrashEvent;
|
||||||
use crate::commands::{CommandRegistry, build_registry};
|
|
||||||
use crate::crash_context::{CrashContext, CrashEvent};
|
|
||||||
use crate::db::DbManager;
|
|
||||||
use crate::message::{InputMode, Msg, Screen};
|
use crate::message::{InputMode, Msg, Screen};
|
||||||
use crate::navigation::NavigationStack;
|
use crate::state::LoadState;
|
||||||
use crate::state::{AppState, LoadState};
|
use crate::task_supervisor::TaskKey;
|
||||||
use crate::task_supervisor::{TaskKey, TaskSupervisor};
|
|
||||||
|
use super::LoreApp;
|
||||||
|
|
||||||
/// Timeout for the g-prefix key sequence.
|
/// Timeout for the g-prefix key sequence.
|
||||||
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
|
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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 {
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Key dispatch
|
// Key dispatch
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -84,7 +30,7 @@ impl LoreApp {
|
|||||||
/// 5-stage key dispatch pipeline.
|
/// 5-stage key dispatch pipeline.
|
||||||
///
|
///
|
||||||
/// Returns the Cmd to execute (Quit, None, or a task command).
|
/// Returns the Cmd to execute (Quit, None, or a task command).
|
||||||
fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
|
pub(crate) fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
|
||||||
Self::normalize_key(&mut key);
|
Self::normalize_key(&mut key);
|
||||||
|
|
||||||
let screen = self.navigation.current().clone();
|
let screen = self.navigation.current().clone();
|
||||||
@@ -269,8 +215,14 @@ impl LoreApp {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.navigation.push(screen.clone());
|
self.navigation.push(screen.clone());
|
||||||
self.state
|
|
||||||
.set_loading(screen.clone(), LoadState::Refreshing);
|
// 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
|
// Spawn supervised task for data loading (placeholder — actual DB
|
||||||
// query dispatch comes in Phase 2 screen implementations).
|
// query dispatch comes in Phase 2 screen implementations).
|
||||||
@@ -284,7 +236,7 @@ impl LoreApp {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Handle non-key messages.
|
/// Handle non-key messages.
|
||||||
fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
|
pub(crate) fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
|
||||||
// Record in crash context.
|
// Record in crash context.
|
||||||
self.crash_context.push(CrashEvent::MsgDispatched {
|
self.crash_context.push(CrashEvent::MsgDispatched {
|
||||||
msg_name: format!("{msg:?}")
|
msg_name: format!("{msg:?}")
|
||||||
@@ -343,24 +295,24 @@ impl LoreApp {
|
|||||||
Msg::Tick => Cmd::none(),
|
Msg::Tick => Cmd::none(),
|
||||||
|
|
||||||
// --- Loaded results (stale guard) ---
|
// --- Loaded results (stale guard) ---
|
||||||
Msg::IssueListLoaded { generation, rows } => {
|
Msg::IssueListLoaded { generation, page } => {
|
||||||
if self
|
if self
|
||||||
.supervisor
|
.supervisor
|
||||||
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
|
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
|
||||||
{
|
{
|
||||||
self.state.issue_list.rows = rows;
|
self.state.issue_list.apply_page(page);
|
||||||
self.state.set_loading(Screen::IssueList, LoadState::Idle);
|
self.state.set_loading(Screen::IssueList, LoadState::Idle);
|
||||||
self.supervisor
|
self.supervisor
|
||||||
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
|
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
|
||||||
}
|
}
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
}
|
}
|
||||||
Msg::MrListLoaded { generation, rows } => {
|
Msg::MrListLoaded { generation, page } => {
|
||||||
if self
|
if self
|
||||||
.supervisor
|
.supervisor
|
||||||
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
|
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
|
||||||
{
|
{
|
||||||
self.state.mr_list.rows = rows;
|
self.state.mr_list.apply_page(page);
|
||||||
self.state.set_loading(Screen::MrList, LoadState::Idle);
|
self.state.set_loading(Screen::MrList, LoadState::Idle);
|
||||||
self.supervisor
|
self.supervisor
|
||||||
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
|
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
|
||||||
@@ -372,8 +324,7 @@ impl LoreApp {
|
|||||||
.supervisor
|
.supervisor
|
||||||
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
|
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
|
||||||
{
|
{
|
||||||
self.state.dashboard.issue_count = data.issue_count;
|
self.state.dashboard.update(*data);
|
||||||
self.state.dashboard.mr_count = data.mr_count;
|
|
||||||
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
|
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
|
||||||
self.supervisor
|
self.supervisor
|
||||||
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
|
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
|
||||||
@@ -388,19 +339,13 @@ impl LoreApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LoreApp {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Model for LoreApp {
|
impl Model for LoreApp {
|
||||||
type Message = Msg;
|
type Message = Msg;
|
||||||
|
|
||||||
fn init(&mut self) -> Cmd<Self::Message> {
|
fn init(&mut self) -> Cmd<Self::Message> {
|
||||||
// Install crash context panic hook.
|
// Install crash context panic hook.
|
||||||
CrashContext::install_panic_hook(&self.crash_context);
|
crate::crash_context::CrashContext::install_panic_hook(&self.crash_context);
|
||||||
CrashContext::prune_crash_files();
|
crate::crash_context::CrashContext::prune_crash_files();
|
||||||
|
|
||||||
// Navigate to dashboard (will trigger data load in future phase).
|
// Navigate to dashboard (will trigger data load in future phase).
|
||||||
Cmd::none()
|
Cmd::none()
|
||||||
@@ -420,293 +365,3 @@ impl Model for LoreApp {
|
|||||||
crate::view::render_screen(frame, self);
|
crate::view::render_screen(frame, self);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `App::fullscreen(LoreApp::new()).run()` compiles.
|
|
||||||
#[cfg(test)]
|
|
||||||
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.
|
|
||||||
#[cfg(test)]
|
|
||||||
fn _assert_app_inline_compiles() {
|
|
||||||
fn _inner() {
|
|
||||||
use ftui::App;
|
|
||||||
let _app_builder = App::inline(LoreApp::new(), 12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::clock::FakeClock;
|
|
||||||
|
|
||||||
fn test_app() -> LoreApp {
|
|
||||||
LoreApp::with_clock(Box::new(FakeClock::new(chrono::Utc::now())))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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() {
|
|
||||||
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,
|
|
||||||
rows: vec![crate::message::IssueRow {
|
|
||||||
key: crate::message::EntityKey::issue(1, 1),
|
|
||||||
title: "stale".into(),
|
|
||||||
state: "opened".into(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
assert!(app.state.issue_list.rows.is_empty());
|
|
||||||
|
|
||||||
// Current result with gen2 should be applied.
|
|
||||||
app.update(Msg::IssueListLoaded {
|
|
||||||
generation: gen2,
|
|
||||||
rows: vec![crate::message::IssueRow {
|
|
||||||
key: crate::message::EntityKey::issue(1, 2),
|
|
||||||
title: "fresh".into(),
|
|
||||||
state: "opened".into(),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
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_state() {
|
|
||||||
let mut app = test_app();
|
|
||||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,11 @@ use chrono::{DateTime, TimeDelta, Utc};
|
|||||||
pub trait Clock: Send + Sync {
|
pub trait Clock: Send + Sync {
|
||||||
/// Returns the current time.
|
/// Returns the current time.
|
||||||
fn now(&self) -> DateTime<Utc>;
|
fn now(&self) -> DateTime<Utc>;
|
||||||
|
|
||||||
|
/// Returns the current time as milliseconds since the Unix epoch.
|
||||||
|
fn now_ms(&self) -> i64 {
|
||||||
|
self.now().timestamp_millis()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -54,6 +59,15 @@ impl FakeClock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Advance the clock by `duration`. Uses `checked_add` to handle overflow
|
||||||
/// gracefully — if the addition would overflow, the time is not changed.
|
/// gracefully — if the addition would overflow, the time is not changed.
|
||||||
pub fn advance(&self, duration: TimeDelta) {
|
pub fn advance(&self, duration: TimeDelta) {
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,13 @@ impl CrashContext {
|
|||||||
///
|
///
|
||||||
/// Captures the current events via a snapshot. The hook chains with
|
/// Captures the current events via a snapshot. The hook chains with
|
||||||
/// the default panic handler so backtraces are still printed.
|
/// 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) {
|
pub fn install_panic_hook(ctx: &Self) {
|
||||||
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
|
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
|
||||||
let prev_hook = std::panic::take_hook();
|
let prev_hook = std::panic::take_hook();
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,16 @@ pub mod app; // LoreApp Model trait impl (Phase 0 proof: bd-2emv, full: bd-6pmy)
|
|||||||
// Phase 1 modules.
|
// Phase 1 modules.
|
||||||
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
|
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
|
||||||
pub mod crash_context; // CrashContext ring buffer + panic hook (bd-2fr7)
|
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 navigation; // NavigationStack: back/forward/jump list (bd-1qpp)
|
||||||
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
|
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
|
||||||
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
|
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
|
||||||
pub mod view; // View layer: render_screen + common widgets (bd-26f2)
|
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.
|
/// Options controlling how the TUI launches.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LaunchOptions {
|
pub struct LaunchOptions {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ pub enum Msg {
|
|||||||
// --- Issue list ---
|
// --- Issue list ---
|
||||||
IssueListLoaded {
|
IssueListLoaded {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
rows: Vec<IssueRow>,
|
page: crate::state::issue_list::IssueListPage,
|
||||||
},
|
},
|
||||||
IssueListFilterChanged(String),
|
IssueListFilterChanged(String),
|
||||||
IssueListSortChanged,
|
IssueListSortChanged,
|
||||||
@@ -231,7 +231,7 @@ pub enum Msg {
|
|||||||
// --- MR list ---
|
// --- MR list ---
|
||||||
MrListLoaded {
|
MrListLoaded {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
rows: Vec<MrRow>,
|
page: crate::state::mr_list::MrListPage,
|
||||||
},
|
},
|
||||||
MrListFilterChanged(String),
|
MrListFilterChanged(String),
|
||||||
MrSelected(EntityKey),
|
MrSelected(EntityKey),
|
||||||
@@ -318,7 +318,7 @@ pub enum Msg {
|
|||||||
// --- Dashboard ---
|
// --- Dashboard ---
|
||||||
DashboardLoaded {
|
DashboardLoaded {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
data: Box<DashboardData>,
|
data: Box<crate::state::dashboard::DashboardData>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Global actions ---
|
// --- Global actions ---
|
||||||
@@ -349,23 +349,6 @@ impl From<Event> for Msg {
|
|||||||
// Placeholder data types (will be fleshed out in Phase 1+)
|
// Placeholder data types (will be fleshed out in Phase 1+)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Placeholder for an issue row in list views.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct IssueRow {
|
|
||||||
pub key: EntityKey,
|
|
||||||
pub title: String,
|
|
||||||
pub state: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Placeholder for a merge request row in list views.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MrRow {
|
|
||||||
pub key: EntityKey,
|
|
||||||
pub title: String,
|
|
||||||
pub state: String,
|
|
||||||
pub draft: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Placeholder for issue detail payload.
|
/// Placeholder for issue detail payload.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IssueDetail {
|
pub struct IssueDetail {
|
||||||
@@ -410,12 +393,8 @@ pub struct WhoResult {
|
|||||||
pub experts: Vec<String>,
|
pub experts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder for dashboard summary data.
|
// DashboardData moved to crate::state::dashboard (enriched with
|
||||||
#[derive(Debug, Clone)]
|
// EntityCounts, ProjectSyncInfo, RecentActivityItem, LastSyncInfo).
|
||||||
pub struct DashboardData {
|
|
||||||
pub issue_count: u64,
|
|
||||||
pub mr_count: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
|
|||||||
@@ -60,9 +60,10 @@ impl NavigationStack {
|
|||||||
self.forward_stack.clear();
|
self.forward_stack.clear();
|
||||||
|
|
||||||
// Record significant hops in jump list (vim behavior):
|
// Record significant hops in jump list (vim behavior):
|
||||||
// truncate any forward entries beyond jump_index, then append.
|
// 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() {
|
if self.current.is_detail_or_entity() {
|
||||||
self.jump_list.truncate(self.jump_index);
|
self.jump_list.truncate(self.jump_index.saturating_add(1));
|
||||||
self.jump_list.push(self.current.clone());
|
self.jump_list.push(self.current.clone());
|
||||||
self.jump_index = self.jump_list.len();
|
self.jump_index = self.jump_list.len();
|
||||||
}
|
}
|
||||||
@@ -90,23 +91,37 @@ impl NavigationStack {
|
|||||||
|
|
||||||
/// Jump backward through the jump list (vim Ctrl+O).
|
/// Jump backward through the jump list (vim Ctrl+O).
|
||||||
///
|
///
|
||||||
/// Only visits detail/entity screens.
|
/// 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> {
|
pub fn jump_back(&mut self) -> Option<&Screen> {
|
||||||
if self.jump_index == 0 {
|
while self.jump_index > 0 {
|
||||||
return None;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.jump_index -= 1;
|
None
|
||||||
self.jump_list.get(self.jump_index)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jump forward through the jump list (vim Ctrl+I).
|
/// Jump forward through the jump list (vim Ctrl+I).
|
||||||
|
///
|
||||||
|
/// Skips entries matching the current screen.
|
||||||
pub fn jump_forward(&mut self) -> Option<&Screen> {
|
pub fn jump_forward(&mut self) -> Option<&Screen> {
|
||||||
if self.jump_index >= self.jump_list.len() {
|
while self.jump_index < self.jump_list.len() {
|
||||||
return None;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let screen = self.jump_list.get(self.jump_index)?;
|
None
|
||||||
self.jump_index += 1;
|
|
||||||
Some(screen)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset to a single screen, clearing all history.
|
/// Reset to a single screen, clearing all history.
|
||||||
@@ -246,24 +261,21 @@ mod tests {
|
|||||||
nav.push(Screen::MrList);
|
nav.push(Screen::MrList);
|
||||||
nav.push(mr.clone());
|
nav.push(mr.clone());
|
||||||
|
|
||||||
// jump_index is at 2 (past the end of 2 items)
|
// Current is MrDetail. jump_list = [IssueDetail, MrDetail], index = 2.
|
||||||
let prev = nav.jump_back();
|
// First jump_back skips MrDetail (== current) and lands on IssueDetail.
|
||||||
assert_eq!(prev, Some(&mr));
|
|
||||||
|
|
||||||
let prev = nav.jump_back();
|
let prev = nav.jump_back();
|
||||||
assert_eq!(prev, Some(&issue));
|
assert_eq!(prev, Some(&issue));
|
||||||
|
assert!(nav.is_at(&issue));
|
||||||
|
|
||||||
// at beginning
|
// Already at beginning of jump list.
|
||||||
assert!(nav.jump_back().is_none());
|
assert!(nav.jump_back().is_none());
|
||||||
|
|
||||||
// forward
|
// jump_forward skips IssueDetail (== current) and lands on MrDetail.
|
||||||
let next = nav.jump_forward();
|
|
||||||
assert_eq!(next, Some(&issue));
|
|
||||||
|
|
||||||
let next = nav.jump_forward();
|
let next = nav.jump_forward();
|
||||||
assert_eq!(next, Some(&mr));
|
assert_eq!(next, Some(&mr));
|
||||||
|
assert!(nav.is_at(&mr));
|
||||||
|
|
||||||
// at end
|
// At end of jump list.
|
||||||
assert!(nav.jump_forward().is_none());
|
assert!(nav.jump_forward().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,10 +286,9 @@ mod tests {
|
|||||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
|
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
|
||||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
|
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
|
||||||
|
|
||||||
// jump back twice
|
// jump back twice — lands on issue(1,1), jump_index = 0
|
||||||
nav.jump_back();
|
nav.jump_back();
|
||||||
nav.jump_back();
|
nav.jump_back();
|
||||||
// jump_index = 1, pointing at issue 2
|
|
||||||
|
|
||||||
// new detail push truncates forward entries
|
// new detail push truncates forward entries
|
||||||
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
|
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
|
||||||
|
|||||||
@@ -1,10 +1,255 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
//! Dashboard screen state.
|
//! 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.
|
/// State for the dashboard summary screen.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct DashboardState {
|
pub struct DashboardState {
|
||||||
pub issue_count: u64,
|
pub counts: EntityCounts,
|
||||||
pub mr_count: u64,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,376 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/issue_list
|
||||||
|
|
||||||
//! Issue list screen state.
|
//! 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 crate::message::IssueRow;
|
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.
|
/// State for the issue list screen.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct IssueListState {
|
pub struct IssueListState {
|
||||||
pub rows: Vec<IssueRow>,
|
/// Current page of issue rows.
|
||||||
pub filter: String,
|
pub rows: Vec<IssueListRow>,
|
||||||
pub filter_focused: bool,
|
/// Total count of matching issues.
|
||||||
|
pub total_count: u64,
|
||||||
|
/// Selected row index (within current window).
|
||||||
pub selected_index: usize,
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub mod sync;
|
|||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
pub mod who;
|
pub mod who;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::message::Screen;
|
use crate::message::Screen;
|
||||||
|
|
||||||
@@ -80,6 +80,8 @@ impl LoadState {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ScreenLoadStateMap {
|
pub struct ScreenLoadStateMap {
|
||||||
map: HashMap<Screen, LoadState>,
|
map: HashMap<Screen, LoadState>,
|
||||||
|
/// Screens that have had a load state set at least once.
|
||||||
|
visited: HashSet<Screen>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScreenLoadStateMap {
|
impl ScreenLoadStateMap {
|
||||||
@@ -94,6 +96,7 @@ impl ScreenLoadStateMap {
|
|||||||
///
|
///
|
||||||
/// Setting to `Idle` removes the entry to prevent map growth.
|
/// Setting to `Idle` removes the entry to prevent map growth.
|
||||||
pub fn set(&mut self, screen: Screen, state: LoadState) {
|
pub fn set(&mut self, screen: Screen, state: LoadState) {
|
||||||
|
self.visited.insert(screen.clone());
|
||||||
if state == LoadState::Idle {
|
if state == LoadState::Idle {
|
||||||
self.map.remove(&screen);
|
self.map.remove(&screen);
|
||||||
} else {
|
} else {
|
||||||
@@ -101,6 +104,12 @@ impl ScreenLoadStateMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Whether any screen is currently loading.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn any_loading(&self) -> bool {
|
pub fn any_loading(&self) -> bool {
|
||||||
|
|||||||
@@ -1,14 +1,422 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/mr_list
|
||||||
|
|
||||||
//! Merge request list screen state.
|
//! 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 crate::message::MrRow;
|
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.
|
/// State for the MR list screen.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct MrListState {
|
pub struct MrListState {
|
||||||
pub rows: Vec<MrRow>,
|
/// Current page of MR rows.
|
||||||
pub filter: String,
|
pub rows: Vec<MrListRow>,
|
||||||
pub filter_focused: bool,
|
/// Total count of matching MRs.
|
||||||
|
pub total_count: u64,
|
||||||
|
/// Selected row index (within current window).
|
||||||
pub selected_index: usize,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
676
crates/lore-tui/src/view/common/entity_table.rs
Normal file
676
crates/lore-tui/src/view/common/entity_table.rs
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
|
||||||
|
|
||||||
|
//! Generic entity table widget for list screens.
|
||||||
|
//!
|
||||||
|
//! `EntityTable<R>` renders rows with sortable, responsive columns.
|
||||||
|
//! Columns hide gracefully when the terminal is too narrow, using
|
||||||
|
//! priority-based visibility.
|
||||||
|
|
||||||
|
use ftui::core::geometry::Rect;
|
||||||
|
use ftui::render::cell::{Cell, PackedRgba};
|
||||||
|
use ftui::render::drawing::Draw;
|
||||||
|
use ftui::render::frame::Frame;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Column definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Describes a single table column.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColumnDef {
|
||||||
|
/// Display name shown in the header.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Minimum width in characters. Column is hidden if it can't meet this.
|
||||||
|
pub min_width: u16,
|
||||||
|
/// Flex weight for distributing extra space.
|
||||||
|
pub flex_weight: u16,
|
||||||
|
/// Visibility priority (0 = always shown, higher = hidden first).
|
||||||
|
pub priority: u8,
|
||||||
|
/// Text alignment within the column.
|
||||||
|
pub align: Align,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text alignment within a column.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Align {
|
||||||
|
#[default]
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TableRow trait
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Trait for types that can be rendered as a table row.
|
||||||
|
pub trait TableRow {
|
||||||
|
/// Return the cell text for each column, in column order.
|
||||||
|
fn cells(&self, col_count: usize) -> Vec<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EntityTable state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Rendering state for the entity table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EntityTableState {
|
||||||
|
/// Index of the selected row (0-based, within the full data set).
|
||||||
|
pub selected: usize,
|
||||||
|
/// Scroll offset (first visible row index).
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
/// Index of the column used for sorting.
|
||||||
|
pub sort_column: usize,
|
||||||
|
/// Sort direction.
|
||||||
|
pub sort_ascending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EntityTableState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: 0,
|
||||||
|
scroll_offset: 0,
|
||||||
|
sort_column: 0,
|
||||||
|
sort_ascending: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityTableState {
|
||||||
|
/// Move selection down by 1.
|
||||||
|
pub fn select_next(&mut self, total_rows: usize) {
|
||||||
|
if total_rows == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected = (self.selected + 1).min(total_rows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move selection up by 1.
|
||||||
|
pub fn select_prev(&mut self) {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page down (move by `page_size` rows).
|
||||||
|
pub fn page_down(&mut self, total_rows: usize, page_size: usize) {
|
||||||
|
if total_rows == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected = (self.selected + page_size).min(total_rows - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page up.
|
||||||
|
pub fn page_up(&mut self, page_size: usize) {
|
||||||
|
self.selected = self.selected.saturating_sub(page_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to top.
|
||||||
|
pub fn select_first(&mut self) {
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to bottom.
|
||||||
|
pub fn select_last(&mut self, total_rows: usize) {
|
||||||
|
if total_rows > 0 {
|
||||||
|
self.selected = total_rows - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle sort column forward (wraps around).
|
||||||
|
pub fn cycle_sort(&mut self, col_count: usize) {
|
||||||
|
if col_count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sort_column = (self.sort_column + 1) % col_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle sort direction on current column.
|
||||||
|
pub fn toggle_sort_direction(&mut self) {
|
||||||
|
self.sort_ascending = !self.sort_ascending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure scroll offset keeps selection visible.
|
||||||
|
fn adjust_scroll(&mut self, visible_rows: usize) {
|
||||||
|
if visible_rows == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.selected < self.scroll_offset {
|
||||||
|
self.scroll_offset = self.selected;
|
||||||
|
}
|
||||||
|
if self.selected >= self.scroll_offset + visible_rows {
|
||||||
|
self.scroll_offset = self.selected - visible_rows + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Colors for the entity table. Will be replaced by Theme injection.
|
||||||
|
pub struct TableColors {
|
||||||
|
pub header_fg: PackedRgba,
|
||||||
|
pub header_bg: PackedRgba,
|
||||||
|
pub row_fg: PackedRgba,
|
||||||
|
pub row_alt_bg: PackedRgba,
|
||||||
|
pub selected_fg: PackedRgba,
|
||||||
|
pub selected_bg: PackedRgba,
|
||||||
|
pub sort_indicator: PackedRgba,
|
||||||
|
pub border: PackedRgba,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Compute which columns are visible given the available width.
|
||||||
|
///
|
||||||
|
/// Returns indices of visible columns sorted by original order,
|
||||||
|
/// along with their allocated widths.
|
||||||
|
pub fn visible_columns(columns: &[ColumnDef], available_width: u16) -> Vec<(usize, u16)> {
|
||||||
|
// Sort by priority (lowest = most important).
|
||||||
|
let mut indexed: Vec<(usize, &ColumnDef)> = columns.iter().enumerate().collect();
|
||||||
|
indexed.sort_by_key(|(_, col)| col.priority);
|
||||||
|
|
||||||
|
let mut result: Vec<(usize, u16)> = Vec::new();
|
||||||
|
let mut used_width: u16 = 0;
|
||||||
|
let gap = 1u16; // 1-char gap between columns.
|
||||||
|
|
||||||
|
for (idx, col) in &indexed {
|
||||||
|
let needed = col.min_width + if result.is_empty() { 0 } else { gap };
|
||||||
|
if used_width + needed <= available_width {
|
||||||
|
result.push((*idx, col.min_width));
|
||||||
|
used_width += needed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute remaining space by flex weight.
|
||||||
|
let remaining = available_width.saturating_sub(used_width);
|
||||||
|
if remaining > 0 {
|
||||||
|
let total_weight: u16 = result
|
||||||
|
.iter()
|
||||||
|
.map(|(idx, _)| columns[*idx].flex_weight)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
if total_weight > 0 {
|
||||||
|
for (idx, width) in &mut result {
|
||||||
|
let weight = columns[*idx].flex_weight;
|
||||||
|
let extra =
|
||||||
|
(u32::from(remaining) * u32::from(weight) / u32::from(total_weight)) as u16;
|
||||||
|
*width += extra;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by original column order for rendering.
|
||||||
|
result.sort_by_key(|(idx, _)| *idx);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the entity table header row.
|
||||||
|
pub fn render_header(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
columns: &[ColumnDef],
|
||||||
|
visible: &[(usize, u16)],
|
||||||
|
state: &EntityTableState,
|
||||||
|
y: u16,
|
||||||
|
area_x: u16,
|
||||||
|
colors: &TableColors,
|
||||||
|
) {
|
||||||
|
let header_cell = Cell {
|
||||||
|
fg: colors.header_fg,
|
||||||
|
bg: colors.header_bg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let sort_cell = Cell {
|
||||||
|
fg: colors.sort_indicator,
|
||||||
|
bg: colors.header_bg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill header background.
|
||||||
|
let total_width: u16 = visible.iter().map(|(_, w)| w + 1).sum();
|
||||||
|
let header_rect = Rect::new(area_x, y, total_width, 1);
|
||||||
|
frame.draw_rect_filled(
|
||||||
|
header_rect,
|
||||||
|
Cell {
|
||||||
|
bg: colors.header_bg,
|
||||||
|
..Cell::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut x = area_x;
|
||||||
|
for (col_idx, col_width) in visible {
|
||||||
|
let col = &columns[*col_idx];
|
||||||
|
let col_max = x.saturating_add(*col_width);
|
||||||
|
|
||||||
|
let after_name = frame.print_text_clipped(x, y, col.name, header_cell, col_max);
|
||||||
|
|
||||||
|
// Sort indicator.
|
||||||
|
if *col_idx == state.sort_column {
|
||||||
|
let arrow = if state.sort_ascending { " ^" } else { " v" };
|
||||||
|
frame.print_text_clipped(after_name, y, arrow, sort_cell, col_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
x = col_max.saturating_add(1); // gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style context for rendering a single row.
|
||||||
|
pub struct RowContext<'a> {
|
||||||
|
pub columns: &'a [ColumnDef],
|
||||||
|
pub visible: &'a [(usize, u16)],
|
||||||
|
pub is_selected: bool,
|
||||||
|
pub is_alt: bool,
|
||||||
|
pub colors: &'a TableColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a data row.
|
||||||
|
pub fn render_row<R: TableRow>(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
row: &R,
|
||||||
|
y: u16,
|
||||||
|
area_x: u16,
|
||||||
|
ctx: &RowContext<'_>,
|
||||||
|
) {
|
||||||
|
let (fg, bg) = if ctx.is_selected {
|
||||||
|
(ctx.colors.selected_fg, ctx.colors.selected_bg)
|
||||||
|
} else if ctx.is_alt {
|
||||||
|
(ctx.colors.row_fg, ctx.colors.row_alt_bg)
|
||||||
|
} else {
|
||||||
|
(ctx.colors.row_fg, Cell::default().bg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let cell_style = Cell {
|
||||||
|
fg,
|
||||||
|
bg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill row background if selected or alt.
|
||||||
|
if ctx.is_selected || ctx.is_alt {
|
||||||
|
let total_width: u16 = ctx.visible.iter().map(|(_, w)| w + 1).sum();
|
||||||
|
frame.draw_rect_filled(
|
||||||
|
Rect::new(area_x, y, total_width, 1),
|
||||||
|
Cell {
|
||||||
|
bg,
|
||||||
|
..Cell::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cells = row.cells(ctx.columns.len());
|
||||||
|
let mut x = area_x;
|
||||||
|
|
||||||
|
for (col_idx, col_width) in ctx.visible {
|
||||||
|
let col_max = x.saturating_add(*col_width);
|
||||||
|
let text = cells.get(*col_idx).map(String::as_str).unwrap_or("");
|
||||||
|
|
||||||
|
match ctx.columns[*col_idx].align {
|
||||||
|
Align::Left => {
|
||||||
|
frame.print_text_clipped(x, y, text, cell_style, col_max);
|
||||||
|
}
|
||||||
|
Align::Right => {
|
||||||
|
let text_len = text.len() as u16;
|
||||||
|
let start = if text_len < *col_width {
|
||||||
|
x + col_width - text_len
|
||||||
|
} else {
|
||||||
|
x
|
||||||
|
};
|
||||||
|
frame.print_text_clipped(start, y, text, cell_style, col_max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
x = col_max.saturating_add(1); // gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a complete entity table: header + scrollable rows.
|
||||||
|
pub fn render_entity_table<R: TableRow>(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
rows: &[R],
|
||||||
|
columns: &[ColumnDef],
|
||||||
|
state: &mut EntityTableState,
|
||||||
|
area: Rect,
|
||||||
|
colors: &TableColors,
|
||||||
|
) {
|
||||||
|
if area.height < 2 || area.width < 5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible = visible_columns(columns, area.width);
|
||||||
|
if visible.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header row.
|
||||||
|
render_header(frame, columns, &visible, state, area.y, area.x, colors);
|
||||||
|
|
||||||
|
// Separator.
|
||||||
|
let sep_y = area.y.saturating_add(1);
|
||||||
|
let sep_cell = Cell {
|
||||||
|
fg: colors.border,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let rule = "─".repeat(area.width as usize);
|
||||||
|
frame.print_text_clipped(
|
||||||
|
area.x,
|
||||||
|
sep_y,
|
||||||
|
&rule,
|
||||||
|
sep_cell,
|
||||||
|
area.x.saturating_add(area.width),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data rows.
|
||||||
|
let data_start_y = area.y.saturating_add(2);
|
||||||
|
let visible_rows = area.height.saturating_sub(2) as usize; // minus header + separator
|
||||||
|
|
||||||
|
state.adjust_scroll(visible_rows);
|
||||||
|
|
||||||
|
let start = state.scroll_offset;
|
||||||
|
let end = (start + visible_rows).min(rows.len());
|
||||||
|
|
||||||
|
for (i, row) in rows[start..end].iter().enumerate() {
|
||||||
|
let row_y = data_start_y.saturating_add(i as u16);
|
||||||
|
let absolute_idx = start + i;
|
||||||
|
let ctx = RowContext {
|
||||||
|
columns,
|
||||||
|
visible: &visible,
|
||||||
|
is_selected: absolute_idx == state.selected,
|
||||||
|
is_alt: absolute_idx % 2 == 1,
|
||||||
|
colors,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_row(frame, row, row_y, area.x, &ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll indicator if more rows below.
|
||||||
|
if end < rows.len() {
|
||||||
|
let indicator_y = data_start_y.saturating_add(visible_rows as u16);
|
||||||
|
if indicator_y < area.y.saturating_add(area.height) {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: colors.border,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let remaining = rows.len() - end;
|
||||||
|
frame.print_text_clipped(
|
||||||
|
area.x,
|
||||||
|
indicator_y,
|
||||||
|
&format!("... {remaining} more"),
|
||||||
|
muted,
|
||||||
|
area.x.saturating_add(area.width),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ftui::render::grapheme_pool::GraphemePool;
|
||||||
|
|
||||||
|
macro_rules! with_frame {
|
||||||
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||||
|
let mut pool = GraphemePool::new();
|
||||||
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||||
|
$body
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_columns() -> Vec<ColumnDef> {
|
||||||
|
vec![
|
||||||
|
ColumnDef {
|
||||||
|
name: "IID",
|
||||||
|
min_width: 5,
|
||||||
|
flex_weight: 0,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Right,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Title",
|
||||||
|
min_width: 10,
|
||||||
|
flex_weight: 3,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "State",
|
||||||
|
min_width: 8,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 1,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Author",
|
||||||
|
min_width: 8,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 2,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Updated",
|
||||||
|
min_width: 10,
|
||||||
|
flex_weight: 0,
|
||||||
|
priority: 3,
|
||||||
|
align: Align::Right,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestRow {
|
||||||
|
cells: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableRow for TestRow {
|
||||||
|
fn cells(&self, _col_count: usize) -> Vec<String> {
|
||||||
|
self.cells.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_colors() -> TableColors {
|
||||||
|
TableColors {
|
||||||
|
header_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
header_bg: PackedRgba::rgb(0x30, 0x30, 0x30),
|
||||||
|
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
row_alt_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||||
|
selected_fg: PackedRgba::rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
selected_bg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
|
||||||
|
sort_indicator: PackedRgba::rgb(0xDA, 0x70, 0x2C),
|
||||||
|
border: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_visible_columns_all_fit() {
|
||||||
|
let cols = test_columns();
|
||||||
|
let vis = visible_columns(&cols, 100);
|
||||||
|
assert_eq!(vis.len(), 5, "All 5 columns should fit at 100 cols");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_visible_columns_hides_low_priority() {
|
||||||
|
let cols = test_columns();
|
||||||
|
// min widths: 5 + 10 + 8 + 8 + 10 + 4 gaps = 45.
|
||||||
|
// At 25 cols, only priority 0 columns (IID + Title) should fit.
|
||||||
|
let vis = visible_columns(&cols, 25);
|
||||||
|
let visible_indices: Vec<usize> = vis.iter().map(|(idx, _)| *idx).collect();
|
||||||
|
assert!(visible_indices.contains(&0), "IID should always be visible");
|
||||||
|
assert!(
|
||||||
|
visible_indices.contains(&1),
|
||||||
|
"Title should always be visible"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!visible_indices.contains(&4),
|
||||||
|
"Updated (priority 3) should be hidden"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_column_hiding_at_60_cols() {
|
||||||
|
let cols = test_columns();
|
||||||
|
let vis = visible_columns(&cols, 60);
|
||||||
|
// min widths for priority 0,1,2: 5+10+8+8 + 3 gaps = 34.
|
||||||
|
// Priority 3 (Updated, min 10 + gap) = 45 total, should still fit.
|
||||||
|
assert!(vis.len() >= 3, "At least 3 columns at 60 cols");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_select_next_prev() {
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
state.select_next(5);
|
||||||
|
assert_eq!(state.selected, 1);
|
||||||
|
state.select_next(5);
|
||||||
|
assert_eq!(state.selected, 2);
|
||||||
|
state.select_prev();
|
||||||
|
assert_eq!(state.selected, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_select_bounds() {
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
state.select_prev(); // at 0, can't go below
|
||||||
|
assert_eq!(state.selected, 0);
|
||||||
|
|
||||||
|
state.select_next(3);
|
||||||
|
state.select_next(3);
|
||||||
|
state.select_next(3); // at 2, can't go above last
|
||||||
|
assert_eq!(state.selected, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_page_up_down() {
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
state.page_down(20, 5);
|
||||||
|
assert_eq!(state.selected, 5);
|
||||||
|
state.page_up(3);
|
||||||
|
assert_eq!(state.selected, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_first_last() {
|
||||||
|
let mut state = EntityTableState {
|
||||||
|
selected: 5,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.select_first();
|
||||||
|
assert_eq!(state.selected, 0);
|
||||||
|
state.select_last(10);
|
||||||
|
assert_eq!(state.selected, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_cycle_sort() {
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
assert_eq!(state.sort_column, 0);
|
||||||
|
state.cycle_sort(5);
|
||||||
|
assert_eq!(state.sort_column, 1);
|
||||||
|
state.sort_column = 4;
|
||||||
|
state.cycle_sort(5); // wraps to 0
|
||||||
|
assert_eq!(state.sort_column, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_toggle_sort_direction() {
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
assert!(state.sort_ascending);
|
||||||
|
state.toggle_sort_direction();
|
||||||
|
assert!(!state.sort_ascending);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_adjust_scroll() {
|
||||||
|
let mut state = EntityTableState {
|
||||||
|
selected: 15,
|
||||||
|
scroll_offset: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.adjust_scroll(10);
|
||||||
|
assert_eq!(state.scroll_offset, 6); // selected=15 should be at bottom of 10-row window
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entity_table_no_panic() {
|
||||||
|
with_frame!(80, 20, |frame| {
|
||||||
|
let cols = test_columns();
|
||||||
|
let rows = vec![
|
||||||
|
TestRow {
|
||||||
|
cells: vec![
|
||||||
|
"#42".into(),
|
||||||
|
"Fix auth bug".into(),
|
||||||
|
"opened".into(),
|
||||||
|
"taylor".into(),
|
||||||
|
"2h ago".into(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
TestRow {
|
||||||
|
cells: vec![
|
||||||
|
"#43".into(),
|
||||||
|
"Add tests".into(),
|
||||||
|
"merged".into(),
|
||||||
|
"alice".into(),
|
||||||
|
"1d ago".into(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
let colors = test_colors();
|
||||||
|
|
||||||
|
render_entity_table(
|
||||||
|
&mut frame,
|
||||||
|
&rows,
|
||||||
|
&cols,
|
||||||
|
&mut state,
|
||||||
|
Rect::new(0, 0, 80, 20),
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entity_table_tiny_noop() {
|
||||||
|
with_frame!(3, 1, |frame| {
|
||||||
|
let cols = test_columns();
|
||||||
|
let rows: Vec<TestRow> = vec![];
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
let colors = test_colors();
|
||||||
|
|
||||||
|
render_entity_table(
|
||||||
|
&mut frame,
|
||||||
|
&rows,
|
||||||
|
&cols,
|
||||||
|
&mut state,
|
||||||
|
Rect::new(0, 0, 3, 1),
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_entity_table_empty_rows() {
|
||||||
|
with_frame!(80, 10, |frame| {
|
||||||
|
let cols = test_columns();
|
||||||
|
let rows: Vec<TestRow> = vec![];
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
let colors = test_colors();
|
||||||
|
|
||||||
|
render_entity_table(
|
||||||
|
&mut frame,
|
||||||
|
&rows,
|
||||||
|
&cols,
|
||||||
|
&mut state,
|
||||||
|
Rect::new(0, 0, 80, 10),
|
||||||
|
&colors,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state_select_next_empty() {
|
||||||
|
let mut state = EntityTableState::default();
|
||||||
|
state.select_next(0); // no rows
|
||||||
|
assert_eq!(state.selected, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,15 @@ pub fn render_error_toast(
|
|||||||
let max_toast_width = (area.width / 2).clamp(20, 60);
|
let max_toast_width = (area.width / 2).clamp(20, 60);
|
||||||
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
|
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;
|
let trunc_len = max_toast_width.saturating_sub(7) as usize;
|
||||||
format!(" {}... ", &msg[..trunc_len.min(msg.len())])
|
// Find a char boundary at or before trunc_len to avoid panicking
|
||||||
|
// on multi-byte UTF-8 (e.g., emoji or CJK in error messages).
|
||||||
|
let safe_end = msg
|
||||||
|
.char_indices()
|
||||||
|
.take_while(|&(i, _)| i <= trunc_len)
|
||||||
|
.last()
|
||||||
|
.map_or(0, |(i, c)| i + c.len_utf8())
|
||||||
|
.min(msg.len());
|
||||||
|
format!(" {}... ", &msg[..safe_end])
|
||||||
} else {
|
} else {
|
||||||
format!(" {msg} ")
|
format!(" {msg} ")
|
||||||
};
|
};
|
||||||
|
|||||||
469
crates/lore-tui/src/view/common/filter_bar.rs
Normal file
469
crates/lore-tui/src/view/common/filter_bar.rs
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
#![allow(dead_code)] // Phase 2: consumed by Issue List + MR List screens
|
||||||
|
|
||||||
|
//! Filter bar widget for list screens.
|
||||||
|
//!
|
||||||
|
//! Wraps a text input with DSL parsing, inline diagnostics for unknown
|
||||||
|
//! fields, and rendered filter chips below the input.
|
||||||
|
|
||||||
|
use ftui::core::geometry::Rect;
|
||||||
|
use ftui::render::cell::{Cell, PackedRgba};
|
||||||
|
use ftui::render::drawing::Draw;
|
||||||
|
use ftui::render::frame::Frame;
|
||||||
|
|
||||||
|
use crate::filter_dsl::{self, FilterToken};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter bar state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// State for the filter bar widget.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FilterBarState {
|
||||||
|
/// Current filter input text.
|
||||||
|
pub input: String,
|
||||||
|
/// Cursor position within the input string (byte offset).
|
||||||
|
pub cursor: usize,
|
||||||
|
/// Whether the filter bar has focus.
|
||||||
|
pub focused: bool,
|
||||||
|
/// Parsed tokens from the current input.
|
||||||
|
pub tokens: Vec<FilterToken>,
|
||||||
|
/// Fields that are unknown for the current entity type.
|
||||||
|
pub unknown_fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterBarState {
|
||||||
|
/// Update parsed tokens from the current input text.
|
||||||
|
pub fn reparse(&mut self, known_fields: &[&str]) {
|
||||||
|
self.tokens = filter_dsl::parse_filter_tokens(&self.input);
|
||||||
|
self.unknown_fields = filter_dsl::unknown_fields(&self.tokens, known_fields)
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a character at the cursor position.
|
||||||
|
pub fn insert_char(&mut self, ch: char) {
|
||||||
|
if self.cursor > self.input.len() {
|
||||||
|
self.cursor = self.input.len();
|
||||||
|
}
|
||||||
|
self.input.insert(self.cursor, ch);
|
||||||
|
self.cursor += ch.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the character before the cursor (backspace).
|
||||||
|
pub fn delete_back(&mut self) {
|
||||||
|
if self.cursor > 0 && !self.input.is_empty() {
|
||||||
|
// Find the previous character boundary.
|
||||||
|
let prev = self.input[..self.cursor]
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.input.remove(prev);
|
||||||
|
self.cursor = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the character at the cursor (delete key).
|
||||||
|
pub fn delete_forward(&mut self) {
|
||||||
|
if self.cursor < self.input.len() {
|
||||||
|
self.input.remove(self.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor left by one character.
|
||||||
|
pub fn move_left(&mut self) {
|
||||||
|
if self.cursor > 0 {
|
||||||
|
self.cursor = self.input[..self.cursor]
|
||||||
|
.char_indices()
|
||||||
|
.next_back()
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor right by one character.
|
||||||
|
pub fn move_right(&mut self) {
|
||||||
|
if self.cursor < self.input.len() {
|
||||||
|
self.cursor = self.input[self.cursor..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.map(|ch| self.cursor + ch.len_utf8())
|
||||||
|
.unwrap_or(self.input.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor to start.
|
||||||
|
pub fn move_home(&mut self) {
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move cursor to end.
|
||||||
|
pub fn move_end(&mut self) {
|
||||||
|
self.cursor = self.input.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the input.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.input.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
self.tokens.clear();
|
||||||
|
self.unknown_fields.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the filter has any active tokens.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
!self.tokens.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Colors for the filter bar.
|
||||||
|
pub struct FilterBarColors {
|
||||||
|
pub input_fg: PackedRgba,
|
||||||
|
pub input_bg: PackedRgba,
|
||||||
|
pub cursor_fg: PackedRgba,
|
||||||
|
pub cursor_bg: PackedRgba,
|
||||||
|
pub chip_fg: PackedRgba,
|
||||||
|
pub chip_bg: PackedRgba,
|
||||||
|
pub error_fg: PackedRgba,
|
||||||
|
pub label_fg: PackedRgba,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the filter bar.
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
/// ```text
|
||||||
|
/// Row 0: [Filter: ][input text with cursor___________]
|
||||||
|
/// Row 1: [chip1] [chip2] [chip3] (if tokens present)
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Returns the number of rows consumed (1 or 2).
|
||||||
|
pub fn render_filter_bar(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
state: &FilterBarState,
|
||||||
|
area: Rect,
|
||||||
|
colors: &FilterBarColors,
|
||||||
|
) -> u16 {
|
||||||
|
if area.height == 0 || area.width < 10 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_x = area.x.saturating_add(area.width);
|
||||||
|
let y = area.y;
|
||||||
|
|
||||||
|
// Label.
|
||||||
|
let label = if state.focused { "Filter: " } else { "/ " };
|
||||||
|
let label_cell = Cell {
|
||||||
|
fg: colors.label_fg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let after_label = frame.print_text_clipped(area.x, y, label, label_cell, max_x);
|
||||||
|
|
||||||
|
// Input text.
|
||||||
|
let input_cell = Cell {
|
||||||
|
fg: colors.input_fg,
|
||||||
|
bg: if state.focused {
|
||||||
|
colors.input_bg
|
||||||
|
} else {
|
||||||
|
Cell::default().bg
|
||||||
|
},
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if state.input.is_empty() && !state.focused {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: colors.label_fg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
frame.print_text_clipped(after_label, y, "type / to filter", muted, max_x);
|
||||||
|
} else {
|
||||||
|
// Render input text with cursor highlight.
|
||||||
|
render_input_with_cursor(frame, state, after_label, y, max_x, input_cell, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error indicators for unknown fields.
|
||||||
|
if !state.unknown_fields.is_empty() {
|
||||||
|
let err_cell = Cell {
|
||||||
|
fg: colors.error_fg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let err_msg = format!("Unknown: {}", state.unknown_fields.join(", "));
|
||||||
|
// Right-align the error.
|
||||||
|
let err_x = max_x.saturating_sub(err_msg.len() as u16 + 1);
|
||||||
|
frame.print_text_clipped(err_x, y, &err_msg, err_cell, max_x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chip row (if tokens present and space available).
|
||||||
|
if !state.tokens.is_empty() && area.height >= 2 {
|
||||||
|
let chip_y = y.saturating_add(1);
|
||||||
|
render_chips(frame, &state.tokens, area.x, chip_y, max_x, colors);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render input text with cursor highlight at the correct position.
|
||||||
|
fn render_input_with_cursor(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
state: &FilterBarState,
|
||||||
|
start_x: u16,
|
||||||
|
y: u16,
|
||||||
|
max_x: u16,
|
||||||
|
base_cell: Cell,
|
||||||
|
colors: &FilterBarColors,
|
||||||
|
) {
|
||||||
|
if !state.focused {
|
||||||
|
frame.print_text_clipped(start_x, y, &state.input, base_cell, max_x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split at cursor position.
|
||||||
|
let cursor = state.cursor;
|
||||||
|
let input = &state.input;
|
||||||
|
let (before, after) = if cursor <= input.len() {
|
||||||
|
(&input[..cursor], &input[cursor..])
|
||||||
|
} else {
|
||||||
|
(input.as_str(), "")
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut x = frame.print_text_clipped(start_x, y, before, base_cell, max_x);
|
||||||
|
|
||||||
|
// Cursor character (or space if at end).
|
||||||
|
let cursor_cell = Cell {
|
||||||
|
fg: colors.cursor_fg,
|
||||||
|
bg: colors.cursor_bg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ch) = after.chars().next() {
|
||||||
|
let s = String::from(ch);
|
||||||
|
x = frame.print_text_clipped(x, y, &s, cursor_cell, max_x);
|
||||||
|
let remaining = &after[ch.len_utf8()..];
|
||||||
|
frame.print_text_clipped(x, y, remaining, base_cell, max_x);
|
||||||
|
} else {
|
||||||
|
// Cursor at end — render a visible block.
|
||||||
|
frame.print_text_clipped(x, y, " ", cursor_cell, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render filter chips as compact tags.
|
||||||
|
fn render_chips(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
tokens: &[FilterToken],
|
||||||
|
start_x: u16,
|
||||||
|
y: u16,
|
||||||
|
max_x: u16,
|
||||||
|
colors: &FilterBarColors,
|
||||||
|
) {
|
||||||
|
let chip_cell = Cell {
|
||||||
|
fg: colors.chip_fg,
|
||||||
|
bg: colors.chip_bg,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut x = start_x;
|
||||||
|
|
||||||
|
for token in tokens {
|
||||||
|
if x >= max_x {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = match token {
|
||||||
|
FilterToken::FieldValue { field, value } => format!("{field}:{value}"),
|
||||||
|
FilterToken::Negation { field, value } => format!("-{field}:{value}"),
|
||||||
|
FilterToken::FreeText(text) => text.clone(),
|
||||||
|
FilterToken::QuotedValue(text) => format!("\"{text}\""),
|
||||||
|
};
|
||||||
|
|
||||||
|
let chip_text = format!("[{label}]");
|
||||||
|
x = frame.print_text_clipped(x, y, &chip_text, chip_cell, max_x);
|
||||||
|
x = x.saturating_add(1); // gap between chips
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::filter_dsl::ISSUE_FIELDS;
|
||||||
|
use ftui::render::grapheme_pool::GraphemePool;
|
||||||
|
|
||||||
|
macro_rules! with_frame {
|
||||||
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||||
|
let mut pool = GraphemePool::new();
|
||||||
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||||
|
$body
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_colors() -> FilterBarColors {
|
||||||
|
FilterBarColors {
|
||||||
|
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||||
|
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
|
||||||
|
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||||
|
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
|
||||||
|
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_insert_char() {
|
||||||
|
let mut state = FilterBarState::default();
|
||||||
|
state.insert_char('a');
|
||||||
|
state.insert_char('b');
|
||||||
|
assert_eq!(state.input, "ab");
|
||||||
|
assert_eq!(state.cursor, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_delete_back() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "abc".into(),
|
||||||
|
cursor: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.delete_back();
|
||||||
|
assert_eq!(state.input, "ab");
|
||||||
|
assert_eq!(state.cursor, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_delete_back_at_start() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "abc".into(),
|
||||||
|
cursor: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.delete_back();
|
||||||
|
assert_eq!(state.input, "abc");
|
||||||
|
assert_eq!(state.cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_move_left_right() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "abc".into(),
|
||||||
|
cursor: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.move_left();
|
||||||
|
assert_eq!(state.cursor, 1);
|
||||||
|
state.move_right();
|
||||||
|
assert_eq!(state.cursor, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_home_end() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "hello".into(),
|
||||||
|
cursor: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.move_home();
|
||||||
|
assert_eq!(state.cursor, 0);
|
||||||
|
state.move_end();
|
||||||
|
assert_eq!(state.cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_clear() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "state:opened".into(),
|
||||||
|
cursor: 12,
|
||||||
|
tokens: vec![FilterToken::FieldValue {
|
||||||
|
field: "state".into(),
|
||||||
|
value: "opened".into(),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.clear();
|
||||||
|
assert!(state.input.is_empty());
|
||||||
|
assert_eq!(state.cursor, 0);
|
||||||
|
assert!(state.tokens.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_reparse() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "state:opened bogus:val".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.reparse(ISSUE_FIELDS);
|
||||||
|
assert_eq!(state.tokens.len(), 2);
|
||||||
|
assert_eq!(state.unknown_fields, vec!["bogus"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_state_is_active() {
|
||||||
|
let mut state = FilterBarState::default();
|
||||||
|
assert!(!state.is_active());
|
||||||
|
|
||||||
|
state.input = "state:opened".into();
|
||||||
|
state.reparse(ISSUE_FIELDS);
|
||||||
|
assert!(state.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_filter_bar_unfocused_no_panic() {
|
||||||
|
with_frame!(80, 2, |frame| {
|
||||||
|
let state = FilterBarState::default();
|
||||||
|
let colors = test_colors();
|
||||||
|
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_filter_bar_focused_no_panic() {
|
||||||
|
with_frame!(80, 2, |frame| {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "state:opened".into(),
|
||||||
|
cursor: 12,
|
||||||
|
focused: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.reparse(ISSUE_FIELDS);
|
||||||
|
let colors = test_colors();
|
||||||
|
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 80, 2), &colors);
|
||||||
|
assert_eq!(rows, 2); // chips rendered
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_filter_bar_tiny_noop() {
|
||||||
|
with_frame!(5, 1, |frame| {
|
||||||
|
let state = FilterBarState::default();
|
||||||
|
let colors = test_colors();
|
||||||
|
let rows = render_filter_bar(&mut frame, &state, Rect::new(0, 0, 5, 1), &colors);
|
||||||
|
assert_eq!(rows, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_bar_unicode_cursor() {
|
||||||
|
let mut state = FilterBarState {
|
||||||
|
input: "author:田中".into(),
|
||||||
|
cursor: 7, // points at start of 田
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.move_right();
|
||||||
|
assert_eq!(state.cursor, 10); // past 田 (3 bytes)
|
||||||
|
state.move_left();
|
||||||
|
assert_eq!(state.cursor, 7); // back to 田
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,17 @@
|
|||||||
//! no side effects.
|
//! no side effects.
|
||||||
|
|
||||||
mod breadcrumb;
|
mod breadcrumb;
|
||||||
|
pub mod entity_table;
|
||||||
mod error_toast;
|
mod error_toast;
|
||||||
|
pub mod filter_bar;
|
||||||
mod help_overlay;
|
mod help_overlay;
|
||||||
mod loading;
|
mod loading;
|
||||||
mod status_bar;
|
mod status_bar;
|
||||||
|
|
||||||
pub use breadcrumb::render_breadcrumb;
|
pub use breadcrumb::render_breadcrumb;
|
||||||
|
pub use entity_table::{ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table};
|
||||||
pub use error_toast::render_error_toast;
|
pub use error_toast::render_error_toast;
|
||||||
|
pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
|
||||||
pub use help_overlay::render_help_overlay;
|
pub use help_overlay::render_help_overlay;
|
||||||
pub use loading::render_loading;
|
pub use loading::render_loading;
|
||||||
pub use status_bar::render_status_bar;
|
pub use status_bar::render_status_bar;
|
||||||
|
|||||||
554
crates/lore-tui/src/view/dashboard.rs
Normal file
554
crates/lore-tui/src/view/dashboard.rs
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
#![allow(dead_code)] // Phase 2: wired into render_screen dispatch
|
||||||
|
|
||||||
|
//! Dashboard screen view — entity counts, project sync status, recent activity.
|
||||||
|
//!
|
||||||
|
//! Responsive layout using [`crate::layout::classify_width`]:
|
||||||
|
//! - Wide (Lg/Xl, >=120 cols): 3-column `[Stats | Projects | Recent]`
|
||||||
|
//! - Medium (Md, 90–119): 2-column `[Stats+Projects | Recent]`
|
||||||
|
//! - Narrow (Xs/Sm, <90): single column stacked
|
||||||
|
|
||||||
|
use ftui::core::geometry::Rect;
|
||||||
|
use ftui::layout::{Breakpoint, Constraint, Flex};
|
||||||
|
use ftui::render::cell::{Cell, PackedRgba};
|
||||||
|
use ftui::render::drawing::Draw;
|
||||||
|
use ftui::render::frame::Frame;
|
||||||
|
|
||||||
|
use crate::layout::classify_width;
|
||||||
|
use crate::state::dashboard::{DashboardState, EntityCounts, LastSyncInfo, RecentActivityItem};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colors (Flexoki palette — will use injected Theme in a later phase)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||||
|
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||||
|
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
|
||||||
|
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
|
||||||
|
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
|
||||||
|
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
||||||
|
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
|
||||||
|
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the full dashboard screen into `area`.
|
||||||
|
pub fn render_dashboard(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||||
|
if area.height < 2 || area.width < 10 {
|
||||||
|
return; // Too small to render.
|
||||||
|
}
|
||||||
|
|
||||||
|
let bp = classify_width(area.width);
|
||||||
|
|
||||||
|
match bp {
|
||||||
|
Breakpoint::Lg | Breakpoint::Xl => render_wide(frame, state, area),
|
||||||
|
Breakpoint::Md => render_medium(frame, state, area),
|
||||||
|
Breakpoint::Xs | Breakpoint::Sm => render_narrow(frame, state, area),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout variants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Wide: 3-column [Stats | Projects | Recent Activity].
|
||||||
|
fn render_wide(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||||
|
let cols = Flex::horizontal()
|
||||||
|
.constraints([
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_stat_panel(frame, &state.counts, cols[0]);
|
||||||
|
render_project_list(frame, state, cols[1]);
|
||||||
|
render_recent_activity(frame, state, cols[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Medium: 2-column [Stats+Projects stacked | Recent Activity].
|
||||||
|
fn render_medium(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||||
|
let cols = Flex::horizontal()
|
||||||
|
.constraints([Constraint::Ratio(2, 5), Constraint::Ratio(3, 5)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Left column: stats on top, projects below.
|
||||||
|
let left_rows = Flex::vertical()
|
||||||
|
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
|
||||||
|
.split(cols[0]);
|
||||||
|
|
||||||
|
render_stat_panel(frame, &state.counts, left_rows[0]);
|
||||||
|
render_project_list(frame, state, left_rows[1]);
|
||||||
|
|
||||||
|
render_recent_activity(frame, state, cols[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Narrow: single column stacked.
|
||||||
|
fn render_narrow(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||||
|
let rows = Flex::vertical()
|
||||||
|
.constraints([
|
||||||
|
Constraint::Fixed(8), // stats
|
||||||
|
Constraint::Fixed(4), // projects (compact)
|
||||||
|
Constraint::Fill, // recent
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_stat_panel(frame, &state.counts, rows[0]);
|
||||||
|
render_project_list(frame, state, rows[1]);
|
||||||
|
render_recent_activity(frame, state, rows[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panels
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Entity counts panel.
|
||||||
|
fn render_stat_panel(frame: &mut Frame<'_>, counts: &EntityCounts, area: Rect) {
|
||||||
|
if area.height == 0 || area.width < 5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_cell = Cell {
|
||||||
|
fg: ACCENT,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let label_cell = Cell {
|
||||||
|
fg: TEXT_MUTED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let value_cell = Cell {
|
||||||
|
fg: TEXT,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_x = area.x.saturating_add(area.width);
|
||||||
|
let mut y = area.y;
|
||||||
|
let x = area.x.saturating_add(1); // 1-char left padding
|
||||||
|
|
||||||
|
// Title
|
||||||
|
frame.print_text_clipped(x, y, "Entity Counts", title_cell, max_x);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
|
// Stats rows
|
||||||
|
let stats: &[(&str, String)] = &[
|
||||||
|
(
|
||||||
|
"Issues",
|
||||||
|
format!("{} open / {}", counts.issues_open, counts.issues_total),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"MRs",
|
||||||
|
format!("{} open / {}", counts.mrs_open, counts.mrs_total),
|
||||||
|
),
|
||||||
|
("Discussions", counts.discussions.to_string()),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
format!(
|
||||||
|
"{} ({}% system)",
|
||||||
|
counts.notes_total, counts.notes_system_pct
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("Documents", counts.documents.to_string()),
|
||||||
|
("Embeddings", counts.embeddings.to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (label, value) in stats {
|
||||||
|
if y >= area.y.saturating_add(area.height) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let after_label = frame.print_text_clipped(x, y, label, label_cell, max_x);
|
||||||
|
let after_colon = frame.print_text_clipped(after_label, y, ": ", label_cell, max_x);
|
||||||
|
frame.print_text_clipped(after_colon, y, value, value_cell, max_x);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-project sync freshness list.
|
||||||
|
fn render_project_list(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||||
|
if area.height == 0 || area.width < 5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_cell = Cell {
|
||||||
|
fg: ACCENT,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let label_cell = Cell {
|
||||||
|
fg: TEXT,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_x = area.x.saturating_add(area.width);
|
||||||
|
let mut y = area.y;
|
||||||
|
let x = area.x.saturating_add(1);
|
||||||
|
|
||||||
|
frame.print_text_clipped(x, y, "Projects", title_cell, max_x);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
|
if state.projects.is_empty() {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: TEXT_MUTED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
frame.print_text_clipped(x, y, "No projects synced", muted, max_x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for proj in &state.projects {
|
||||||
|
if y >= area.y.saturating_add(area.height) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let freshness_color = staleness_color(proj.minutes_since_sync);
|
||||||
|
let freshness_cell = Cell {
|
||||||
|
fg: freshness_color,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let indicator = staleness_indicator(proj.minutes_since_sync);
|
||||||
|
let after_dot = frame.print_text_clipped(x, y, &indicator, freshness_cell, max_x);
|
||||||
|
let after_space = frame.print_text_clipped(after_dot, y, " ", label_cell, max_x);
|
||||||
|
frame.print_text_clipped(after_space, y, &proj.path, label_cell, max_x);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last sync summary if available.
|
||||||
|
if let Some(ref sync) = state.last_sync
|
||||||
|
&& y < area.y.saturating_add(area.height)
|
||||||
|
{
|
||||||
|
y = y.saturating_add(1); // blank line
|
||||||
|
render_sync_summary(frame, sync, x, y, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrollable recent activity list.
|
||||||
|
fn render_recent_activity(frame: &mut Frame<'_>, state: &DashboardState, area: Rect) {
|
||||||
|
if area.height == 0 || area.width < 5 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_cell = Cell {
|
||||||
|
fg: ACCENT,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_x = area.x.saturating_add(area.width);
|
||||||
|
let mut y = area.y;
|
||||||
|
let x = area.x.saturating_add(1);
|
||||||
|
|
||||||
|
frame.print_text_clipped(x, y, "Recent Activity", title_cell, max_x);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
render_horizontal_rule(frame, area.x, y, area.width, BORDER);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
|
||||||
|
if state.recent.is_empty() {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: TEXT_MUTED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
frame.print_text_clipped(x, y, "No recent activity", muted, max_x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_rows = (area.y.saturating_add(area.height)).saturating_sub(y) as usize;
|
||||||
|
let items = &state.recent;
|
||||||
|
let start = state.scroll_offset.min(items.len().saturating_sub(1));
|
||||||
|
let end = (start + visible_rows).min(items.len());
|
||||||
|
|
||||||
|
for item in &items[start..end] {
|
||||||
|
if y >= area.y.saturating_add(area.height) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
render_activity_row(frame, item, x, y, max_x);
|
||||||
|
y = y.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll indicator if there's more content.
|
||||||
|
if end < items.len() && y < area.y.saturating_add(area.height) {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: TEXT_MUTED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let remaining = items.len() - end;
|
||||||
|
frame.print_text_clipped(x, y, &format!("... {remaining} more"), muted, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render a single recent activity row.
|
||||||
|
fn render_activity_row(
|
||||||
|
frame: &mut Frame<'_>,
|
||||||
|
item: &RecentActivityItem,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
max_x: u16,
|
||||||
|
) {
|
||||||
|
let type_color = if item.entity_type == "issue" {
|
||||||
|
CYAN
|
||||||
|
} else {
|
||||||
|
ACCENT
|
||||||
|
};
|
||||||
|
let type_cell = Cell {
|
||||||
|
fg: type_color,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let text_cell = Cell {
|
||||||
|
fg: TEXT,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let muted_cell = Cell {
|
||||||
|
fg: TEXT_MUTED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let type_label = if item.entity_type == "issue" {
|
||||||
|
format!("#{}", item.iid)
|
||||||
|
} else {
|
||||||
|
format!("!{}", item.iid)
|
||||||
|
};
|
||||||
|
|
||||||
|
let after_type = frame.print_text_clipped(x, y, &type_label, type_cell, max_x);
|
||||||
|
let after_space = frame.print_text_clipped(after_type, y, " ", text_cell, max_x);
|
||||||
|
|
||||||
|
// Truncate title to leave room for time.
|
||||||
|
let time_str = format_relative_time(item.minutes_ago);
|
||||||
|
let time_width = time_str.len() as u16 + 2; // " " + time
|
||||||
|
let title_max = max_x.saturating_sub(time_width);
|
||||||
|
|
||||||
|
let after_title = frame.print_text_clipped(after_space, y, &item.title, text_cell, title_max);
|
||||||
|
|
||||||
|
// Right-align time string.
|
||||||
|
let time_x = max_x.saturating_sub(time_str.len() as u16 + 1);
|
||||||
|
if time_x > after_title {
|
||||||
|
frame.print_text_clipped(time_x, y, &time_str, muted_cell, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a last-sync summary line.
|
||||||
|
fn render_sync_summary(frame: &mut Frame<'_>, sync: &LastSyncInfo, x: u16, y: u16, max_x: u16) {
|
||||||
|
let status_color = if sync.status == "succeeded" {
|
||||||
|
GREEN
|
||||||
|
} else {
|
||||||
|
RED
|
||||||
|
};
|
||||||
|
let cell = Cell {
|
||||||
|
fg: status_color,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let muted = Cell {
|
||||||
|
fg: TEXT_MUTED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let label_end = frame.print_text_clipped(x, y, "Last sync: ", muted, max_x);
|
||||||
|
let status_end = frame.print_text_clipped(label_end, y, &sync.status, cell, max_x);
|
||||||
|
|
||||||
|
if let Some(ref err) = sync.error {
|
||||||
|
let err_cell = Cell {
|
||||||
|
fg: RED,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let after_space = frame.print_text_clipped(status_end, y, " — ", muted, max_x);
|
||||||
|
frame.print_text_clipped(after_space, y, err, err_cell, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a horizontal rule across a row.
|
||||||
|
fn render_horizontal_rule(frame: &mut Frame<'_>, x: u16, y: u16, width: u16, color: PackedRgba) {
|
||||||
|
let cell = Cell {
|
||||||
|
fg: color,
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
let rule = "─".repeat(width as usize);
|
||||||
|
frame.print_text_clipped(x, y, &rule, cell, x.saturating_add(width));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staleness color: green <60min, yellow <360min, red >360min.
|
||||||
|
const fn staleness_color(minutes: u64) -> PackedRgba {
|
||||||
|
if minutes == u64::MAX {
|
||||||
|
RED // Never synced.
|
||||||
|
} else if minutes < 60 {
|
||||||
|
GREEN
|
||||||
|
} else if minutes < 360 {
|
||||||
|
YELLOW
|
||||||
|
} else {
|
||||||
|
RED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staleness dot indicator.
|
||||||
|
fn staleness_indicator(minutes: u64) -> String {
|
||||||
|
if minutes == u64::MAX {
|
||||||
|
"● never".to_string()
|
||||||
|
} else if minutes < 60 {
|
||||||
|
format!("● {minutes}m ago")
|
||||||
|
} else if minutes < 1440 {
|
||||||
|
format!("● {}h ago", minutes / 60)
|
||||||
|
} else {
|
||||||
|
format!("● {}d ago", minutes / 1440)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format relative time for activity feed.
|
||||||
|
fn format_relative_time(minutes: u64) -> String {
|
||||||
|
if minutes == 0 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if minutes < 60 {
|
||||||
|
format!("{minutes}m ago")
|
||||||
|
} else if minutes < 1440 {
|
||||||
|
format!("{}h ago", minutes / 60)
|
||||||
|
} else {
|
||||||
|
format!("{}d ago", minutes / 1440)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::state::dashboard::{DashboardData, EntityCounts, ProjectSyncInfo};
|
||||||
|
use ftui::render::grapheme_pool::GraphemePool;
|
||||||
|
|
||||||
|
macro_rules! with_frame {
|
||||||
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||||
|
let mut pool = GraphemePool::new();
|
||||||
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||||
|
$body
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_state() -> DashboardState {
|
||||||
|
let mut state = DashboardState::default();
|
||||||
|
state.update(DashboardData {
|
||||||
|
counts: EntityCounts {
|
||||||
|
issues_open: 42,
|
||||||
|
issues_total: 100,
|
||||||
|
mrs_open: 10,
|
||||||
|
mrs_total: 50,
|
||||||
|
discussions: 200,
|
||||||
|
notes_total: 500,
|
||||||
|
notes_system_pct: 30,
|
||||||
|
documents: 80,
|
||||||
|
embeddings: 75,
|
||||||
|
},
|
||||||
|
projects: vec![
|
||||||
|
ProjectSyncInfo {
|
||||||
|
path: "group/alpha".into(),
|
||||||
|
minutes_since_sync: 15,
|
||||||
|
},
|
||||||
|
ProjectSyncInfo {
|
||||||
|
path: "group/beta".into(),
|
||||||
|
minutes_since_sync: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recent: vec![RecentActivityItem {
|
||||||
|
entity_type: "issue".into(),
|
||||||
|
iid: 42,
|
||||||
|
title: "Fix authentication bug".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
minutes_ago: 5,
|
||||||
|
}],
|
||||||
|
last_sync: None,
|
||||||
|
});
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_dashboard_wide_no_panic() {
|
||||||
|
with_frame!(140, 30, |frame| {
|
||||||
|
let state = sample_state();
|
||||||
|
let area = Rect::new(0, 0, 140, 30);
|
||||||
|
render_dashboard(&mut frame, &state, area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_dashboard_medium_no_panic() {
|
||||||
|
with_frame!(100, 24, |frame| {
|
||||||
|
let state = sample_state();
|
||||||
|
let area = Rect::new(0, 0, 100, 24);
|
||||||
|
render_dashboard(&mut frame, &state, area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_dashboard_narrow_no_panic() {
|
||||||
|
with_frame!(60, 20, |frame| {
|
||||||
|
let state = sample_state();
|
||||||
|
let area = Rect::new(0, 0, 60, 20);
|
||||||
|
render_dashboard(&mut frame, &state, area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_dashboard_tiny_noop() {
|
||||||
|
with_frame!(5, 1, |frame| {
|
||||||
|
let state = DashboardState::default();
|
||||||
|
let area = Rect::new(0, 0, 5, 1);
|
||||||
|
render_dashboard(&mut frame, &state, area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_dashboard_empty_state_no_panic() {
|
||||||
|
with_frame!(120, 24, |frame| {
|
||||||
|
let state = DashboardState::default();
|
||||||
|
let area = Rect::new(0, 0, 120, 24);
|
||||||
|
render_dashboard(&mut frame, &state, area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_staleness_color_thresholds() {
|
||||||
|
assert_eq!(staleness_color(0), GREEN);
|
||||||
|
assert_eq!(staleness_color(59), GREEN);
|
||||||
|
assert_eq!(staleness_color(60), YELLOW);
|
||||||
|
assert_eq!(staleness_color(359), YELLOW);
|
||||||
|
assert_eq!(staleness_color(360), RED);
|
||||||
|
assert_eq!(staleness_color(u64::MAX), RED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_staleness_indicator() {
|
||||||
|
assert_eq!(staleness_indicator(15), "● 15m ago");
|
||||||
|
assert_eq!(staleness_indicator(120), "● 2h ago");
|
||||||
|
assert_eq!(staleness_indicator(2880), "● 2d ago");
|
||||||
|
assert_eq!(staleness_indicator(u64::MAX), "● never");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_relative_time() {
|
||||||
|
assert_eq!(format_relative_time(0), "just now");
|
||||||
|
assert_eq!(format_relative_time(5), "5m ago");
|
||||||
|
assert_eq!(format_relative_time(90), "1h ago");
|
||||||
|
assert_eq!(format_relative_time(1500), "1d ago");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stat_panel_renders_title() {
|
||||||
|
with_frame!(40, 10, |frame| {
|
||||||
|
let counts = EntityCounts {
|
||||||
|
issues_open: 3,
|
||||||
|
issues_total: 10,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
render_stat_panel(&mut frame, &counts, Rect::new(0, 0, 40, 10));
|
||||||
|
|
||||||
|
// Check that 'E' from "Entity Counts" is rendered at x=1, y=0.
|
||||||
|
let cell = frame.buffer.get(1, 0).unwrap();
|
||||||
|
assert_eq!(cell.content.as_char(), Some('E'), "Expected 'E' at (1,0)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
353
crates/lore-tui/src/view/issue_list.rs
Normal file
353
crates/lore-tui/src/view/issue_list.rs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||||
|
|
||||||
|
//! Issue list screen view.
|
||||||
|
//!
|
||||||
|
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
|
||||||
|
//! with issue-specific column definitions and [`TableRow`] implementation.
|
||||||
|
|
||||||
|
use ftui::core::geometry::Rect;
|
||||||
|
use ftui::render::cell::{Cell, PackedRgba};
|
||||||
|
use ftui::render::drawing::Draw;
|
||||||
|
use ftui::render::frame::Frame;
|
||||||
|
|
||||||
|
use crate::state::issue_list::{IssueListRow, IssueListState, SortField, SortOrder};
|
||||||
|
use crate::view::common::entity_table::{
|
||||||
|
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
|
||||||
|
};
|
||||||
|
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TableRow implementation for IssueListRow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl TableRow for IssueListRow {
|
||||||
|
fn cells(&self, col_count: usize) -> Vec<String> {
|
||||||
|
let mut cells = Vec::with_capacity(col_count);
|
||||||
|
|
||||||
|
// Column order must match ISSUE_COLUMNS definition.
|
||||||
|
// 0: IID
|
||||||
|
cells.push(format!("#{}", self.iid));
|
||||||
|
// 1: Title
|
||||||
|
cells.push(self.title.clone());
|
||||||
|
// 2: State
|
||||||
|
cells.push(self.state.clone());
|
||||||
|
// 3: Author
|
||||||
|
cells.push(self.author.clone());
|
||||||
|
// 4: Labels
|
||||||
|
cells.push(self.labels.join(", "));
|
||||||
|
// 5: Project
|
||||||
|
cells.push(self.project_path.clone());
|
||||||
|
|
||||||
|
cells.truncate(col_count);
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Column definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Column definitions for the issue list table.
|
||||||
|
const ISSUE_COLUMNS: &[ColumnDef] = &[
|
||||||
|
ColumnDef {
|
||||||
|
name: "IID",
|
||||||
|
min_width: 5,
|
||||||
|
flex_weight: 0,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Right,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Title",
|
||||||
|
min_width: 15,
|
||||||
|
flex_weight: 4,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "State",
|
||||||
|
min_width: 7,
|
||||||
|
flex_weight: 0,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Author",
|
||||||
|
min_width: 8,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 1,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Labels",
|
||||||
|
min_width: 10,
|
||||||
|
flex_weight: 2,
|
||||||
|
priority: 2,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Project",
|
||||||
|
min_width: 12,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 3,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn table_colors() -> TableColors {
|
||||||
|
TableColors {
|
||||||
|
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
|
||||||
|
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
|
||||||
|
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||||
|
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
|
||||||
|
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_colors() -> FilterBarColors {
|
||||||
|
FilterBarColors {
|
||||||
|
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||||
|
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
|
||||||
|
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||||
|
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
|
||||||
|
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the full issue list screen.
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
/// ```text
|
||||||
|
/// Row 0: [Filter bar: / filter input_________]
|
||||||
|
/// Row 1: [chip1] [chip2] (if filter active)
|
||||||
|
/// Row 2: ─────────────────────────────────────
|
||||||
|
/// Row 3..N: IID Title State Author ...
|
||||||
|
/// ───────────────────────────────────────
|
||||||
|
/// #42 Fix login bug open alice ...
|
||||||
|
/// #41 Add tests open bob ...
|
||||||
|
/// Bottom: Showing 42 of 128 issues
|
||||||
|
/// ```
|
||||||
|
pub fn render_issue_list(frame: &mut Frame<'_>, state: &IssueListState, area: Rect) {
|
||||||
|
if area.height < 3 || area.width < 10 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut y = area.y;
|
||||||
|
let max_x = area.x.saturating_add(area.width);
|
||||||
|
|
||||||
|
// -- Filter bar ---------------------------------------------------------
|
||||||
|
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
|
||||||
|
let fb_state = FilterBarState {
|
||||||
|
input: state.filter_input.clone(),
|
||||||
|
cursor: state.filter_input.len(),
|
||||||
|
focused: state.filter_focused,
|
||||||
|
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
|
||||||
|
unknown_fields: Vec::new(),
|
||||||
|
};
|
||||||
|
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
|
||||||
|
y = y.saturating_add(filter_rows);
|
||||||
|
|
||||||
|
// -- Status line (total count) ------------------------------------------
|
||||||
|
let remaining_height = area.height.saturating_sub(y - area.y);
|
||||||
|
if remaining_height < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve bottom row for status.
|
||||||
|
let table_height = remaining_height.saturating_sub(1);
|
||||||
|
let status_y = y.saturating_add(table_height);
|
||||||
|
|
||||||
|
// -- Entity table -------------------------------------------------------
|
||||||
|
let sort_col = match state.sort_field {
|
||||||
|
SortField::UpdatedAt => 0, // Map to IID column (closest visual proxy)
|
||||||
|
SortField::Iid => 0,
|
||||||
|
SortField::Title => 1,
|
||||||
|
SortField::State => 2,
|
||||||
|
SortField::Author => 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut table_state = EntityTableState {
|
||||||
|
selected: state.selected_index,
|
||||||
|
scroll_offset: state.scroll_offset,
|
||||||
|
sort_column: sort_col,
|
||||||
|
sort_ascending: matches!(state.sort_order, SortOrder::Asc),
|
||||||
|
};
|
||||||
|
|
||||||
|
let table_area = Rect::new(area.x, y, area.width, table_height);
|
||||||
|
render_entity_table(
|
||||||
|
frame,
|
||||||
|
&state.rows,
|
||||||
|
ISSUE_COLUMNS,
|
||||||
|
&mut table_state,
|
||||||
|
table_area,
|
||||||
|
&table_colors(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Bottom status ------------------------------------------------------
|
||||||
|
if status_y < area.y.saturating_add(area.height) {
|
||||||
|
render_status_line(frame, state, area.x, status_y, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the bottom status line showing row count and pagination info.
|
||||||
|
fn render_status_line(frame: &mut Frame<'_>, state: &IssueListState, x: u16, y: u16, max_x: u16) {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if state.rows.is_empty() {
|
||||||
|
"No issues found".to_string()
|
||||||
|
} else {
|
||||||
|
let showing = state.rows.len();
|
||||||
|
let total = state.total_count;
|
||||||
|
if state.next_cursor.is_some() {
|
||||||
|
format!("Showing {showing} of {total} issues (more available)")
|
||||||
|
} else {
|
||||||
|
format!("Showing {showing} of {total} issues")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.print_text_clipped(x, y, &status, muted, max_x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ftui::render::grapheme_pool::GraphemePool;
|
||||||
|
|
||||||
|
macro_rules! with_frame {
|
||||||
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||||
|
let mut pool = GraphemePool::new();
|
||||||
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||||
|
$body
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_state(row_count: usize) -> IssueListState {
|
||||||
|
let rows: Vec<IssueListRow> = (0..row_count)
|
||||||
|
.map(|i| IssueListRow {
|
||||||
|
project_path: "group/project".into(),
|
||||||
|
iid: (i + 1) as i64,
|
||||||
|
title: format!("Issue {}", i + 1),
|
||||||
|
state: if i % 2 == 0 { "opened" } else { "closed" }.into(),
|
||||||
|
author: "taylor".into(),
|
||||||
|
labels: if i == 0 {
|
||||||
|
vec!["bug".into(), "critical".into()]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
|
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
IssueListState {
|
||||||
|
total_count: row_count as u64,
|
||||||
|
rows,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_issue_list_no_panic() {
|
||||||
|
with_frame!(120, 30, |frame| {
|
||||||
|
let state = sample_state(10);
|
||||||
|
render_issue_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_issue_list_empty_no_panic() {
|
||||||
|
with_frame!(80, 20, |frame| {
|
||||||
|
let state = IssueListState::default();
|
||||||
|
render_issue_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_issue_list_tiny_noop() {
|
||||||
|
with_frame!(5, 2, |frame| {
|
||||||
|
let state = sample_state(5);
|
||||||
|
render_issue_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
|
||||||
|
// Should not panic with too-small area.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_issue_list_narrow_no_panic() {
|
||||||
|
with_frame!(40, 15, |frame| {
|
||||||
|
let state = sample_state(5);
|
||||||
|
render_issue_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_issue_list_with_filter_no_panic() {
|
||||||
|
with_frame!(100, 25, |frame| {
|
||||||
|
let mut state = sample_state(5);
|
||||||
|
state.filter_input = "state:opened".into();
|
||||||
|
state.filter_focused = true;
|
||||||
|
render_issue_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_issue_list_row_cells() {
|
||||||
|
let row = IssueListRow {
|
||||||
|
project_path: "group/proj".into(),
|
||||||
|
iid: 42,
|
||||||
|
title: "Fix bug".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
labels: vec!["bug".into(), "urgent".into()],
|
||||||
|
updated_at: 1_700_000_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cells = row.cells(6);
|
||||||
|
assert_eq!(cells[0], "#42");
|
||||||
|
assert_eq!(cells[1], "Fix bug");
|
||||||
|
assert_eq!(cells[2], "opened");
|
||||||
|
assert_eq!(cells[3], "alice");
|
||||||
|
assert_eq!(cells[4], "bug, urgent");
|
||||||
|
assert_eq!(cells[5], "group/proj");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_issue_list_row_cells_truncated() {
|
||||||
|
let row = IssueListRow {
|
||||||
|
project_path: "g/p".into(),
|
||||||
|
iid: 1,
|
||||||
|
title: "t".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "a".into(),
|
||||||
|
labels: vec![],
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Request fewer columns than available.
|
||||||
|
let cells = row.cells(3);
|
||||||
|
assert_eq!(cells.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_column_count() {
|
||||||
|
assert_eq!(ISSUE_COLUMNS.len(), 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +7,23 @@
|
|||||||
//! bar, and optional overlays (help, error toast).
|
//! bar, and optional overlays (help, error toast).
|
||||||
|
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod issue_list;
|
||||||
|
pub mod mr_list;
|
||||||
|
|
||||||
use ftui::layout::{Constraint, Flex};
|
use ftui::layout::{Constraint, Flex};
|
||||||
use ftui::render::cell::PackedRgba;
|
use ftui::render::cell::PackedRgba;
|
||||||
use ftui::render::frame::Frame;
|
use ftui::render::frame::Frame;
|
||||||
|
|
||||||
use crate::app::LoreApp;
|
use crate::app::LoreApp;
|
||||||
|
use crate::message::Screen;
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
|
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
|
||||||
};
|
};
|
||||||
|
use dashboard::render_dashboard;
|
||||||
|
use issue_list::render_issue_list;
|
||||||
|
use mr_list::render_mr_list;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
|
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
|
||||||
@@ -79,12 +86,14 @@ pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
|
|||||||
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
|
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
|
||||||
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
|
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
|
||||||
|
|
||||||
// Per-screen content dispatch (Phase 2+).
|
// Per-screen content dispatch (other screens wired in later phases).
|
||||||
// match screen {
|
if screen == &Screen::Dashboard {
|
||||||
// Screen::Dashboard => ...,
|
render_dashboard(frame, &app.state.dashboard, content_area);
|
||||||
// Screen::IssueList => ...,
|
} else if screen == &Screen::IssueList {
|
||||||
// ...
|
render_issue_list(frame, &app.state.issue_list, content_area);
|
||||||
// }
|
} else if screen == &Screen::MrList {
|
||||||
|
render_mr_list(frame, &app.state.mr_list, content_area);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Status bar ---
|
// --- Status bar ---
|
||||||
render_status_bar(
|
render_status_bar(
|
||||||
|
|||||||
390
crates/lore-tui/src/view/mr_list.rs
Normal file
390
crates/lore-tui/src/view/mr_list.rs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
|
||||||
|
|
||||||
|
//! MR list screen view.
|
||||||
|
//!
|
||||||
|
//! Composes the reusable [`EntityTable`] and [`FilterBar`] widgets
|
||||||
|
//! with MR-specific column definitions and [`TableRow`] implementation.
|
||||||
|
|
||||||
|
use ftui::core::geometry::Rect;
|
||||||
|
use ftui::render::cell::{Cell, PackedRgba};
|
||||||
|
use ftui::render::drawing::Draw;
|
||||||
|
use ftui::render::frame::Frame;
|
||||||
|
|
||||||
|
use crate::state::mr_list::{MrListRow, MrListState, MrSortField, MrSortOrder};
|
||||||
|
use crate::view::common::entity_table::{
|
||||||
|
Align, ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table,
|
||||||
|
};
|
||||||
|
use crate::view::common::filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TableRow implementation for MrListRow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl TableRow for MrListRow {
|
||||||
|
fn cells(&self, col_count: usize) -> Vec<String> {
|
||||||
|
let mut cells = Vec::with_capacity(col_count);
|
||||||
|
|
||||||
|
// Column order must match MR_COLUMNS definition.
|
||||||
|
// 0: IID (with draft indicator)
|
||||||
|
let iid_text = if self.draft {
|
||||||
|
format!("!{} [WIP]", self.iid)
|
||||||
|
} else {
|
||||||
|
format!("!{}", self.iid)
|
||||||
|
};
|
||||||
|
cells.push(iid_text);
|
||||||
|
// 1: Title
|
||||||
|
cells.push(self.title.clone());
|
||||||
|
// 2: State
|
||||||
|
cells.push(self.state.clone());
|
||||||
|
// 3: Author
|
||||||
|
cells.push(self.author.clone());
|
||||||
|
// 4: Target Branch
|
||||||
|
cells.push(self.target_branch.clone());
|
||||||
|
// 5: Labels
|
||||||
|
cells.push(self.labels.join(", "));
|
||||||
|
// 6: Project
|
||||||
|
cells.push(self.project_path.clone());
|
||||||
|
|
||||||
|
cells.truncate(col_count);
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Column definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Column definitions for the MR list table.
|
||||||
|
const MR_COLUMNS: &[ColumnDef] = &[
|
||||||
|
ColumnDef {
|
||||||
|
name: "IID",
|
||||||
|
min_width: 6,
|
||||||
|
flex_weight: 0,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Right,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Title",
|
||||||
|
min_width: 15,
|
||||||
|
flex_weight: 4,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "State",
|
||||||
|
min_width: 7,
|
||||||
|
flex_weight: 0,
|
||||||
|
priority: 0,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Author",
|
||||||
|
min_width: 8,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 1,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Target",
|
||||||
|
min_width: 8,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 1,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Labels",
|
||||||
|
min_width: 10,
|
||||||
|
flex_weight: 2,
|
||||||
|
priority: 2,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
ColumnDef {
|
||||||
|
name: "Project",
|
||||||
|
min_width: 12,
|
||||||
|
flex_weight: 1,
|
||||||
|
priority: 3,
|
||||||
|
align: Align::Left,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn table_colors() -> TableColors {
|
||||||
|
TableColors {
|
||||||
|
header_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
header_bg: PackedRgba::rgb(0x34, 0x34, 0x31),
|
||||||
|
row_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
row_alt_bg: PackedRgba::rgb(0x1C, 0x1B, 0x1A),
|
||||||
|
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
|
||||||
|
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
sort_indicator: PackedRgba::rgb(0x87, 0x96, 0x6B),
|
||||||
|
border: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_colors() -> FilterBarColors {
|
||||||
|
FilterBarColors {
|
||||||
|
input_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
input_bg: PackedRgba::rgb(0x28, 0x28, 0x24),
|
||||||
|
cursor_fg: PackedRgba::rgb(0x00, 0x00, 0x00),
|
||||||
|
cursor_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
chip_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
|
||||||
|
chip_bg: PackedRgba::rgb(0x40, 0x40, 0x3C),
|
||||||
|
error_fg: PackedRgba::rgb(0xAF, 0x3A, 0x29),
|
||||||
|
label_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the full MR list screen.
|
||||||
|
///
|
||||||
|
/// Layout:
|
||||||
|
/// ```text
|
||||||
|
/// Row 0: [Filter bar: / filter input_________]
|
||||||
|
/// Row 1: [chip1] [chip2] (if filter active)
|
||||||
|
/// Row 2: -----------------------------------------
|
||||||
|
/// Row 3..N: IID Title State Author ...
|
||||||
|
/// -----------------------------------------
|
||||||
|
/// !42 Fix pipeline opened alice ...
|
||||||
|
/// !41 Add CI config merged bob ...
|
||||||
|
/// Bottom: Showing 42 of 128 merge requests
|
||||||
|
/// ```
|
||||||
|
pub fn render_mr_list(frame: &mut Frame<'_>, state: &MrListState, area: Rect) {
|
||||||
|
if area.height < 3 || area.width < 10 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut y = area.y;
|
||||||
|
let max_x = area.x.saturating_add(area.width);
|
||||||
|
|
||||||
|
// -- Filter bar ---------------------------------------------------------
|
||||||
|
let filter_area = Rect::new(area.x, y, area.width, 2.min(area.height));
|
||||||
|
let fb_state = FilterBarState {
|
||||||
|
input: state.filter_input.clone(),
|
||||||
|
cursor: state.filter_input.len(),
|
||||||
|
focused: state.filter_focused,
|
||||||
|
tokens: crate::filter_dsl::parse_filter_tokens(&state.filter_input),
|
||||||
|
unknown_fields: Vec::new(),
|
||||||
|
};
|
||||||
|
let filter_rows = render_filter_bar(frame, &fb_state, filter_area, &filter_colors());
|
||||||
|
y = y.saturating_add(filter_rows);
|
||||||
|
|
||||||
|
// -- Status line (total count) ------------------------------------------
|
||||||
|
let remaining_height = area.height.saturating_sub(y - area.y);
|
||||||
|
if remaining_height < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve bottom row for status.
|
||||||
|
let table_height = remaining_height.saturating_sub(1);
|
||||||
|
let status_y = y.saturating_add(table_height);
|
||||||
|
|
||||||
|
// -- Entity table -------------------------------------------------------
|
||||||
|
let sort_col = match state.sort_field {
|
||||||
|
MrSortField::UpdatedAt | MrSortField::Iid => 0,
|
||||||
|
MrSortField::Title => 1,
|
||||||
|
MrSortField::State => 2,
|
||||||
|
MrSortField::Author => 3,
|
||||||
|
MrSortField::TargetBranch => 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut table_state = EntityTableState {
|
||||||
|
selected: state.selected_index,
|
||||||
|
scroll_offset: state.scroll_offset,
|
||||||
|
sort_column: sort_col,
|
||||||
|
sort_ascending: matches!(state.sort_order, MrSortOrder::Asc),
|
||||||
|
};
|
||||||
|
|
||||||
|
let table_area = Rect::new(area.x, y, area.width, table_height);
|
||||||
|
render_entity_table(
|
||||||
|
frame,
|
||||||
|
&state.rows,
|
||||||
|
MR_COLUMNS,
|
||||||
|
&mut table_state,
|
||||||
|
table_area,
|
||||||
|
&table_colors(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Bottom status ------------------------------------------------------
|
||||||
|
if status_y < area.y.saturating_add(area.height) {
|
||||||
|
render_status_line(frame, state, area.x, status_y, max_x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the bottom status line showing row count and pagination info.
|
||||||
|
fn render_status_line(frame: &mut Frame<'_>, state: &MrListState, x: u16, y: u16, max_x: u16) {
|
||||||
|
let muted = Cell {
|
||||||
|
fg: PackedRgba::rgb(0x87, 0x87, 0x80),
|
||||||
|
..Cell::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if state.rows.is_empty() {
|
||||||
|
"No merge requests found".to_string()
|
||||||
|
} else {
|
||||||
|
let showing = state.rows.len();
|
||||||
|
let total = state.total_count;
|
||||||
|
if state.next_cursor.is_some() {
|
||||||
|
format!("Showing {showing} of {total} merge requests (more available)")
|
||||||
|
} else {
|
||||||
|
format!("Showing {showing} of {total} merge requests")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.print_text_clipped(x, y, &status, muted, max_x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ftui::render::grapheme_pool::GraphemePool;
|
||||||
|
|
||||||
|
macro_rules! with_frame {
|
||||||
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||||
|
let mut pool = GraphemePool::new();
|
||||||
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||||
|
$body
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_state(row_count: usize) -> MrListState {
|
||||||
|
let rows: Vec<MrListRow> = (0..row_count)
|
||||||
|
.map(|i| MrListRow {
|
||||||
|
project_path: "group/project".into(),
|
||||||
|
iid: (i + 1) as i64,
|
||||||
|
title: format!("MR {}", i + 1),
|
||||||
|
state: if i % 2 == 0 { "opened" } else { "merged" }.into(),
|
||||||
|
author: "taylor".into(),
|
||||||
|
target_branch: "main".into(),
|
||||||
|
labels: if i == 0 {
|
||||||
|
vec!["backend".into(), "urgent".into()]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
|
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
|
||||||
|
draft: i % 3 == 0,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
MrListState {
|
||||||
|
total_count: row_count as u64,
|
||||||
|
rows,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_mr_list_no_panic() {
|
||||||
|
with_frame!(120, 30, |frame| {
|
||||||
|
let state = sample_state(10);
|
||||||
|
render_mr_list(&mut frame, &state, Rect::new(0, 0, 120, 30));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_mr_list_empty_no_panic() {
|
||||||
|
with_frame!(80, 20, |frame| {
|
||||||
|
let state = MrListState::default();
|
||||||
|
render_mr_list(&mut frame, &state, Rect::new(0, 0, 80, 20));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_mr_list_tiny_noop() {
|
||||||
|
with_frame!(5, 2, |frame| {
|
||||||
|
let state = sample_state(5);
|
||||||
|
render_mr_list(&mut frame, &state, Rect::new(0, 0, 5, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_mr_list_narrow_no_panic() {
|
||||||
|
with_frame!(40, 15, |frame| {
|
||||||
|
let state = sample_state(5);
|
||||||
|
render_mr_list(&mut frame, &state, Rect::new(0, 0, 40, 15));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_mr_list_with_filter_no_panic() {
|
||||||
|
with_frame!(100, 25, |frame| {
|
||||||
|
let mut state = sample_state(5);
|
||||||
|
state.filter_input = "state:opened".into();
|
||||||
|
state.filter_focused = true;
|
||||||
|
render_mr_list(&mut frame, &state, Rect::new(0, 0, 100, 25));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mr_list_row_cells() {
|
||||||
|
let row = MrListRow {
|
||||||
|
project_path: "group/proj".into(),
|
||||||
|
iid: 42,
|
||||||
|
title: "Fix pipeline".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "alice".into(),
|
||||||
|
target_branch: "main".into(),
|
||||||
|
labels: vec!["backend".into(), "urgent".into()],
|
||||||
|
updated_at: 1_700_000_000_000,
|
||||||
|
draft: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cells = row.cells(7);
|
||||||
|
assert_eq!(cells[0], "!42");
|
||||||
|
assert_eq!(cells[1], "Fix pipeline");
|
||||||
|
assert_eq!(cells[2], "opened");
|
||||||
|
assert_eq!(cells[3], "alice");
|
||||||
|
assert_eq!(cells[4], "main");
|
||||||
|
assert_eq!(cells[5], "backend, urgent");
|
||||||
|
assert_eq!(cells[6], "group/proj");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mr_list_row_cells_draft() {
|
||||||
|
let row = MrListRow {
|
||||||
|
project_path: "g/p".into(),
|
||||||
|
iid: 7,
|
||||||
|
title: "WIP MR".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "bob".into(),
|
||||||
|
target_branch: "develop".into(),
|
||||||
|
labels: vec![],
|
||||||
|
updated_at: 0,
|
||||||
|
draft: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cells = row.cells(7);
|
||||||
|
assert_eq!(cells[0], "!7 [WIP]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mr_list_row_cells_truncated() {
|
||||||
|
let row = MrListRow {
|
||||||
|
project_path: "g/p".into(),
|
||||||
|
iid: 1,
|
||||||
|
title: "t".into(),
|
||||||
|
state: "opened".into(),
|
||||||
|
author: "a".into(),
|
||||||
|
target_branch: "main".into(),
|
||||||
|
labels: vec![],
|
||||||
|
updated_at: 0,
|
||||||
|
draft: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cells = row.cells(3);
|
||||||
|
assert_eq!(cells.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_column_count() {
|
||||||
|
assert_eq!(MR_COLUMNS.len(), 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
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');
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::error::{LoreError, Result};
|
||||||
|
use super::paths::get_config_path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct GitLabConfig {
|
||||||
|
#[serde(rename = "baseUrl")]
|
||||||
|
pub base_url: String,
|
||||||
|
|
||||||
|
#[serde(rename = "tokenEnvVar", default = "default_token_env_var")]
|
||||||
|
pub token_env_var: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_token_env_var() -> String {
|
||||||
|
"GITLAB_TOKEN".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ProjectConfig {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SyncConfig {
|
||||||
|
#[serde(rename = "backfillDays")]
|
||||||
|
pub backfill_days: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "staleLockMinutes")]
|
||||||
|
pub stale_lock_minutes: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "heartbeatIntervalSeconds")]
|
||||||
|
pub heartbeat_interval_seconds: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "cursorRewindSeconds")]
|
||||||
|
pub cursor_rewind_seconds: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "primaryConcurrency")]
|
||||||
|
pub primary_concurrency: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "dependentConcurrency")]
|
||||||
|
pub dependent_concurrency: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "requestsPerSecond")]
|
||||||
|
pub requests_per_second: f64,
|
||||||
|
|
||||||
|
#[serde(rename = "fetchResourceEvents", default = "default_true")]
|
||||||
|
pub fetch_resource_events: bool,
|
||||||
|
|
||||||
|
#[serde(rename = "fetchMrFileChanges", default = "default_true")]
|
||||||
|
pub fetch_mr_file_changes: bool,
|
||||||
|
|
||||||
|
#[serde(rename = "fetchWorkItemStatus", default = "default_true")]
|
||||||
|
pub fetch_work_item_status: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyncConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backfill_days: 14,
|
||||||
|
stale_lock_minutes: 10,
|
||||||
|
heartbeat_interval_seconds: 30,
|
||||||
|
cursor_rewind_seconds: 2,
|
||||||
|
primary_concurrency: 4,
|
||||||
|
dependent_concurrency: 8,
|
||||||
|
requests_per_second: 30.0,
|
||||||
|
fetch_resource_events: true,
|
||||||
|
fetch_mr_file_changes: true,
|
||||||
|
fetch_work_item_status: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
#[serde(rename = "dbPath")]
|
||||||
|
pub db_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "backupDir")]
|
||||||
|
pub backup_dir: Option<String>,
|
||||||
|
|
||||||
|
#[serde(
|
||||||
|
rename = "compressRawPayloads",
|
||||||
|
default = "default_compress_raw_payloads"
|
||||||
|
)]
|
||||||
|
pub compress_raw_payloads: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_compress_raw_payloads() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct EmbeddingConfig {
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
#[serde(rename = "baseUrl")]
|
||||||
|
pub base_url: String,
|
||||||
|
pub concurrency: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EmbeddingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
provider: "ollama".to_string(),
|
||||||
|
model: "nomic-embed-text".to_string(),
|
||||||
|
base_url: "http://localhost:11434".to_string(),
|
||||||
|
concurrency: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct LoggingConfig {
|
||||||
|
#[serde(rename = "logDir")]
|
||||||
|
pub log_dir: Option<String>,
|
||||||
|
|
||||||
|
#[serde(rename = "retentionDays", default = "default_retention_days")]
|
||||||
|
pub retention_days: u32,
|
||||||
|
|
||||||
|
#[serde(rename = "fileLogging", default = "default_file_logging")]
|
||||||
|
pub file_logging: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_retention_days() -> u32 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_file_logging() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
log_dir: None,
|
||||||
|
retention_days: default_retention_days(),
|
||||||
|
file_logging: default_file_logging(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ScoringConfig {
|
||||||
|
/// Points per MR where the user authored code touching the path.
|
||||||
|
#[serde(rename = "authorWeight")]
|
||||||
|
pub author_weight: i64,
|
||||||
|
|
||||||
|
/// Points per MR where the user reviewed code touching the path.
|
||||||
|
#[serde(rename = "reviewerWeight")]
|
||||||
|
pub reviewer_weight: i64,
|
||||||
|
|
||||||
|
/// Bonus points per individual inline review comment (DiffNote).
|
||||||
|
#[serde(rename = "noteBonus")]
|
||||||
|
pub note_bonus: i64,
|
||||||
|
|
||||||
|
/// Points per MR where the user was assigned as a reviewer.
|
||||||
|
#[serde(rename = "reviewerAssignmentWeight")]
|
||||||
|
pub reviewer_assignment_weight: i64,
|
||||||
|
|
||||||
|
/// Half-life in days for author contribution decay.
|
||||||
|
#[serde(rename = "authorHalfLifeDays")]
|
||||||
|
pub author_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Half-life in days for reviewer contribution decay.
|
||||||
|
#[serde(rename = "reviewerHalfLifeDays")]
|
||||||
|
pub reviewer_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Half-life in days for reviewer assignment decay.
|
||||||
|
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
|
||||||
|
pub reviewer_assignment_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Half-life in days for note/comment contribution decay.
|
||||||
|
#[serde(rename = "noteHalfLifeDays")]
|
||||||
|
pub note_half_life_days: u32,
|
||||||
|
|
||||||
|
/// Multiplier applied to scores from closed (not merged) MRs.
|
||||||
|
#[serde(rename = "closedMrMultiplier")]
|
||||||
|
pub closed_mr_multiplier: f64,
|
||||||
|
|
||||||
|
/// Minimum character count for a review note to earn note_bonus.
|
||||||
|
#[serde(rename = "reviewerMinNoteChars")]
|
||||||
|
pub reviewer_min_note_chars: u32,
|
||||||
|
|
||||||
|
/// Usernames excluded from expert/scoring results.
|
||||||
|
#[serde(rename = "excludedUsernames")]
|
||||||
|
pub excluded_usernames: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScoringConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
author_weight: 25,
|
||||||
|
reviewer_weight: 10,
|
||||||
|
note_bonus: 1,
|
||||||
|
reviewer_assignment_weight: 3,
|
||||||
|
author_half_life_days: 180,
|
||||||
|
reviewer_half_life_days: 90,
|
||||||
|
reviewer_assignment_half_life_days: 45,
|
||||||
|
note_half_life_days: 45,
|
||||||
|
closed_mr_multiplier: 0.5,
|
||||||
|
reviewer_min_note_chars: 20,
|
||||||
|
excluded_usernames: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub gitlab: GitLabConfig,
|
||||||
|
pub projects: Vec<ProjectConfig>,
|
||||||
|
|
||||||
|
#[serde(rename = "defaultProject")]
|
||||||
|
pub default_project: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync: SyncConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub storage: StorageConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub embedding: EmbeddingConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub logging: LoggingConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub scoring: ScoringConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load(cli_override: Option<&str>) -> Result<Self> {
|
||||||
|
let config_path = get_config_path(cli_override);
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Err(LoreError::ConfigNotFound {
|
||||||
|
path: config_path.display().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::load_from_path(&config_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_path(path: &Path) -> Result<Self> {
|
||||||
|
let content = fs::read_to_string(path).map_err(|e| LoreError::ConfigInvalid {
|
||||||
|
details: format!("Failed to read config file: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let config: Config =
|
||||||
|
serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid {
|
||||||
|
details: format!("Invalid JSON: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if config.projects.is_empty() {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "At least one project is required".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for project in &config.projects {
|
||||||
|
if project.path.is_empty() {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "Project path cannot be empty".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if url::Url::parse(&config.gitlab.base_url).is_err() {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: format!("Invalid GitLab URL: {}", config.gitlab.base_url),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref dp) = config.default_project {
|
||||||
|
let matched = config.projects.iter().any(|p| {
|
||||||
|
p.path.eq_ignore_ascii_case(dp)
|
||||||
|
|| p.path
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.ends_with(&format!("/{}", dp.to_ascii_lowercase()))
|
||||||
|
});
|
||||||
|
if !matched {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: format!(
|
||||||
|
"defaultProject '{}' does not match any configured project path",
|
||||||
|
dp
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_scoring(&config.scoring)?;
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the effective project filter: CLI flag wins, then config default.
|
||||||
|
pub fn effective_project<'a>(&'a self, cli_project: Option<&'a str>) -> Option<&'a str> {
|
||||||
|
cli_project.or(self.default_project.as_deref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
||||||
|
if scoring.author_weight < 0 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.authorWeight must be >= 0".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_weight < 0 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerWeight must be >= 0".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.note_bonus < 0 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.noteBonus must be >= 0".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_assignment_weight < 0 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.author_half_life_days == 0 || scoring.author_half_life_days > 3650 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.authorHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_half_life_days == 0 || scoring.reviewer_half_life_days > 3650 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_assignment_half_life_days == 0
|
||||||
|
|| scoring.reviewer_assignment_half_life_days > 3650
|
||||||
|
{
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerAssignmentHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.note_half_life_days == 0 || scoring.note_half_life_days > 3650 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.noteHalfLifeDays must be in 1..=3650".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !scoring.closed_mr_multiplier.is_finite()
|
||||||
|
|| scoring.closed_mr_multiplier <= 0.0
|
||||||
|
|| scoring.closed_mr_multiplier > 1.0
|
||||||
|
{
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring.reviewer_min_note_chars > 4096 {
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if scoring
|
||||||
|
.excluded_usernames
|
||||||
|
.iter()
|
||||||
|
.any(|u| u.trim().is_empty())
|
||||||
|
{
|
||||||
|
return Err(LoreError::ConfigInvalid {
|
||||||
|
details: "scoring.excludedUsernames entries must be non-empty".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct MinimalConfig {
|
||||||
|
pub gitlab: MinimalGitLabConfig,
|
||||||
|
pub projects: Vec<ProjectConfig>,
|
||||||
|
#[serde(rename = "defaultProject", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_project: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct MinimalGitLabConfig {
|
||||||
|
#[serde(rename = "baseUrl")]
|
||||||
|
pub base_url: String,
|
||||||
|
#[serde(rename = "tokenEnvVar")]
|
||||||
|
pub token_env_var: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl serde::Serialize for ProjectConfig {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
use serde::ser::SerializeStruct;
|
||||||
|
let mut state = serializer.serialize_struct("ProjectConfig", 1)?;
|
||||||
|
state.serialize_field("path", &self.path)?;
|
||||||
|
state.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write_config(dir: &TempDir, scoring_json: &str) -> std::path::PathBuf {
|
||||||
|
let path = dir.path().join("config.json");
|
||||||
|
let config = format!(
|
||||||
|
r#"{{
|
||||||
|
"gitlab": {{
|
||||||
|
"baseUrl": "https://gitlab.example.com",
|
||||||
|
"tokenEnvVar": "GITLAB_TOKEN"
|
||||||
|
}},
|
||||||
|
"projects": [
|
||||||
|
{{ "path": "group/project" }}
|
||||||
|
],
|
||||||
|
"scoring": {scoring_json}
|
||||||
|
}}"#
|
||||||
|
);
|
||||||
|
fs::write(&path, config).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config_with_default_project(
|
||||||
|
dir: &TempDir,
|
||||||
|
default_project: Option<&str>,
|
||||||
|
) -> std::path::PathBuf {
|
||||||
|
let path = dir.path().join("config.json");
|
||||||
|
let dp_field = match default_project {
|
||||||
|
Some(dp) => format!(r#","defaultProject": "{dp}""#),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let config = format!(
|
||||||
|
r#"{{
|
||||||
|
"gitlab": {{
|
||||||
|
"baseUrl": "https://gitlab.example.com",
|
||||||
|
"tokenEnvVar": "GITLAB_TOKEN"
|
||||||
|
}},
|
||||||
|
"projects": [
|
||||||
|
{{ "path": "group/project" }},
|
||||||
|
{{ "path": "other/repo" }}
|
||||||
|
]{dp_field}
|
||||||
|
}}"#
|
||||||
|
);
|
||||||
|
fs::write(&path, config).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_rejects_negative_author_weight() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config(
|
||||||
|
&dir,
|
||||||
|
r#"{
|
||||||
|
"authorWeight": -1,
|
||||||
|
"reviewerWeight": 10,
|
||||||
|
"noteBonus": 1
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
let err = Config::load_from_path(&path).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("scoring.authorWeight"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_rejects_negative_reviewer_weight() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config(
|
||||||
|
&dir,
|
||||||
|
r#"{
|
||||||
|
"authorWeight": 25,
|
||||||
|
"reviewerWeight": -1,
|
||||||
|
"noteBonus": 1
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
let err = Config::load_from_path(&path).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("scoring.reviewerWeight"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_fetch_work_item_status_default_true() {
|
||||||
|
let config = SyncConfig::default();
|
||||||
|
assert!(config.fetch_work_item_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_deserialize_without_key() {
|
||||||
|
let json = r#"{}"#;
|
||||||
|
let config: SyncConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(
|
||||||
|
config.fetch_work_item_status,
|
||||||
|
"Missing key should default to true"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_rejects_negative_note_bonus() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config(
|
||||||
|
&dir,
|
||||||
|
r#"{
|
||||||
|
"authorWeight": 25,
|
||||||
|
"reviewerWeight": 10,
|
||||||
|
"noteBonus": -1
|
||||||
|
}"#,
|
||||||
|
);
|
||||||
|
let err = Config::load_from_path(&path).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_project_cli_overrides_default() {
|
||||||
|
let config = Config {
|
||||||
|
gitlab: GitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: Some("group/project".to_string()),
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
storage: StorageConfig::default(),
|
||||||
|
embedding: EmbeddingConfig::default(),
|
||||||
|
logging: LoggingConfig::default(),
|
||||||
|
scoring: ScoringConfig::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
config.effective_project(Some("other/repo")),
|
||||||
|
Some("other/repo")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_project_falls_back_to_default() {
|
||||||
|
let config = Config {
|
||||||
|
gitlab: GitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: Some("group/project".to_string()),
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
storage: StorageConfig::default(),
|
||||||
|
embedding: EmbeddingConfig::default(),
|
||||||
|
logging: LoggingConfig::default(),
|
||||||
|
scoring: ScoringConfig::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(config.effective_project(None), Some("group/project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_project_none_when_both_absent() {
|
||||||
|
let config = Config {
|
||||||
|
gitlab: GitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: None,
|
||||||
|
sync: SyncConfig::default(),
|
||||||
|
storage: StorageConfig::default(),
|
||||||
|
embedding: EmbeddingConfig::default(),
|
||||||
|
logging: LoggingConfig::default(),
|
||||||
|
scoring: ScoringConfig::default(),
|
||||||
|
};
|
||||||
|
assert_eq!(config.effective_project(None), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_with_valid_default_project() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config_with_default_project(&dir, Some("group/project"));
|
||||||
|
let config = Config::load_from_path(&path).unwrap();
|
||||||
|
assert_eq!(config.default_project.as_deref(), Some("group/project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_rejects_invalid_default_project() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config_with_default_project(&dir, Some("nonexistent/project"));
|
||||||
|
let err = Config::load_from_path(&path).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("defaultProject"), "unexpected error: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_default_project_suffix_match() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let path = write_config_with_default_project(&dir, Some("project"));
|
||||||
|
let config = Config::load_from_path(&path).unwrap();
|
||||||
|
assert_eq!(config.default_project.as_deref(), Some("project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_config_omits_null_default_project() {
|
||||||
|
let config = MinimalConfig {
|
||||||
|
gitlab: MinimalGitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
assert!(
|
||||||
|
!json.contains("defaultProject"),
|
||||||
|
"null default_project should be omitted: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minimal_config_includes_default_project_when_set() {
|
||||||
|
let config = MinimalConfig {
|
||||||
|
gitlab: MinimalGitLabConfig {
|
||||||
|
base_url: "https://gitlab.example.com".to_string(),
|
||||||
|
token_env_var: "GITLAB_TOKEN".to_string(),
|
||||||
|
},
|
||||||
|
projects: vec![ProjectConfig {
|
||||||
|
path: "group/project".to_string(),
|
||||||
|
}],
|
||||||
|
default_project: Some("group/project".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains("defaultProject"),
|
||||||
|
"set default_project should be present: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_zero_half_life() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
author_half_life_days: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("authorHalfLifeDays"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_absurd_half_life() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
author_half_life_days: 5000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("authorHalfLifeDays"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_nan_multiplier() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
closed_mr_multiplier: f64::NAN,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("closedMrMultiplier"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_zero_multiplier() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
closed_mr_multiplier: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("closedMrMultiplier"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_negative_reviewer_assignment_weight() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
reviewer_assignment_weight: -1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("reviewerAssignmentWeight"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_oversized_min_note_chars() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
reviewer_min_note_chars: 5000,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("reviewerMinNoteChars"),
|
||||||
|
"unexpected error: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_rejects_empty_excluded_username() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
excluded_usernames: vec!["valid".to_string(), " ".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let err = validate_scoring(&scoring).unwrap_err();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("excludedUsernames"), "unexpected error: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_accepts_valid_new_fields() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
author_half_life_days: 365,
|
||||||
|
reviewer_half_life_days: 180,
|
||||||
|
reviewer_assignment_half_life_days: 90,
|
||||||
|
note_half_life_days: 60,
|
||||||
|
closed_mr_multiplier: 0.5,
|
||||||
|
reviewer_min_note_chars: 20,
|
||||||
|
reviewer_assignment_weight: 3,
|
||||||
|
excluded_usernames: vec!["bot-user".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_accepts_boundary_half_life() {
|
||||||
|
// 1 and 3650 are both valid boundaries
|
||||||
|
let scoring_min = ScoringConfig {
|
||||||
|
author_half_life_days: 1,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring_min).unwrap();
|
||||||
|
|
||||||
|
let scoring_max = ScoringConfig {
|
||||||
|
author_half_life_days: 3650,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring_max).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_validation_accepts_multiplier_at_one() {
|
||||||
|
let scoring = ScoringConfig {
|
||||||
|
closed_mr_multiplier: 1.0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
validate_scoring(&scoring).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
"026",
|
"026",
|
||||||
include_str!("../../migrations/026_scoring_indexes.sql"),
|
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> {
|
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||||
|
|||||||
Reference in New Issue
Block a user