8 Commits

Author SHA1 Message Date
teernisse
050e00345a feat(tui): Phase 2 detail screens — Issue Detail, MR Detail, discussion tree, cross-refs
Implements the remaining Phase 2 Core Screens:

- Discussion tree widget (view/common/discussion_tree.rs): DiscussionNode/NoteNode types,
  expand/collapse state, visual row flattening, format_relative_time with Clock trait
- Cross-reference widget (view/common/cross_ref.rs): CrossRefKind enum, navigable refs,
  badge rendering ([MR]/[REL]/[REF])
- Issue Detail (state + action + view): progressive hydration (metadata Phase 1,
  discussions Phase 2), section cycling, description scroll, sanitized GitLab content
- MR Detail (state + action + view): tab bar (Overview/Files/Discussions), file changes
  with change type indicators, branch info, draft/merge status, diff note support
- Message + update wiring: IssueDetailLoaded, MrDetailLoaded, DiscussionsLoaded handlers
  with TaskSupervisor stale-result guards

Closes bd-1d6z, bd-8ab7, bd-3t1b, bd-1cl9 (Phase 2 epic).
389 tests passing, clippy clean, fmt clean.
2026-02-18 15:37:23 -05:00
teernisse
90c8b43267 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
2026-02-18 14:48:15 -05:00
teernisse
c5b7f4c864 (no description set) 2026-02-18 12:59:07 -05:00
teernisse
28ce63f818 refactor: split common/mod.rs into per-widget modules 2026-02-18 12:58:38 -05:00
teernisse
eb5b464d03 feat: TUI Phase 1 common widgets + scoring/path beads 2026-02-18 12:58:12 -05:00
teernisse
4664e0cfe3 feat: complete TUI Phase 0 — Toolchain Gate 2026-02-18 12:47:10 -05:00
teernisse
63bd58c9b4 feat(who): filter unresolved discussions to open entities only
Workload and active modes now exclude discussions on closed issues and
merged/closed MRs by default. Adds --include-closed flag to restore
the previous behavior when needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:34:28 -05:00
teernisse
714c8c2623 feat(path): rename-aware ambiguity resolution for suffix probe
When a bare filename like 'operators.ts' matches multiple full paths,
check if they are the same file connected by renames (via BFS on
mr_file_changes). If so, auto-resolve to the newest path instead of
erroring. Also wires path resolution into file-history and trace
commands so bare filenames work everywhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:34:28 -05:00
66 changed files with 21912 additions and 424 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
bd-1sc6
bd-2kr0

23
.gitignore vendored
View File

@@ -1,11 +1,6 @@
# Dependencies
node_modules/
# Build output
dist/
# Test coverage
coverage/
# Rust build output
/target
**/target/
# IDE
.idea/
@@ -25,14 +20,11 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local config files
lore.config.json
# beads
# beads viewer cache
.bv/
# SQLite databases (local development)
@@ -40,10 +32,15 @@ lore.config.json
*.db-wal
*.db-shm
# Mock seed data
tools/mock-seed/
# Added by cargo
/target
# Profiling / benchmarks
perf.data
perf.data.old
flamegraph.svg
*.profraw

3252
crates/lore-tui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
[package]
name = "lore-tui"
version = "0.1.0"
edition = "2024"
description = "Terminal UI for Gitlore — local GitLab data explorer"
authors = ["Taylor Eernisse"]
license = "MIT"
[[bin]]
name = "lore-tui"
path = "src/main.rs"
[dependencies]
# FrankenTUI (Elm-architecture TUI framework)
ftui = "0.1.1"
# Lore library (config, db, ingestion, search, etc.)
lore = { path = "../.." }
# CLI
clap = { version = "4", features = ["derive", "env"] }
# Error handling
anyhow = "1"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Paths
dirs = "6"
# Database (read-only queries from TUI)
rusqlite = { version = "0.38", features = ["bundled"] }
# Terminal (crossterm for raw mode + event reading, used by ftui runtime)
crossterm = "0.28"
# Serialization (crash context NDJSON dumps)
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Regex (used by safety module for PII/secret redaction)
regex = "1"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly-2026-02-08"
profile = "minimal"
components = ["rustfmt", "clippy"]

File diff suppressed because it is too large Load Diff

View 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()
}
}

View 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));
}

View File

@@ -0,0 +1,419 @@
//! Model trait impl and key dispatch for LoreApp.
use chrono::TimeDelta;
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
use crate::crash_context::CrashEvent;
use crate::message::{InputMode, Msg, Screen};
use crate::state::LoadState;
use crate::task_supervisor::TaskKey;
use super::LoreApp;
/// Timeout for the g-prefix key sequence.
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
impl LoreApp {
// -----------------------------------------------------------------------
// Key dispatch
// -----------------------------------------------------------------------
/// Normalize terminal key variants for cross-terminal consistency.
fn normalize_key(key: &mut KeyEvent) {
// BackTab -> Shift+Tab canonical form.
if key.code == KeyCode::BackTab {
key.code = KeyCode::Tab;
key.modifiers |= Modifiers::SHIFT;
}
}
/// 5-stage key dispatch pipeline.
///
/// Returns the Cmd to execute (Quit, None, or a task command).
pub(crate) fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
Self::normalize_key(&mut key);
let screen = self.navigation.current().clone();
// Record key press in crash context.
self.crash_context.push(CrashEvent::KeyPress {
key: format!("{:?}", key.code),
mode: format!("{:?}", self.input_mode),
screen: screen.label().to_string(),
});
// --- Stage 1: Quit check ---
// Ctrl+C always quits regardless of mode.
if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) {
return Cmd::quit();
}
// --- Stage 2: InputMode routing ---
match &self.input_mode {
InputMode::Text => {
return self.handle_text_mode_key(&key, &screen);
}
InputMode::Palette => {
return self.handle_palette_mode_key(&key, &screen);
}
InputMode::GoPrefix { started_at } => {
let elapsed = self.clock.now().signed_duration_since(*started_at);
if elapsed > GO_PREFIX_TIMEOUT {
// Timeout expired — cancel prefix and re-process as normal.
self.input_mode = InputMode::Normal;
} else {
return self.handle_go_prefix_key(&key, &screen);
}
}
InputMode::Normal => {}
}
// --- Stage 3: Global shortcuts (Normal mode) ---
// 'q' quits.
if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE {
return Cmd::quit();
}
// 'g' starts prefix sequence.
if self
.command_registry
.is_sequence_starter(&key.code, &key.modifiers)
{
self.input_mode = InputMode::GoPrefix {
started_at: self.clock.now(),
};
return Cmd::none();
}
// Registry-based single-key lookup.
if let Some(cmd_def) =
self.command_registry
.lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode)
{
return self.execute_command(cmd_def.id, &screen);
}
// --- Stage 4: Screen-local keys ---
// Delegated to AppState::interpret_screen_key in future phases.
// --- Stage 5: Fallback (unhandled) ---
Cmd::none()
}
/// Handle keys in Text input mode.
///
/// Only Esc and Ctrl+P pass through; everything else is consumed by
/// the focused text widget (handled in future phases).
fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
// Esc blurs the text input.
if key.code == KeyCode::Escape {
self.state.blur_text_focus();
self.input_mode = InputMode::Normal;
return Cmd::none();
}
// Ctrl+P opens palette even in text mode.
if let Some(cmd_def) =
self.command_registry
.lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text)
{
return self.execute_command(cmd_def.id, screen);
}
// All other keys consumed by text widget (future).
Cmd::none()
}
/// Handle keys in Palette mode.
fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd<Msg> {
if key.code == KeyCode::Escape {
self.input_mode = InputMode::Normal;
return Cmd::none();
}
// Palette key dispatch will be expanded in the palette widget phase.
Cmd::none()
}
/// Handle the second key of a g-prefix sequence.
fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
self.input_mode = InputMode::Normal;
if let Some(cmd_def) = self.command_registry.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&key.code,
&key.modifiers,
screen,
) {
return self.execute_command(cmd_def.id, screen);
}
// Invalid second key — cancel prefix silently.
Cmd::none()
}
/// Execute a command by ID.
fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd<Msg> {
match id {
"quit" => Cmd::quit(),
"go_back" => {
self.navigation.pop();
Cmd::none()
}
"show_help" => {
self.state.show_help = !self.state.show_help;
Cmd::none()
}
"command_palette" => {
self.input_mode = InputMode::Palette;
self.state.command_palette.query_focused = true;
Cmd::none()
}
"open_in_browser" => {
// Will dispatch OpenInBrowser msg in future phase.
Cmd::none()
}
"show_cli" => {
// Will show CLI equivalent in future phase.
Cmd::none()
}
"go_home" => self.navigate_to(Screen::Dashboard),
"go_issues" => self.navigate_to(Screen::IssueList),
"go_mrs" => self.navigate_to(Screen::MrList),
"go_search" => self.navigate_to(Screen::Search),
"go_timeline" => self.navigate_to(Screen::Timeline),
"go_who" => self.navigate_to(Screen::Who),
"go_sync" => self.navigate_to(Screen::Sync),
"jump_back" => {
self.navigation.jump_back();
Cmd::none()
}
"jump_forward" => {
self.navigation.jump_forward();
Cmd::none()
}
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
// Screen-specific actions — delegated in future phases.
Cmd::none()
}
_ => Cmd::none(),
}
}
// -----------------------------------------------------------------------
// Navigation helpers
// -----------------------------------------------------------------------
/// Navigate to a screen, pushing the nav stack and starting a data load.
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
let screen_label = screen.label().to_string();
let current_label = self.navigation.current().label().to_string();
self.crash_context.push(CrashEvent::StateTransition {
from: current_label,
to: screen_label,
});
self.navigation.push(screen.clone());
// First visit → full-screen spinner; revisit → corner spinner over stale data.
let load_state = if self.state.load_state.was_visited(&screen) {
LoadState::Refreshing
} else {
LoadState::LoadingInitial
};
self.state.set_loading(screen.clone(), load_state);
// Spawn supervised task for data loading (placeholder — actual DB
// query dispatch comes in Phase 2 screen implementations).
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
Cmd::none()
}
// -----------------------------------------------------------------------
// Message dispatch (non-key)
// -----------------------------------------------------------------------
/// Handle non-key messages.
pub(crate) fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
// Record in crash context.
self.crash_context.push(CrashEvent::MsgDispatched {
msg_name: format!("{msg:?}")
.split('(')
.next()
.unwrap_or("?")
.to_string(),
screen: self.navigation.current().label().to_string(),
});
match msg {
Msg::Quit => Cmd::quit(),
// --- Navigation ---
Msg::NavigateTo(screen) => self.navigate_to(screen),
Msg::GoBack => {
self.navigation.pop();
Cmd::none()
}
Msg::GoForward => {
self.navigation.go_forward();
Cmd::none()
}
Msg::GoHome => self.navigate_to(Screen::Dashboard),
Msg::JumpBack(_) => {
self.navigation.jump_back();
Cmd::none()
}
Msg::JumpForward(_) => {
self.navigation.jump_forward();
Cmd::none()
}
// --- Error ---
Msg::Error(err) => {
self.state.set_error(err.to_string());
Cmd::none()
}
// --- Help / UI ---
Msg::ShowHelp => {
self.state.show_help = !self.state.show_help;
Cmd::none()
}
Msg::BlurTextInput => {
self.state.blur_text_focus();
self.input_mode = InputMode::Normal;
Cmd::none()
}
// --- Terminal ---
Msg::Resize { width, height } => {
self.state.terminal_size = (width, height);
Cmd::none()
}
Msg::Tick => Cmd::none(),
// --- Loaded results (stale guard) ---
Msg::IssueListLoaded { generation, page } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
{
self.state.issue_list.apply_page(page);
self.state.set_loading(Screen::IssueList, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
}
Cmd::none()
}
Msg::MrListLoaded { generation, page } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
{
self.state.mr_list.apply_page(page);
self.state.set_loading(Screen::MrList, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
}
Cmd::none()
}
Msg::DashboardLoaded { generation, data } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
{
self.state.dashboard.update(*data);
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
}
Cmd::none()
}
// --- Issue detail ---
Msg::IssueDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.issue_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
Msg::DiscussionsLoaded {
generation,
key,
discussions,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.issue_detail.apply_discussions(discussions);
}
Cmd::none()
}
// --- MR detail ---
Msg::MrDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::MrDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.mr_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
// All other message variants: no-op for now.
// Future phases will fill these in as screens are implemented.
_ => Cmd::none(),
}
}
}
impl Model for LoreApp {
type Message = Msg;
fn init(&mut self) -> Cmd<Self::Message> {
// Install crash context panic hook.
crate::crash_context::CrashContext::install_panic_hook(&self.crash_context);
crate::crash_context::CrashContext::prune_crash_files();
// Navigate to dashboard (will trigger data load in future phase).
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
// Route raw key events through the 5-stage pipeline.
if let Msg::RawEvent(Event::Key(key)) = msg {
return self.interpret_key(key);
}
// Everything else goes through message dispatch.
self.handle_msg(msg)
}
fn view(&self, frame: &mut Frame) {
crate::view::render_screen(frame, self);
}
}

View File

@@ -0,0 +1,165 @@
//! Injected clock for deterministic time in tests and consistent frame timestamps.
//!
//! All relative-time rendering (e.g., "3h ago") uses [`Clock::now()`] rather
//! than wall-clock time directly. This enables:
//! - Deterministic snapshot tests via [`FakeClock`]
//! - Consistent timestamps within a single frame render pass
use std::sync::{Arc, Mutex};
use chrono::{DateTime, TimeDelta, Utc};
/// Trait for obtaining the current time.
///
/// Inject via `Arc<dyn Clock>` to allow swapping between real and fake clocks.
pub trait Clock: Send + Sync {
/// Returns the current time.
fn now(&self) -> DateTime<Utc>;
/// Returns the current time as milliseconds since the Unix epoch.
fn now_ms(&self) -> i64 {
self.now().timestamp_millis()
}
}
// ---------------------------------------------------------------------------
// SystemClock
// ---------------------------------------------------------------------------
/// Real wall-clock time via `chrono::Utc::now()`.
#[derive(Debug, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
// ---------------------------------------------------------------------------
// FakeClock
// ---------------------------------------------------------------------------
/// A controllable clock for tests. Returns a frozen time that can be
/// advanced or set explicitly.
///
/// `FakeClock` is `Clone` (shares the inner `Arc`) and `Send + Sync`
/// for use across `Cmd::task` threads.
#[derive(Debug, Clone)]
pub struct FakeClock {
inner: Arc<Mutex<DateTime<Utc>>>,
}
impl FakeClock {
/// Create a fake clock frozen at the given time.
#[must_use]
pub fn new(time: DateTime<Utc>) -> Self {
Self {
inner: Arc::new(Mutex::new(time)),
}
}
/// Create a fake clock frozen at the given millisecond epoch timestamp.
///
/// Convenience for action tests that work with raw epoch milliseconds.
#[must_use]
pub fn from_ms(epoch_ms: i64) -> Self {
let time = DateTime::from_timestamp_millis(epoch_ms).expect("valid millisecond timestamp");
Self::new(time)
}
/// Advance the clock by `duration`. Uses `checked_add` to handle overflow
/// gracefully — if the addition would overflow, the time is not changed.
pub fn advance(&self, duration: TimeDelta) {
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
if let Some(advanced) = guard.checked_add_signed(duration) {
*guard = advanced;
}
}
/// Set the clock to an exact time.
pub fn set(&self, time: DateTime<Utc>) {
let mut guard = self.inner.lock().expect("FakeClock mutex poisoned");
*guard = time;
}
}
impl Clock for FakeClock {
fn now(&self) -> DateTime<Utc> {
*self.inner.lock().expect("FakeClock mutex poisoned")
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed_time() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 2, 12, 12, 0, 0).unwrap()
}
#[test]
fn test_fake_clock_frozen() {
let clock = FakeClock::new(fixed_time());
let t1 = clock.now();
let t2 = clock.now();
assert_eq!(t1, t2);
assert_eq!(t1, fixed_time());
}
#[test]
fn test_fake_clock_advance() {
let clock = FakeClock::new(fixed_time());
clock.advance(TimeDelta::hours(3));
let expected = Utc.with_ymd_and_hms(2026, 2, 12, 15, 0, 0).unwrap();
assert_eq!(clock.now(), expected);
}
#[test]
fn test_fake_clock_set() {
let clock = FakeClock::new(fixed_time());
let new_time = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
clock.set(new_time);
assert_eq!(clock.now(), new_time);
}
#[test]
fn test_fake_clock_clone_shares_state() {
let clock1 = FakeClock::new(fixed_time());
let clock2 = clock1.clone();
clock1.advance(TimeDelta::minutes(30));
// Both clones see the advanced time.
assert_eq!(clock1.now(), clock2.now());
}
#[test]
fn test_system_clock_returns_reasonable_time() {
let clock = SystemClock;
let now = clock.now();
// Sanity: time should be after 2025.
assert!(now.year() >= 2025);
}
#[test]
fn test_fake_clock_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<FakeClock>();
assert_send_sync::<SystemClock>();
}
#[test]
fn test_clock_trait_object_works() {
let fake: Arc<dyn Clock> = Arc::new(FakeClock::new(fixed_time()));
assert_eq!(fake.now(), fixed_time());
let real: Arc<dyn Clock> = Arc::new(SystemClock);
let _ = real.now(); // Just verify it doesn't panic.
}
use chrono::Datelike;
}

View File

@@ -0,0 +1,807 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Command registry — single source of truth for all TUI actions.
//!
//! Every keybinding, palette entry, help text, CLI equivalent, and
//! status hint is generated from [`CommandRegistry`]. No hardcoded
//! duplicate maps exist in view/state modules.
//!
//! Supports single-key and two-key sequences (g-prefix vim bindings).
use std::collections::HashMap;
use ftui::{KeyCode, Modifiers};
use crate::message::{InputMode, Screen};
// ---------------------------------------------------------------------------
// Key formatting
// ---------------------------------------------------------------------------
/// Format a key code + modifiers as a human-readable string.
fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
let mut parts = Vec::new();
if modifiers.contains(Modifiers::CTRL) {
parts.push("Ctrl");
}
if modifiers.contains(Modifiers::ALT) {
parts.push("Alt");
}
if modifiers.contains(Modifiers::SHIFT) {
parts.push("Shift");
}
let key_name = match code {
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Escape => "Esc".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Delete => "Del".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PgUp".to_string(),
KeyCode::PageDown => "PgDn".to_string(),
KeyCode::F(n) => format!("F{n}"),
_ => "?".to_string(),
};
parts.push(&key_name);
// We need to own the joined string.
let joined: String = parts.join("+");
joined
}
// ---------------------------------------------------------------------------
// KeyCombo
// ---------------------------------------------------------------------------
/// A keybinding: either a single key or a two-key sequence.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum KeyCombo {
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
Single { code: KeyCode, modifiers: Modifiers },
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
Sequence {
first_code: KeyCode,
first_modifiers: Modifiers,
second_code: KeyCode,
second_modifiers: Modifiers,
},
}
impl KeyCombo {
/// Convenience: single key with no modifiers.
#[must_use]
pub const fn key(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::NONE,
}
}
/// Convenience: single key with Ctrl modifier.
#[must_use]
pub const fn ctrl(code: KeyCode) -> Self {
Self::Single {
code,
modifiers: Modifiers::CTRL,
}
}
/// Convenience: g-prefix sequence (g + char).
#[must_use]
pub const fn g_then(c: char) -> Self {
Self::Sequence {
first_code: KeyCode::Char('g'),
first_modifiers: Modifiers::NONE,
second_code: KeyCode::Char(c),
second_modifiers: Modifiers::NONE,
}
}
/// Human-readable display string for this key combo.
#[must_use]
pub fn display(&self) -> String {
match self {
Self::Single { code, modifiers } => format_key(*code, *modifiers),
Self::Sequence {
first_code,
first_modifiers,
second_code,
second_modifiers,
} => {
let first = format_key(*first_code, *first_modifiers);
let second = format_key(*second_code, *second_modifiers);
format!("{first} {second}")
}
}
}
/// Whether this combo starts with the given key.
#[must_use]
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
match self {
Self::Single {
code: c,
modifiers: m,
} => c == code && m == modifiers,
Self::Sequence {
first_code,
first_modifiers,
..
} => first_code == code && first_modifiers == modifiers,
}
}
}
// ---------------------------------------------------------------------------
// ScreenFilter
// ---------------------------------------------------------------------------
/// Specifies which screens a command is available on.
#[derive(Debug, Clone)]
pub enum ScreenFilter {
/// Available on all screens.
Global,
/// Available only on specific screens.
Only(Vec<Screen>),
}
impl ScreenFilter {
/// Whether the command is available on the given screen.
#[must_use]
pub fn matches(&self, screen: &Screen) -> bool {
match self {
Self::Global => true,
Self::Only(screens) => screens.contains(screen),
}
}
}
// ---------------------------------------------------------------------------
// CommandDef
// ---------------------------------------------------------------------------
/// Unique command identifier.
pub type CommandId = &'static str;
/// A registered command with its keybinding, help text, and scope.
#[derive(Debug, Clone)]
pub struct CommandDef {
/// Unique identifier (e.g., "quit", "go_issues").
pub id: CommandId,
/// Human-readable label for palette and help overlay.
pub label: &'static str,
/// Keybinding (if any).
pub keybinding: Option<KeyCombo>,
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
pub cli_equivalent: Option<&'static str>,
/// Description for help overlay.
pub help_text: &'static str,
/// Short hint for status bar (e.g., "q:quit").
pub status_hint: &'static str,
/// Which screens this command is available on.
pub available_in: ScreenFilter,
/// Whether this command works in Text input mode.
pub available_in_text_mode: bool,
}
// ---------------------------------------------------------------------------
// CommandRegistry
// ---------------------------------------------------------------------------
/// Single source of truth for all TUI commands.
///
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
/// by keybinding and per-screen filtering.
pub struct CommandRegistry {
commands: Vec<CommandDef>,
/// Single-key -> command IDs that start with this key.
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
/// Full sequence -> command index (for two-key combos).
by_sequence: HashMap<KeyCombo, usize>,
}
impl CommandRegistry {
/// Look up a command by a single key press on a given screen and input mode.
///
/// Returns `None` if no matching command is found. For sequence starters
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
/// that case.
#[must_use]
pub fn lookup_key(
&self,
code: &KeyCode,
modifiers: &Modifiers,
screen: &Screen,
mode: &InputMode,
) -> Option<&CommandDef> {
let is_text = matches!(mode, InputMode::Text);
let key = (*code, *modifiers);
let indices = self.by_single_key.get(&key)?;
for &idx in indices {
let cmd = &self.commands[idx];
if !cmd.available_in.matches(screen) {
continue;
}
if is_text && !cmd.available_in_text_mode {
continue;
}
// Only match Single combos here, not sequence starters.
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
return Some(cmd);
}
}
None
}
/// Complete a two-key sequence.
///
/// Called after the first key of a sequence is detected (e.g., after 'g').
#[must_use]
pub fn complete_sequence(
&self,
first_code: &KeyCode,
first_modifiers: &Modifiers,
second_code: &KeyCode,
second_modifiers: &Modifiers,
screen: &Screen,
) -> Option<&CommandDef> {
let combo = KeyCombo::Sequence {
first_code: *first_code,
first_modifiers: *first_modifiers,
second_code: *second_code,
second_modifiers: *second_modifiers,
};
let &idx = self.by_sequence.get(&combo)?;
let cmd = &self.commands[idx];
if cmd.available_in.matches(screen) {
Some(cmd)
} else {
None
}
}
/// Whether a key starts a multi-key sequence (e.g., 'g').
#[must_use]
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
self.by_sequence
.keys()
.any(|combo| combo.starts_with(code, modifiers))
}
/// Commands available for the command palette on a given screen.
///
/// Returned sorted by label.
#[must_use]
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
let mut entries: Vec<&CommandDef> = self
.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.collect();
entries.sort_by_key(|c| c.label);
entries
}
/// Commands for the help overlay on a given screen.
#[must_use]
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| c.keybinding.is_some())
.collect()
}
/// Status bar hints for the current screen.
#[must_use]
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
self.commands
.iter()
.filter(|c| c.available_in.matches(screen))
.filter(|c| !c.status_hint.is_empty())
.map(|c| c.status_hint)
.collect()
}
/// Total number of registered commands.
#[must_use]
pub fn len(&self) -> usize {
self.commands.len()
}
/// Whether the registry has no commands.
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
}
// ---------------------------------------------------------------------------
// build_registry
// ---------------------------------------------------------------------------
/// Build the command registry with all TUI commands.
///
/// This is the single source of truth — every keybinding, help text,
/// and palette entry originates here.
#[must_use]
pub fn build_registry() -> CommandRegistry {
let commands = vec![
// --- Global commands ---
CommandDef {
id: "quit",
label: "Quit",
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
cli_equivalent: None,
help_text: "Exit the TUI",
status_hint: "q:quit",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_back",
label: "Go Back",
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
cli_equivalent: None,
help_text: "Go back to previous screen",
status_hint: "esc:back",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "show_help",
label: "Help",
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
cli_equivalent: None,
help_text: "Show keybinding help overlay",
status_hint: "?:help",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "command_palette",
label: "Command Palette",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
cli_equivalent: None,
help_text: "Open command palette",
status_hint: "C-p:palette",
available_in: ScreenFilter::Global,
available_in_text_mode: true,
},
CommandDef {
id: "open_in_browser",
label: "Open in Browser",
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Open current entity in browser",
status_hint: "o:browser",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "show_cli",
label: "Show CLI Equivalent",
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
cli_equivalent: None,
help_text: "Show equivalent lore CLI command",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Navigation: g-prefix sequences ---
CommandDef {
id: "go_home",
label: "Go to Dashboard",
keybinding: Some(KeyCombo::g_then('h')),
cli_equivalent: None,
help_text: "Jump to dashboard",
status_hint: "gh:home",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_issues",
label: "Go to Issues",
keybinding: Some(KeyCombo::g_then('i')),
cli_equivalent: Some("lore issues"),
help_text: "Jump to issue list",
status_hint: "gi:issues",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_mrs",
label: "Go to Merge Requests",
keybinding: Some(KeyCombo::g_then('m')),
cli_equivalent: Some("lore mrs"),
help_text: "Jump to MR list",
status_hint: "gm:mrs",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_search",
label: "Go to Search",
keybinding: Some(KeyCombo::g_then('/')),
cli_equivalent: Some("lore search"),
help_text: "Jump to search",
status_hint: "g/:search",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_timeline",
label: "Go to Timeline",
keybinding: Some(KeyCombo::g_then('t')),
cli_equivalent: Some("lore timeline"),
help_text: "Jump to timeline",
status_hint: "gt:timeline",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_who",
label: "Go to Who",
keybinding: Some(KeyCombo::g_then('w')),
cli_equivalent: Some("lore who"),
help_text: "Jump to people intelligence",
status_hint: "gw:who",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "go_sync",
label: "Go to Sync",
keybinding: Some(KeyCombo::g_then('s')),
cli_equivalent: Some("lore sync"),
help_text: "Jump to sync status",
status_hint: "gs:sync",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- Vim-style jump list ---
CommandDef {
id: "jump_back",
label: "Jump Back",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
cli_equivalent: None,
help_text: "Jump backward through visited detail views",
status_hint: "C-o:jump back",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
CommandDef {
id: "jump_forward",
label: "Jump Forward",
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
cli_equivalent: None,
help_text: "Jump forward through visited detail views",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
// --- List navigation ---
CommandDef {
id: "move_down",
label: "Move Down",
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
cli_equivalent: None,
help_text: "Move cursor down",
status_hint: "j:down",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "move_up",
label: "Move Up",
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
cli_equivalent: None,
help_text: "Move cursor up",
status_hint: "k:up",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
Screen::Timeline,
]),
available_in_text_mode: false,
},
CommandDef {
id: "select_item",
label: "Select",
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
cli_equivalent: None,
help_text: "Open selected item",
status_hint: "enter:open",
available_in: ScreenFilter::Only(vec![
Screen::IssueList,
Screen::MrList,
Screen::Search,
]),
available_in_text_mode: false,
},
// --- Filter ---
CommandDef {
id: "focus_filter",
label: "Filter",
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
cli_equivalent: None,
help_text: "Focus the filter input",
status_hint: "/:filter",
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
available_in_text_mode: false,
},
// --- Scroll ---
CommandDef {
id: "scroll_to_top",
label: "Scroll to Top",
keybinding: Some(KeyCombo::g_then('g')),
cli_equivalent: None,
help_text: "Scroll to the top of the current view",
status_hint: "",
available_in: ScreenFilter::Global,
available_in_text_mode: false,
},
];
build_from_defs(commands)
}
/// Build index maps from a list of command definitions.
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
for (idx, cmd) in commands.iter().enumerate() {
if let Some(combo) = &cmd.keybinding {
match combo {
KeyCombo::Single { code, modifiers } => {
by_single_key
.entry((*code, *modifiers))
.or_default()
.push(idx);
}
KeyCombo::Sequence { .. } => {
by_sequence.insert(combo.clone(), idx);
// Also index the first key so is_sequence_starter works via by_single_key.
if let KeyCombo::Sequence {
first_code,
first_modifiers,
..
} = combo
{
by_single_key
.entry((*first_code, *first_modifiers))
.or_default()
.push(idx);
}
}
}
}
}
CommandRegistry {
commands,
by_single_key,
by_sequence,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_registry_builds_successfully() {
let reg = build_registry();
assert!(!reg.is_empty());
assert!(reg.len() >= 15);
}
#[test]
fn test_registry_lookup_quit() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_registry_lookup_quit_blocked_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Text,
);
assert!(cmd.is_none());
}
#[test]
fn test_registry_esc_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Escape,
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_back");
}
#[test]
fn test_registry_ctrl_p_works_in_text_mode() {
let reg = build_registry();
let cmd = reg.lookup_key(
&KeyCode::Char('p'),
&Modifiers::CTRL,
&Screen::Search,
&InputMode::Text,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "command_palette");
}
#[test]
fn test_g_is_sequence_starter() {
let reg = build_registry();
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
}
#[test]
fn test_complete_sequence_gi() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('i'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "go_issues");
}
#[test]
fn test_complete_sequence_invalid_second_key() {
let reg = build_registry();
let cmd = reg.complete_sequence(
&KeyCode::Char('g'),
&Modifiers::NONE,
&KeyCode::Char('x'),
&Modifiers::NONE,
&Screen::Dashboard,
);
assert!(cmd.is_none());
}
#[test]
fn test_screen_specific_command() {
let reg = build_registry();
// 'j' (move_down) should work on IssueList
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::IssueList,
&InputMode::Normal,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "move_down");
// 'j' should NOT match on Dashboard (move_down is list-only).
let cmd = reg.lookup_key(
&KeyCode::Char('j'),
&Modifiers::NONE,
&Screen::Dashboard,
&InputMode::Normal,
);
assert!(cmd.is_none());
}
#[test]
fn test_palette_entries_sorted_by_label() {
let reg = build_registry();
let entries = reg.palette_entries(&Screen::Dashboard);
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
let mut sorted = labels.clone();
sorted.sort();
assert_eq!(labels, sorted);
}
#[test]
fn test_help_entries_only_include_keybindings() {
let reg = build_registry();
let entries = reg.help_entries(&Screen::Dashboard);
for entry in &entries {
assert!(
entry.keybinding.is_some(),
"help entry without keybinding: {}",
entry.id
);
}
}
#[test]
fn test_status_hints_non_empty() {
let reg = build_registry();
let hints = reg.status_hints(&Screen::Dashboard);
assert!(!hints.is_empty());
// All returned hints should be non-empty strings.
for hint in &hints {
assert!(!hint.is_empty());
}
}
#[test]
fn test_cli_equivalents_populated() {
let reg = build_registry();
let with_cli: Vec<&CommandDef> = reg
.commands
.iter()
.filter(|c| c.cli_equivalent.is_some())
.collect();
assert!(
with_cli.len() >= 5,
"expected at least 5 commands with cli_equivalent, got {}",
with_cli.len()
);
}
#[test]
fn test_go_prefix_timeout_detection() {
let reg = build_registry();
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
// Simulate InputMode::GoPrefix with timeout check.
let started = Utc::now();
let mode = InputMode::GoPrefix {
started_at: started,
};
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
let cmd = reg.lookup_key(
&KeyCode::Char('q'),
&Modifiers::NONE,
&Screen::Dashboard,
&mode,
);
assert!(cmd.is_some());
assert_eq!(cmd.unwrap().id, "quit");
}
#[test]
fn test_all_commands_have_nonempty_help() {
let reg = build_registry();
for cmd in &reg.commands {
assert!(
!cmd.help_text.is_empty(),
"command {} has empty help_text",
cmd.id
);
}
}
}

View 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,
}

View 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 &reg.commands {
assert!(
!cmd.help_text.is_empty(),
"command {} has empty help_text",
cmd.id
);
}
}
}

View 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,
}
}

View File

@@ -0,0 +1,450 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Ring buffer of recent app events for post-mortem crash diagnostics.
//!
//! The TUI pushes every key press, message dispatch, and state transition
//! into [`CrashContext`]. On panic the installed hook dumps the last 2000
//! events to `~/.local/share/lore/crash-<timestamp>.json` as NDJSON.
//!
//! Retention: only the 5 most recent crash files are kept.
use std::collections::VecDeque;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::Serialize;
/// Maximum number of events retained in the ring buffer.
const MAX_EVENTS: usize = 2000;
/// Maximum number of crash files to keep on disk.
const MAX_CRASH_FILES: usize = 5;
// ---------------------------------------------------------------------------
// CrashEvent
// ---------------------------------------------------------------------------
/// A single event recorded for crash diagnostics.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum CrashEvent {
/// A key was pressed.
KeyPress {
key: String,
mode: String,
screen: String,
},
/// A message was dispatched through update().
MsgDispatched { msg_name: String, screen: String },
/// Navigation changed screens.
StateTransition { from: String, to: String },
/// An error occurred.
Error { message: String },
/// Catch-all for ad-hoc diagnostic breadcrumbs.
Custom { tag: String, detail: String },
}
// ---------------------------------------------------------------------------
// CrashContext
// ---------------------------------------------------------------------------
/// Ring buffer of recent app events for panic diagnostics.
///
/// Holds at most [`MAX_EVENTS`] entries. When full, the oldest event
/// is evicted on each push.
pub struct CrashContext {
events: VecDeque<CrashEvent>,
}
impl CrashContext {
/// Create an empty crash context with pre-allocated capacity.
#[must_use]
pub fn new() -> Self {
Self {
events: VecDeque::with_capacity(MAX_EVENTS),
}
}
/// Record an event. Evicts the oldest when the buffer is full.
pub fn push(&mut self, event: CrashEvent) {
if self.events.len() == MAX_EVENTS {
self.events.pop_front();
}
self.events.push_back(event);
}
/// Number of events currently stored.
#[must_use]
pub fn len(&self) -> usize {
self.events.len()
}
/// Whether the buffer is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Iterate over stored events (oldest first).
pub fn iter(&self) -> impl Iterator<Item = &CrashEvent> {
self.events.iter()
}
/// Dump all events to a file as newline-delimited JSON.
///
/// Creates parent directories if they don't exist.
/// Returns `Ok(())` on success, `Err` on I/O failure.
pub fn dump_to_file(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let mut writer = BufWriter::new(file);
for event in &self.events {
match serde_json::to_string(event) {
Ok(json) => {
writeln!(writer, "{json}")?;
}
Err(_) => {
// Fallback to debug format if serialization fails.
writeln!(
writer,
"{{\"type\":\"SerializationError\",\"debug\":\"{event:?}\"}}"
)?;
}
}
}
writer.flush()?;
Ok(())
}
/// Default crash directory: `~/.local/share/lore/`.
#[must_use]
pub fn crash_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join("lore"))
}
/// Generate a timestamped crash file path.
#[must_use]
pub fn crash_file_path() -> Option<PathBuf> {
let dir = Self::crash_dir()?;
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
Some(dir.join(format!("crash-{timestamp}.json")))
}
/// Remove old crash files, keeping only the most recent [`MAX_CRASH_FILES`].
///
/// Best-effort: silently ignores I/O errors on individual deletions.
pub fn prune_crash_files() {
let Some(dir) = Self::crash_dir() else {
return;
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return;
};
let mut crash_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
})
.collect();
// Sort ascending by filename (timestamps sort lexicographically).
crash_files.sort();
if crash_files.len() > MAX_CRASH_FILES {
let to_remove = crash_files.len() - MAX_CRASH_FILES;
for path in &crash_files[..to_remove] {
let _ = std::fs::remove_file(path);
}
}
}
/// Install a panic hook that dumps the crash context to disk.
///
/// Captures the current events via a snapshot. The hook chains with
/// the default panic handler so backtraces are still printed.
///
/// FIXME: This snapshots events at install time, which is typically
/// during init() when the buffer is empty. The crash dump will only
/// contain the panic itself, not the preceding key presses and state
/// transitions. Fix requires CrashContext to use interior mutability
/// (Arc<Mutex<VecDeque<CrashEvent>>>) so the panic hook reads live
/// state instead of a stale snapshot.
pub fn install_panic_hook(ctx: &Self) {
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Best-effort dump — never panic inside the panic hook.
if let Some(path) = Self::crash_file_path() {
let mut dump = CrashContext::new();
for event in &snapshot {
dump.push(event.clone());
}
// Add the panic info itself as the final event.
dump.push(CrashEvent::Error {
message: format!("{info}"),
});
let _ = dump.dump_to_file(&path);
}
// Chain to the previous hook (prints backtrace, etc.).
prev_hook(info);
}));
}
}
impl Default for CrashContext {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufRead;
/// Helper: create a numbered Custom event.
fn event(n: usize) -> CrashEvent {
CrashEvent::Custom {
tag: "test".into(),
detail: format!("event-{n}"),
}
}
#[test]
fn test_ring_buffer_evicts_oldest() {
let mut ctx = CrashContext::new();
for i in 0..2500 {
ctx.push(event(i));
}
assert_eq!(ctx.len(), MAX_EVENTS);
// First retained event should be #500 (0..499 evicted).
let first = ctx.iter().next().unwrap();
match first {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-500"),
other => panic!("unexpected variant: {other:?}"),
}
// Last retained event should be #2499.
let last = ctx.iter().last().unwrap();
match last {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-2499"),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn test_new_is_empty() {
let ctx = CrashContext::new();
assert!(ctx.is_empty());
assert_eq!(ctx.len(), 0);
}
#[test]
fn test_push_increments_len() {
let mut ctx = CrashContext::new();
ctx.push(event(1));
ctx.push(event(2));
assert_eq!(ctx.len(), 2);
}
#[test]
fn test_push_does_not_evict_below_capacity() {
let mut ctx = CrashContext::new();
for i in 0..MAX_EVENTS {
ctx.push(event(i));
}
assert_eq!(ctx.len(), MAX_EVENTS);
// First should still be event-0.
match ctx.iter().next().unwrap() {
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-0"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn test_dump_to_file_writes_ndjson() {
let mut ctx = CrashContext::new();
ctx.push(CrashEvent::KeyPress {
key: "j".into(),
mode: "Normal".into(),
screen: "Dashboard".into(),
});
ctx.push(CrashEvent::MsgDispatched {
msg_name: "NavigateTo".into(),
screen: "Dashboard".into(),
});
ctx.push(CrashEvent::StateTransition {
from: "Dashboard".into(),
to: "IssueList".into(),
});
ctx.push(CrashEvent::Error {
message: "db busy".into(),
});
ctx.push(CrashEvent::Custom {
tag: "test".into(),
detail: "hello".into(),
});
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test-crash.json");
ctx.dump_to_file(&path).unwrap();
// Verify: each line is valid JSON, total lines == 5.
let file = std::fs::File::open(&path).unwrap();
let reader = io::BufReader::new(file);
let lines: Vec<String> = reader.lines().map(Result::unwrap).collect();
assert_eq!(lines.len(), 5);
// Each line must parse as JSON.
for line in &lines {
let val: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(val.get("type").is_some(), "missing 'type' field: {line}");
}
// Spot check first line: KeyPress with correct fields.
let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(first["type"], "KeyPress");
assert_eq!(first["key"], "j");
assert_eq!(first["mode"], "Normal");
assert_eq!(first["screen"], "Dashboard");
}
#[test]
fn test_dump_creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("a").join("b").join("c").join("crash.json");
let mut ctx = CrashContext::new();
ctx.push(event(1));
ctx.dump_to_file(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_dump_empty_context_creates_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.json");
let ctx = CrashContext::new();
ctx.dump_to_file(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.is_empty());
}
#[test]
fn test_prune_keeps_newest_files() {
let dir = tempfile::tempdir().unwrap();
let crash_dir = dir.path();
// Create 8 crash files with ordered timestamps.
let filenames: Vec<String> = (0..8)
.map(|i| format!("crash-2026010{i}-120000.000.json"))
.collect();
for name in &filenames {
std::fs::write(crash_dir.join(name), "{}").unwrap();
}
// Prune, pointing at our temp dir.
prune_crash_files_in(crash_dir);
let remaining: Vec<String> = std::fs::read_dir(crash_dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("crash-") && n.ends_with(".json"))
.collect();
assert_eq!(remaining.len(), MAX_CRASH_FILES);
// Oldest 3 should be gone.
for name in filenames.iter().take(3) {
assert!(!remaining.contains(name));
}
// Newest 5 should remain.
for name in filenames.iter().skip(3) {
assert!(remaining.contains(name));
}
}
#[test]
fn test_all_event_variants_serialize() {
let events = vec![
CrashEvent::KeyPress {
key: "q".into(),
mode: "Normal".into(),
screen: "Dashboard".into(),
},
CrashEvent::MsgDispatched {
msg_name: "Quit".into(),
screen: "Dashboard".into(),
},
CrashEvent::StateTransition {
from: "Dashboard".into(),
to: "IssueList".into(),
},
CrashEvent::Error {
message: "oops".into(),
},
CrashEvent::Custom {
tag: "debug".into(),
detail: "trace".into(),
},
];
for event in events {
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("type").is_some());
}
}
#[test]
fn test_default_is_new() {
let ctx = CrashContext::default();
assert!(ctx.is_empty());
}
// -----------------------------------------------------------------------
// Test helper: prune files in a specific directory (not the real path).
// -----------------------------------------------------------------------
fn prune_crash_files_in(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut crash_files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
})
.collect();
crash_files.sort();
if crash_files.len() > MAX_CRASH_FILES {
let to_remove = crash_files.len() - MAX_CRASH_FILES;
for path in &crash_files[..to_remove] {
let _ = std::fs::remove_file(path);
}
}
}
}

270
crates/lore-tui/src/db.rs Normal file
View File

@@ -0,0 +1,270 @@
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
//! Database access layer for the TUI.
//!
//! Provides a read pool (3 connections, round-robin) plus a dedicated writer
//! connection. All connections use WAL mode and busy_timeout for concurrency.
//!
//! The TUI operates read-heavy: parallel queries for dashboard, list views,
//! and prefetch. Writes are rare (TUI-local state: scroll positions, bookmarks).
use std::path::Path;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::{Context, Result};
use rusqlite::Connection;
/// Number of reader connections in the pool.
const READER_COUNT: usize = 3;
// ---------------------------------------------------------------------------
// DbManager
// ---------------------------------------------------------------------------
/// Manages a pool of read-only connections plus a dedicated writer.
///
/// Designed for `Arc<DbManager>` sharing across FrankenTUI's `Cmd::task`
/// background threads. Each reader is individually `Mutex`-protected so
/// concurrent tasks can query different readers without blocking.
pub struct DbManager {
readers: Vec<Mutex<Connection>>,
writer: Mutex<Connection>,
next_reader: AtomicUsize,
}
impl DbManager {
/// Open a database at `path` with 3 reader + 1 writer connections.
///
/// All connections get WAL mode, 5000ms busy_timeout, and foreign keys.
/// Reader connections additionally set `query_only = ON` as a safety guard.
pub fn open(path: &Path) -> Result<Self> {
let mut readers = Vec::with_capacity(READER_COUNT);
for i in 0..READER_COUNT {
let conn =
open_connection(path).with_context(|| format!("opening reader connection {i}"))?;
conn.pragma_update(None, "query_only", "ON")
.context("setting query_only on reader")?;
readers.push(Mutex::new(conn));
}
let writer = open_connection(path).context("opening writer connection")?;
Ok(Self {
readers,
writer: Mutex::new(writer),
next_reader: AtomicUsize::new(0),
})
}
/// Execute a read-only query against the pool.
///
/// Selects the next reader via round-robin. The connection is borrowed
/// for the duration of `f` and cannot leak outside.
pub fn with_reader<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&Connection) -> Result<T>,
{
let idx = self.next_reader.fetch_add(1, Ordering::Relaxed) % READER_COUNT;
let conn = self.readers[idx].lock().expect("reader mutex poisoned");
f(&conn)
}
/// Execute a write operation against the dedicated writer.
///
/// Serialized via a single `Mutex`. The TUI writes infrequently
/// (bookmarks, scroll state) so contention is negligible.
pub fn with_writer<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&Connection) -> Result<T>,
{
let conn = self.writer.lock().expect("writer mutex poisoned");
f(&conn)
}
}
// ---------------------------------------------------------------------------
// Connection setup
// ---------------------------------------------------------------------------
/// Open a single SQLite connection with TUI-appropriate pragmas.
///
/// Mirrors lore's `create_connection` pragmas (WAL, busy_timeout, etc.)
/// but skips the sqlite-vec extension registration — the TUI reads standard
/// tables only, never vec0 virtual tables.
fn open_connection(path: &Path) -> Result<Connection> {
let conn = Connection::open(path).context("opening SQLite database")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.pragma_update(None, "busy_timeout", 5000)?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
Ok(conn)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// Create a temporary database file for testing.
///
/// Uses an atomic counter + thread ID to guarantee unique paths even
/// when tests run in parallel.
fn test_db_path() -> std::path::PathBuf {
use std::sync::atomic::AtomicU64;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join("lore-tui-tests");
std::fs::create_dir_all(&dir).expect("create test dir");
dir.join(format!(
"test-{}-{:?}-{n}.db",
std::process::id(),
std::thread::current().id(),
))
}
fn create_test_table(conn: &Connection) {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS test_items (id INTEGER PRIMARY KEY, name TEXT);",
)
.expect("create test table");
}
#[test]
fn test_dbmanager_opens_successfully() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
// Writer creates the test table
db.with_writer(|conn| {
create_test_table(conn);
Ok(())
})
.expect("create table via writer");
}
#[test]
fn test_reader_is_query_only() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
// Create table via writer first
db.with_writer(|conn| {
create_test_table(conn);
Ok(())
})
.unwrap();
// Attempt INSERT via reader — should fail
let result = db.with_reader(|conn| {
conn.execute("INSERT INTO test_items (name) VALUES ('boom')", [])
.map_err(|e| anyhow::anyhow!(e))?;
Ok(())
});
assert!(result.is_err(), "reader should reject writes");
}
#[test]
fn test_writer_allows_mutations() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
db.with_writer(|conn| {
create_test_table(conn);
conn.execute("INSERT INTO test_items (name) VALUES ('hello')", [])?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
assert_eq!(count, 1);
Ok(())
})
.expect("writer should allow mutations");
}
#[test]
fn test_round_robin_rotates_readers() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
// Call with_reader 6 times — should cycle through readers 0,1,2,0,1,2
for expected_cycle in 0..2 {
for expected_idx in 0..READER_COUNT {
let current = db.next_reader.load(Ordering::Relaxed);
assert_eq!(
current % READER_COUNT,
(expected_cycle * READER_COUNT + expected_idx) % READER_COUNT,
);
db.with_reader(|_conn| Ok(())).unwrap();
}
}
}
#[test]
fn test_reader_can_read_writer_data() {
let path = test_db_path();
let db = DbManager::open(&path).expect("open");
db.with_writer(|conn| {
create_test_table(conn);
conn.execute("INSERT INTO test_items (name) VALUES ('visible')", [])?;
Ok(())
})
.unwrap();
let name: String = db
.with_reader(|conn| {
let n: String =
conn.query_row("SELECT name FROM test_items WHERE id = 1", [], |r| r.get(0))?;
Ok(n)
})
.expect("reader should see writer's data");
assert_eq!(name, "visible");
}
#[test]
fn test_dbmanager_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<DbManager>();
}
#[test]
fn test_concurrent_reads() {
let path = test_db_path();
let db = Arc::new(DbManager::open(&path).expect("open"));
db.with_writer(|conn| {
create_test_table(conn);
for i in 0..10 {
conn.execute(
"INSERT INTO test_items (name) VALUES (?1)",
[format!("item-{i}")],
)?;
}
Ok(())
})
.unwrap();
let mut handles = Vec::new();
for _ in 0..6 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
db.with_reader(|conn| {
let count: i64 =
conn.query_row("SELECT COUNT(*) FROM test_items", [], |r| r.get(0))?;
assert_eq!(count, 10);
Ok(())
})
.expect("concurrent read should succeed");
}));
}
for h in handles {
h.join().expect("thread should not panic");
}
}
}

View 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"]);
}
}

View 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);
}
}

View File

@@ -0,0 +1,71 @@
#![forbid(unsafe_code)]
//! Gitlore TUI — terminal interface for exploring GitLab data locally.
//!
//! Built on FrankenTUI (Elm architecture): Model, update, view.
//! The `lore` CLI spawns `lore-tui` via PATH lookup at runtime.
use anyhow::Result;
// Phase 0 modules.
pub mod clock; // Clock trait: SystemClock + FakeClock (bd-2lg6)
pub mod message; // Msg, Screen, EntityKey, AppError, InputMode (bd-c9gk)
pub mod safety; // Terminal safety: sanitize + URL policy + redact (bd-3ir1)
pub mod db; // DbManager: read pool + dedicated writer (bd-2kop)
pub mod theme; // Flexoki theme: build_theme, state_color, label_style (bd-5ofk)
pub mod app; // LoreApp Model trait impl (Phase 0 proof: bd-2emv, full: bd-6pmy)
// Phase 1 modules.
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
pub mod crash_context; // CrashContext ring buffer + panic hook (bd-2fr7)
pub mod layout; // Responsive layout: breakpoints, columns, preview pane (bd-1pzj)
pub mod navigation; // NavigationStack: back/forward/jump list (bd-1qpp)
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
pub mod view; // View layer: render_screen + common widgets (bd-26f2)
// Phase 2 modules.
pub mod action; // Data-fetching actions for TUI screens (bd-35g5+)
pub mod filter_dsl; // Filter DSL tokenizer for list screen filter bars (bd-18qs)
/// Options controlling how the TUI launches.
#[derive(Debug, Clone)]
pub struct LaunchOptions {
/// Path to lore config file.
pub config_path: Option<String>,
/// Run a background sync before displaying data.
pub sync_on_start: bool,
/// Clear cached TUI state and start fresh.
pub fresh: bool,
/// Render backend: "crossterm" or "native".
pub render_mode: String,
/// Use ASCII-only box drawing characters.
pub ascii: bool,
/// Disable alternate screen (render inline).
pub no_alt_screen: bool,
}
/// Launch the TUI in browse mode (no sync).
///
/// Loads config from `options.config_path` (or default location),
/// opens the database read-only, and enters the FrankenTUI event loop.
pub fn launch_tui(options: LaunchOptions) -> Result<()> {
let _options = options;
// Phase 1 will wire this to LoreApp + App::fullscreen().run()
eprintln!("lore-tui: browse mode not yet implemented (Phase 1)");
Ok(())
}
/// Launch the TUI with an initial sync pass.
///
/// Runs `lore sync` in the background while displaying a progress screen,
/// then transitions to browse mode once sync completes.
pub fn launch_sync_tui(options: LaunchOptions) -> Result<()> {
let _options = options;
// Phase 2 will implement the sync progress screen
eprintln!("lore-tui: sync mode not yet implemented (Phase 2)");
Ok(())
}

View File

@@ -0,0 +1,53 @@
#![forbid(unsafe_code)]
use anyhow::Result;
use clap::Parser;
use lore_tui::LaunchOptions;
/// Terminal UI for Gitlore — explore GitLab issues, MRs, and search locally.
#[derive(Parser, Debug)]
#[command(name = "lore-tui", version, about)]
struct TuiCli {
/// Path to lore config file (default: ~/.config/lore/config.json).
#[arg(short, long, env = "LORE_CONFIG_PATH")]
config: Option<String>,
/// Run a sync before launching the TUI.
#[arg(long)]
sync: bool,
/// Clear cached state and start fresh.
#[arg(long)]
fresh: bool,
/// Render mode: "crossterm" (default) or "native".
#[arg(long, default_value = "crossterm")]
render_mode: String,
/// Use ASCII-only drawing characters (no Unicode box drawing).
#[arg(long)]
ascii: bool,
/// Disable alternate screen (render inline).
#[arg(long)]
no_alt_screen: bool,
}
fn main() -> Result<()> {
let cli = TuiCli::parse();
let options = LaunchOptions {
config_path: cli.config,
sync_on_start: cli.sync,
fresh: cli.fresh,
render_mode: cli.render_mode,
ascii: cli.ascii,
no_alt_screen: cli.no_alt_screen,
};
if options.sync_on_start {
lore_tui::launch_sync_tui(options)
} else {
lore_tui::launch_tui(options)
}
}

View File

@@ -0,0 +1,503 @@
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
//! Core types for the lore-tui Elm architecture.
//!
//! - [`Msg`] — every user action and async result flows through this enum.
//! - [`Screen`] — navigation targets.
//! - [`EntityKey`] — safe cross-project entity identity.
//! - [`AppError`] — structured error display in the TUI.
//! - [`InputMode`] — controls key dispatch routing.
use std::fmt;
use chrono::{DateTime, Utc};
use ftui::Event;
// ---------------------------------------------------------------------------
// EntityKind
// ---------------------------------------------------------------------------
/// Distinguishes issue vs merge request in an [`EntityKey`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntityKind {
Issue,
MergeRequest,
}
// ---------------------------------------------------------------------------
// EntityKey
// ---------------------------------------------------------------------------
/// Uniquely identifies an entity (issue or MR) across projects.
///
/// Bare `iid` is unsafe in multi-project datasets — equality requires
/// project_id + iid + kind.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct EntityKey {
pub project_id: i64,
pub iid: i64,
pub kind: EntityKind,
}
impl EntityKey {
#[must_use]
pub fn issue(project_id: i64, iid: i64) -> Self {
Self {
project_id,
iid,
kind: EntityKind::Issue,
}
}
#[must_use]
pub fn mr(project_id: i64, iid: i64) -> Self {
Self {
project_id,
iid,
kind: EntityKind::MergeRequest,
}
}
}
impl fmt::Display for EntityKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let prefix = match self.kind {
EntityKind::Issue => "#",
EntityKind::MergeRequest => "!",
};
write!(f, "p{}:{}{}", self.project_id, prefix, self.iid)
}
}
// ---------------------------------------------------------------------------
// Screen
// ---------------------------------------------------------------------------
/// Navigation targets within the TUI.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Screen {
Dashboard,
IssueList,
IssueDetail(EntityKey),
MrList,
MrDetail(EntityKey),
Search,
Timeline,
Who,
Sync,
Stats,
Doctor,
Bootstrap,
}
impl Screen {
/// Human-readable label for breadcrumbs and status bar.
#[must_use]
pub fn label(&self) -> &str {
match self {
Self::Dashboard => "Dashboard",
Self::IssueList => "Issues",
Self::IssueDetail(_) => "Issue",
Self::MrList => "Merge Requests",
Self::MrDetail(_) => "Merge Request",
Self::Search => "Search",
Self::Timeline => "Timeline",
Self::Who => "Who",
Self::Sync => "Sync",
Self::Stats => "Stats",
Self::Doctor => "Doctor",
Self::Bootstrap => "Bootstrap",
}
}
/// Whether this screen shows a specific entity detail view.
#[must_use]
pub fn is_detail_or_entity(&self) -> bool {
matches!(self, Self::IssueDetail(_) | Self::MrDetail(_))
}
}
// ---------------------------------------------------------------------------
// AppError
// ---------------------------------------------------------------------------
/// Structured error types for user-facing display in the TUI.
#[derive(Debug, Clone)]
pub enum AppError {
/// Database is busy (WAL contention).
DbBusy,
/// Database corruption detected.
DbCorruption(String),
/// GitLab rate-limited; retry after N seconds (if header present).
NetworkRateLimited { retry_after_secs: Option<u64> },
/// Network unavailable.
NetworkUnavailable,
/// GitLab authentication failed.
AuthFailed,
/// Data parsing error.
ParseError(String),
/// Internal / unexpected error.
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DbBusy => write!(f, "Database is busy — another process holds the lock"),
Self::DbCorruption(detail) => write!(f, "Database corruption: {detail}"),
Self::NetworkRateLimited {
retry_after_secs: Some(secs),
} => write!(f, "Rate limited by GitLab — retry in {secs}s"),
Self::NetworkRateLimited {
retry_after_secs: None,
} => write!(f, "Rate limited by GitLab — try again shortly"),
Self::NetworkUnavailable => write!(f, "Network unavailable — working offline"),
Self::AuthFailed => write!(f, "GitLab authentication failed — check your token"),
Self::ParseError(detail) => write!(f, "Parse error: {detail}"),
Self::Internal(detail) => write!(f, "Internal error: {detail}"),
}
}
}
// ---------------------------------------------------------------------------
// InputMode
// ---------------------------------------------------------------------------
/// Controls how keystrokes are routed through the key dispatch pipeline.
#[derive(Debug, Clone, Default)]
pub enum InputMode {
/// Standard navigation mode — keys dispatch to screen-specific handlers.
#[default]
Normal,
/// Text input focused (filter bar, search box).
Text,
/// Command palette is open.
Palette,
/// "g" prefix pressed — waiting for second key (500ms timeout).
GoPrefix { started_at: DateTime<Utc> },
}
// ---------------------------------------------------------------------------
// Msg
// ---------------------------------------------------------------------------
/// Every user action and async result flows through this enum.
///
/// Generation fields (`generation: u64`) on async result variants enable
/// stale-response detection: if the generation doesn't match the current
/// request generation, the result is silently dropped.
#[derive(Debug)]
pub enum Msg {
// --- Terminal events ---
/// Raw terminal event (key, mouse, paste, focus, clipboard).
RawEvent(Event),
/// Periodic tick from runtime subscription.
Tick,
/// Terminal resized.
Resize {
width: u16,
height: u16,
},
// --- Navigation ---
/// Navigate to a specific screen.
NavigateTo(Screen),
/// Go back in navigation history.
GoBack,
/// Go forward in navigation history.
GoForward,
/// Jump to the dashboard.
GoHome,
/// Jump back N screens in history.
JumpBack(usize),
/// Jump forward N screens in history.
JumpForward(usize),
// --- Command palette ---
OpenCommandPalette,
CloseCommandPalette,
CommandPaletteInput(String),
CommandPaletteSelect(String),
// --- Issue list ---
IssueListLoaded {
generation: u64,
page: crate::state::issue_list::IssueListPage,
},
IssueListFilterChanged(String),
IssueListSortChanged,
IssueSelected(EntityKey),
// --- MR list ---
MrListLoaded {
generation: u64,
page: crate::state::mr_list::MrListPage,
},
MrListFilterChanged(String),
MrSelected(EntityKey),
// --- Issue detail ---
IssueDetailLoaded {
generation: u64,
key: EntityKey,
data: Box<crate::state::issue_detail::IssueDetailData>,
},
// --- MR detail ---
MrDetailLoaded {
generation: u64,
key: EntityKey,
data: Box<crate::state::mr_detail::MrDetailData>,
},
// --- Discussions (shared by issue + MR detail) ---
DiscussionsLoaded {
generation: u64,
key: EntityKey,
discussions: Vec<crate::view::common::discussion_tree::DiscussionNode>,
},
// --- Search ---
SearchQueryChanged(String),
SearchRequestStarted {
generation: u64,
query: String,
},
SearchExecuted {
generation: u64,
results: Vec<SearchResult>,
},
SearchResultSelected(EntityKey),
SearchModeChanged,
SearchCapabilitiesLoaded,
// --- Timeline ---
TimelineLoaded {
generation: u64,
events: Vec<TimelineEvent>,
},
TimelineEntitySelected(EntityKey),
// --- Who (people) ---
WhoResultLoaded {
generation: u64,
result: Box<WhoResult>,
},
WhoModeChanged,
// --- Sync ---
SyncStarted,
SyncProgress {
stage: String,
current: u64,
total: u64,
},
SyncProgressBatch {
stage: String,
batch_size: u64,
},
SyncLogLine(String),
SyncBackpressureDrop,
SyncCompleted {
elapsed_ms: u64,
},
SyncCancelled,
SyncFailed(String),
SyncStreamStats {
bytes: u64,
items: u64,
},
// --- Search debounce ---
SearchDebounceArmed {
generation: u64,
},
SearchDebounceFired {
generation: u64,
},
// --- Dashboard ---
DashboardLoaded {
generation: u64,
data: Box<crate::state::dashboard::DashboardData>,
},
// --- Global actions ---
Error(AppError),
ShowHelp,
ShowCliEquivalent,
OpenInBrowser,
BlurTextInput,
ScrollToTopCurrentScreen,
Quit,
}
/// Convert terminal events into messages.
///
/// FrankenTUI requires `From<Event>` on the message type so the runtime
/// can inject terminal events into the model's update loop.
impl From<Event> for Msg {
fn from(event: Event) -> Self {
match event {
Event::Resize { width, height } => Self::Resize { width, height },
Event::Tick => Self::Tick,
other => Self::RawEvent(other),
}
}
}
// ---------------------------------------------------------------------------
// Placeholder data types (will be fleshed out in Phase 1+)
// ---------------------------------------------------------------------------
/// Placeholder for issue detail payload.
#[derive(Debug, Clone)]
pub struct IssueDetail {
pub key: EntityKey,
pub title: String,
pub description: String,
}
/// Placeholder for MR detail payload.
#[derive(Debug, Clone)]
pub struct MrDetail {
pub key: EntityKey,
pub title: String,
pub description: String,
}
/// Placeholder for a discussion thread.
#[derive(Debug, Clone)]
pub struct Discussion {
pub id: String,
pub notes: Vec<String>,
}
/// Placeholder for a search result.
#[derive(Debug, Clone)]
pub struct SearchResult {
pub key: EntityKey,
pub title: String,
pub score: f64,
}
/// Placeholder for a timeline event.
#[derive(Debug, Clone)]
pub struct TimelineEvent {
pub timestamp: String,
pub description: String,
}
/// Placeholder for who/people intelligence result.
#[derive(Debug, Clone)]
pub struct WhoResult {
pub experts: Vec<String>,
}
// DashboardData moved to crate::state::dashboard (enriched with
// EntityCounts, ProjectSyncInfo, RecentActivityItem, LastSyncInfo).
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entity_key_equality() {
assert_eq!(EntityKey::issue(1, 42), EntityKey::issue(1, 42));
assert_ne!(EntityKey::issue(1, 42), EntityKey::mr(1, 42));
}
#[test]
fn test_entity_key_different_projects() {
assert_ne!(EntityKey::issue(1, 42), EntityKey::issue(2, 42));
}
#[test]
fn test_entity_key_display() {
assert_eq!(EntityKey::issue(5, 123).to_string(), "p5:#123");
assert_eq!(EntityKey::mr(5, 456).to_string(), "p5:!456");
}
#[test]
fn test_entity_key_hash_is_usable_in_collections() {
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(EntityKey::issue(1, 1));
set.insert(EntityKey::issue(1, 1)); // duplicate
set.insert(EntityKey::mr(1, 1));
assert_eq!(set.len(), 2);
}
#[test]
fn test_screen_labels() {
assert_eq!(Screen::Dashboard.label(), "Dashboard");
assert_eq!(Screen::IssueList.label(), "Issues");
assert_eq!(Screen::MrList.label(), "Merge Requests");
assert_eq!(Screen::Search.label(), "Search");
}
#[test]
fn test_screen_is_detail_or_entity() {
assert!(Screen::IssueDetail(EntityKey::issue(1, 1)).is_detail_or_entity());
assert!(Screen::MrDetail(EntityKey::mr(1, 1)).is_detail_or_entity());
assert!(!Screen::Dashboard.is_detail_or_entity());
assert!(!Screen::IssueList.is_detail_or_entity());
assert!(!Screen::Search.is_detail_or_entity());
}
#[test]
fn test_app_error_display() {
let err = AppError::DbBusy;
assert!(err.to_string().contains("busy"));
let err = AppError::NetworkRateLimited {
retry_after_secs: Some(30),
};
assert!(err.to_string().contains("30s"));
let err = AppError::NetworkRateLimited {
retry_after_secs: None,
};
assert!(err.to_string().contains("shortly"));
let err = AppError::AuthFailed;
assert!(err.to_string().contains("token"));
}
#[test]
fn test_input_mode_default_is_normal() {
assert!(matches!(InputMode::default(), InputMode::Normal));
}
#[test]
fn test_msg_from_event_resize() {
let event = Event::Resize {
width: 80,
height: 24,
};
let msg = Msg::from(event);
assert!(matches!(
msg,
Msg::Resize {
width: 80,
height: 24
}
));
}
#[test]
fn test_msg_from_event_tick() {
let msg = Msg::from(Event::Tick);
assert!(matches!(msg, Msg::Tick));
}
#[test]
fn test_msg_from_event_focus_wraps_raw() {
let msg = Msg::from(Event::Focus(true));
assert!(matches!(msg, Msg::RawEvent(Event::Focus(true))));
}
}

View File

@@ -0,0 +1,350 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Browser-like navigation stack with vim-style jump list.
//!
//! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I),
//! and breadcrumb generation. State is preserved when navigating away —
//! screens are never cleared on pop.
use crate::message::Screen;
// ---------------------------------------------------------------------------
// NavigationStack
// ---------------------------------------------------------------------------
/// Browser-like navigation with back/forward stacks and a vim jump list.
///
/// The jump list only records "significant" hops — detail views and
/// cross-references — skipping list/dashboard screens that users
/// visit briefly during drilling.
pub struct NavigationStack {
back_stack: Vec<Screen>,
current: Screen,
forward_stack: Vec<Screen>,
jump_list: Vec<Screen>,
jump_index: usize,
}
impl NavigationStack {
/// Create a new stack starting at the Dashboard.
#[must_use]
pub fn new() -> Self {
Self {
back_stack: Vec::new(),
current: Screen::Dashboard,
forward_stack: Vec::new(),
jump_list: Vec::new(),
jump_index: 0,
}
}
/// The currently displayed screen.
#[must_use]
pub fn current(&self) -> &Screen {
&self.current
}
/// Whether the current screen matches the given screen.
#[must_use]
pub fn is_at(&self, screen: &Screen) -> bool {
&self.current == screen
}
/// Navigate to a new screen.
///
/// Pushes current to back_stack, clears forward_stack (browser behavior),
/// and records detail hops in the jump list.
pub fn push(&mut self, screen: Screen) {
let old = std::mem::replace(&mut self.current, screen);
self.back_stack.push(old);
self.forward_stack.clear();
// Record significant hops in jump list (vim behavior):
// Keep entries up to and including the current position, discard
// any forward entries beyond it, then append the new destination.
if self.current.is_detail_or_entity() {
self.jump_list.truncate(self.jump_index.saturating_add(1));
self.jump_list.push(self.current.clone());
self.jump_index = self.jump_list.len();
}
}
/// Go back to the previous screen.
///
/// Returns `None` at root (can't pop past the initial screen).
pub fn pop(&mut self) -> Option<&Screen> {
let prev = self.back_stack.pop()?;
let old = std::mem::replace(&mut self.current, prev);
self.forward_stack.push(old);
Some(&self.current)
}
/// Go forward (redo a pop).
///
/// Returns `None` if there's nothing to go forward to.
pub fn go_forward(&mut self) -> Option<&Screen> {
let next = self.forward_stack.pop()?;
let old = std::mem::replace(&mut self.current, next);
self.back_stack.push(old);
Some(&self.current)
}
/// Jump backward through the jump list (vim Ctrl+O).
///
/// Only visits detail/entity screens. Skips entries matching the
/// current screen so the first press always produces a visible change.
pub fn jump_back(&mut self) -> Option<&Screen> {
while self.jump_index > 0 {
self.jump_index -= 1;
if let Some(target) = self.jump_list.get(self.jump_index).cloned()
&& target != self.current
{
self.current = target;
return Some(&self.current);
}
}
None
}
/// Jump forward through the jump list (vim Ctrl+I).
///
/// Skips entries matching the current screen.
pub fn jump_forward(&mut self) -> Option<&Screen> {
while self.jump_index < self.jump_list.len() {
if let Some(target) = self.jump_list.get(self.jump_index).cloned() {
self.jump_index += 1;
if target != self.current {
self.current = target;
return Some(&self.current);
}
} else {
break;
}
}
None
}
/// Reset to a single screen, clearing all history.
pub fn reset_to(&mut self, screen: Screen) {
self.current = screen;
self.back_stack.clear();
self.forward_stack.clear();
self.jump_list.clear();
self.jump_index = 0;
}
/// Breadcrumb labels for the current navigation path.
///
/// Returns the back stack labels plus the current screen label.
#[must_use]
pub fn breadcrumbs(&self) -> Vec<&str> {
self.back_stack
.iter()
.chain(std::iter::once(&self.current))
.map(Screen::label)
.collect()
}
/// Navigation depth (1 = at root, 2 = one push deep, etc.).
#[must_use]
pub fn depth(&self) -> usize {
self.back_stack.len() + 1
}
/// Whether there's anything to go back to.
#[must_use]
pub fn can_go_back(&self) -> bool {
!self.back_stack.is_empty()
}
/// Whether there's anything to go forward to.
#[must_use]
pub fn can_go_forward(&self) -> bool {
!self.forward_stack.is_empty()
}
}
impl Default for NavigationStack {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::message::EntityKey;
#[test]
fn test_new_starts_at_dashboard() {
let nav = NavigationStack::new();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_push_pop_preserves_order() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42))));
assert_eq!(nav.depth(), 3);
nav.pop();
assert!(nav.is_at(&Screen::IssueList));
nav.pop();
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_pop_at_root_returns_none() {
let mut nav = NavigationStack::new();
assert!(nav.pop().is_none());
assert!(nav.is_at(&Screen::Dashboard));
}
#[test]
fn test_forward_stack_cleared_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList, Search in forward
assert!(nav.can_go_forward());
nav.push(Screen::Timeline); // new push clears forward
assert!(!nav.can_go_forward());
}
#[test]
fn test_go_forward_restores() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.pop(); // back to IssueList
let screen = nav.go_forward();
assert!(screen.is_some());
assert!(nav.is_at(&Screen::Search));
}
#[test]
fn test_go_forward_returns_none_when_empty() {
let mut nav = NavigationStack::new();
assert!(nav.go_forward().is_none());
}
#[test]
fn test_jump_list_skips_list_screens() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList); // not a detail — skip
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record
nav.push(Screen::MrList); // not a detail — skip
nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record
assert_eq!(nav.jump_list.len(), 2);
}
#[test]
fn test_jump_back_and_forward() {
let mut nav = NavigationStack::new();
let issue = Screen::IssueDetail(EntityKey::issue(1, 1));
let mr = Screen::MrDetail(EntityKey::mr(1, 2));
nav.push(Screen::IssueList);
nav.push(issue.clone());
nav.push(Screen::MrList);
nav.push(mr.clone());
// Current is MrDetail. jump_list = [IssueDetail, MrDetail], index = 2.
// First jump_back skips MrDetail (== current) and lands on IssueDetail.
let prev = nav.jump_back();
assert_eq!(prev, Some(&issue));
assert!(nav.is_at(&issue));
// Already at beginning of jump list.
assert!(nav.jump_back().is_none());
// jump_forward skips IssueDetail (== current) and lands on MrDetail.
let next = nav.jump_forward();
assert_eq!(next, Some(&mr));
assert!(nav.is_at(&mr));
// At end of jump list.
assert!(nav.jump_forward().is_none());
}
#[test]
fn test_jump_list_truncates_on_new_push() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
// jump back twice — lands on issue(1,1), jump_index = 0
nav.jump_back();
nav.jump_back();
// new detail push truncates forward entries
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
// should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3)
assert_eq!(nav.jump_list.len(), 2);
assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99)));
}
#[test]
fn test_reset_clears_all_history() {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
nav.push(Screen::Search);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
nav.reset_to(Screen::Dashboard);
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
assert!(nav.jump_list.is_empty());
}
#[test]
fn test_breadcrumbs_reflect_stack() {
let mut nav = NavigationStack::new();
assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]);
nav.push(Screen::IssueList);
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]);
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]);
}
#[test]
fn test_default_is_new() {
let nav = NavigationStack::default();
assert!(nav.is_at(&Screen::Dashboard));
assert_eq!(nav.depth(), 1);
}
#[test]
fn test_can_go_back_and_forward() {
let mut nav = NavigationStack::new();
assert!(!nav.can_go_back());
assert!(!nav.can_go_forward());
nav.push(Screen::IssueList);
assert!(nav.can_go_back());
assert!(!nav.can_go_forward());
nav.pop();
assert!(!nav.can_go_back());
assert!(nav.can_go_forward());
}
}

View File

@@ -0,0 +1,587 @@
//! Terminal safety: sanitize untrusted text, URL policy, credential redaction.
//!
//! GitLab content can contain ANSI escapes, bidi overrides, OSC hyperlinks,
//! and C1 control codes that could corrupt terminal rendering. This module
//! strips dangerous sequences while preserving a safe SGR subset for readability.
use std::fmt::Write;
// ---------------------------------------------------------------------------
// UrlPolicy
// ---------------------------------------------------------------------------
/// Controls how OSC 8 hyperlinks in input are handled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UrlPolicy {
/// Remove OSC 8 hyperlinks entirely, keeping only the link text.
#[default]
Strip,
/// Convert hyperlinks to numbered footnotes: `text [1]` with URL list appended.
Footnote,
/// Pass hyperlinks through unchanged (only for trusted content).
Passthrough,
}
// ---------------------------------------------------------------------------
// RedactPattern
// ---------------------------------------------------------------------------
/// Common patterns for PII/secret redaction.
#[derive(Debug, Clone)]
pub struct RedactPattern {
patterns: Vec<regex::Regex>,
}
impl RedactPattern {
/// Create a default set of redaction patterns (tokens, emails, etc.).
#[must_use]
pub fn defaults() -> Self {
let patterns = vec![
// GitLab personal access tokens
regex::Regex::new(r"glpat-[A-Za-z0-9_\-]{20,}").expect("valid regex"),
// Generic bearer/API tokens (long hex or base64-ish strings after common prefixes)
regex::Regex::new(r"(?i)(token|bearer|api[_-]?key)[\s:=]+\S{8,}").expect("valid regex"),
// Email addresses
regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
.expect("valid regex"),
];
Self { patterns }
}
/// Apply all redaction patterns to the input string.
#[must_use]
pub fn redact(&self, input: &str) -> String {
let mut result = input.to_string();
for pattern in &self.patterns {
result = pattern.replace_all(&result, "[REDACTED]").into_owned();
}
result
}
}
// ---------------------------------------------------------------------------
// sanitize_for_terminal
// ---------------------------------------------------------------------------
/// Sanitize untrusted text for safe terminal display.
///
/// - Strips C1 control codes (0x80-0x9F)
/// - Strips OSC sequences (ESC ] ... ST)
/// - Strips cursor movement CSI sequences (CSI n A/B/C/D/E/F/G/H/J/K)
/// - Strips bidi overrides (U+202A-U+202E, U+2066-U+2069)
/// - Preserves safe SGR subset (bold, italic, underline, reset, standard colors)
///
/// `url_policy` controls handling of OSC 8 hyperlinks.
#[must_use]
pub fn sanitize_for_terminal(input: &str, url_policy: UrlPolicy) -> String {
let mut output = String::with_capacity(input.len());
let mut footnotes: Vec<String> = Vec::new();
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
let ch = chars[i];
// --- Bidi overrides ---
if is_bidi_override(ch) {
i += 1;
continue;
}
// --- C1 control codes (U+0080-U+009F) ---
if ('\u{0080}'..='\u{009F}').contains(&ch) {
i += 1;
continue;
}
// --- C0 control codes except tab, newline, carriage return ---
if ch.is_ascii_control() && ch != '\t' && ch != '\n' && ch != '\r' && ch != '\x1B' {
i += 1;
continue;
}
// --- ESC sequences ---
if ch == '\x1B' {
if i + 1 < len {
match chars[i + 1] {
// CSI sequence: ESC [
'[' => {
let (consumed, safe_seq) = parse_csi(&chars, i);
if let Some(seq) = safe_seq {
output.push_str(&seq);
}
i += consumed;
continue;
}
// OSC sequence: ESC ]
']' => {
let (consumed, link_text, link_url) = parse_osc(&chars, i);
match url_policy {
UrlPolicy::Strip => {
if let Some(text) = link_text {
output.push_str(&text);
}
}
UrlPolicy::Footnote => {
if let (Some(text), Some(url)) = (link_text, link_url) {
footnotes.push(url);
let _ = write!(output, "{text} [{n}]", n = footnotes.len());
}
}
UrlPolicy::Passthrough => {
// Reproduce the raw OSC sequence
for &ch_raw in &chars[i..len.min(i + consumed)] {
output.push(ch_raw);
}
}
}
i += consumed;
continue;
}
_ => {
// Unknown ESC sequence — skip ESC + next char
i += 2;
continue;
}
}
} else {
// Trailing ESC at end of input
i += 1;
continue;
}
}
// --- Normal character ---
output.push(ch);
i += 1;
}
// Append footnotes if any
if !footnotes.is_empty() {
output.push('\n');
for (idx, url) in footnotes.iter().enumerate() {
let _ = write!(output, "\n[{}] {url}", idx + 1);
}
}
output
}
// ---------------------------------------------------------------------------
// Bidi check
// ---------------------------------------------------------------------------
fn is_bidi_override(ch: char) -> bool {
matches!(
ch,
'\u{202A}' // LRE
| '\u{202B}' // RLE
| '\u{202C}' // PDF
| '\u{202D}' // LRO
| '\u{202E}' // RLO
| '\u{2066}' // LRI
| '\u{2067}' // RLI
| '\u{2068}' // FSI
| '\u{2069}' // PDI
)
}
// ---------------------------------------------------------------------------
// CSI parser
// ---------------------------------------------------------------------------
/// Parse a CSI sequence starting at `chars[start]` (which should be ESC).
///
/// Returns `(chars_consumed, Option<safe_sequence_string>)`.
/// If the CSI is a safe SGR, returns the full sequence string to preserve.
/// Otherwise returns None (strip it).
fn parse_csi(chars: &[char], start: usize) -> (usize, Option<String>) {
// Minimum: ESC [ <final_byte>
debug_assert!(chars[start] == '\x1B');
debug_assert!(start + 1 < chars.len() && chars[start + 1] == '[');
let mut i = start + 2; // skip ESC [
let len = chars.len();
// Collect parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F)
let param_start = i;
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x3F {
i += 1;
}
// Collect intermediate bytes
while i < len && (chars[i] as u32) >= 0x20 && (chars[i] as u32) <= 0x2F {
i += 1;
}
// Final byte (0x40-0x7E)
if i >= len || (chars[i] as u32) < 0x40 || (chars[i] as u32) > 0x7E {
// Malformed — consume what we've seen and strip
return (i.saturating_sub(start).max(2), None);
}
let final_byte = chars[i];
let consumed = i + 1 - start;
// Only preserve SGR sequences (final byte 'm')
if final_byte == 'm' {
let param_str: String = chars[param_start..i].iter().collect();
if is_safe_sgr(&param_str) {
let full_seq: String = chars[start..start + consumed].iter().collect();
return (consumed, Some(full_seq));
}
}
// Anything else (cursor movement A-H, erase J/K, etc.) is stripped
(consumed, None)
}
/// Check if all SGR parameters in a sequence are in the safe subset.
///
/// Safe: 0 (reset), 1 (bold), 3 (italic), 4 (underline), 22 (normal intensity),
/// 23 (not italic), 24 (not underline), 39 (default fg), 49 (default bg),
/// 30-37 (standard fg), 40-47 (standard bg), 90-97 (bright fg), 100-107 (bright bg).
fn is_safe_sgr(params: &str) -> bool {
if params.is_empty() {
return true; // ESC[m is reset
}
for param in params.split(';') {
let param = param.trim();
if param.is_empty() {
continue; // treat empty as 0
}
let Ok(n) = param.parse::<u32>() else {
return false;
};
if !is_safe_sgr_code(n) {
return false;
}
}
true
}
fn is_safe_sgr_code(n: u32) -> bool {
matches!(
n,
0 // reset
| 1 // bold
| 3 // italic
| 4 // underline
| 22 // normal intensity (turn off bold)
| 23 // not italic
| 24 // not underline
| 39 // default foreground
| 49 // default background
| 30..=37 // standard foreground colors
| 40..=47 // standard background colors
| 90..=97 // bright foreground colors
| 100..=107 // bright background colors
)
}
// ---------------------------------------------------------------------------
// OSC parser
// ---------------------------------------------------------------------------
/// Parse an OSC sequence starting at `chars[start]` (ESC ]).
///
/// Returns `(chars_consumed, link_text, link_url)`.
/// For OSC 8 hyperlinks: `ESC ] 8 ; params ; url ST text ESC ] 8 ; ; ST`
/// For other OSC: consumed without extracting link data.
fn parse_osc(chars: &[char], start: usize) -> (usize, Option<String>, Option<String>) {
debug_assert!(chars[start] == '\x1B');
debug_assert!(start + 1 < chars.len() && chars[start + 1] == ']');
let len = chars.len();
let i = start + 2; // skip ESC ]
// Find ST (String Terminator): ESC \ or BEL (0x07)
let osc_end = find_st(chars, i);
// Check if this is OSC 8 (hyperlink)
if i < len && chars[i] == '8' && i + 1 < len && chars[i + 1] == ';' {
// OSC 8 hyperlink: ESC ] 8 ; params ; url ST ... ESC ] 8 ; ; ST
let osc_content: String = chars[i..osc_end.0].iter().collect();
let first_consumed = osc_end.1;
// Extract URL from "8;params;url"
let url = extract_osc8_url(&osc_content);
// Now find the link text (between first ST and second OSC 8)
let after_first_st = start + 2 + first_consumed;
let mut text = String::new();
let mut j = after_first_st;
// Collect text until we hit the closing OSC 8 or end of input
while j < len {
if j + 1 < len && chars[j] == '\x1B' && chars[j + 1] == ']' {
// Found another OSC — this should be the closing OSC 8
let close_end = find_st(chars, j + 2);
return (
j + close_end.1 - start + 2,
Some(text),
url.map(String::from),
);
}
text.push(chars[j]);
j += 1;
}
// Reached end without closing OSC 8
return (j - start, Some(text), url.map(String::from));
}
// Non-OSC-8: just consume and strip
(osc_end.1 + (start + 2 - start), None, None)
}
/// Find the String Terminator (ST) for an OSC sequence.
/// ST is either ESC \ (two chars) or BEL (0x07).
/// Returns (content_end_index, total_consumed_from_content_start).
fn find_st(chars: &[char], from: usize) -> (usize, usize) {
let len = chars.len();
let mut i = from;
while i < len {
if chars[i] == '\x07' {
return (i, i - from + 1);
}
if i + 1 < len && chars[i] == '\x1B' && chars[i + 1] == '\\' {
return (i, i - from + 2);
}
i += 1;
}
// Unterminated — consume everything
(len, len - from)
}
/// Extract URL from OSC 8 content "8;params;url".
fn extract_osc8_url(content: &str) -> Option<&str> {
// Format: "8;params;url"
let rest = content.strip_prefix("8;")?;
// Skip params (up to next ;)
let url_start = rest.find(';')? + 1;
let url = &rest[url_start..];
if url.is_empty() { None } else { Some(url) }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- CSI / cursor movement ---
#[test]
fn test_strips_cursor_movement() {
// CSI 5A = cursor up 5
let input = "before\x1B[5Aafter";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "beforeafter");
}
#[test]
fn test_strips_cursor_movement_all_directions() {
for dir in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] {
let input = format!("x\x1B[3{dir}y");
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "xy", "failed for direction {dir}");
}
}
#[test]
fn test_strips_erase_sequences() {
// CSI 2J = erase display
let input = "before\x1B[2Jafter";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "beforeafter");
}
// --- SGR preservation ---
#[test]
fn test_preserves_bold_italic_underline_reset() {
let input = "\x1B[1mbold\x1B[0m \x1B[3mitalic\x1B[0m \x1B[4munderline\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_preserves_standard_colors() {
// Red foreground, green background
let input = "\x1B[31mred\x1B[42m on green\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_preserves_bright_colors() {
let input = "\x1B[91mbright red\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_preserves_combined_safe_sgr() {
// Bold + red foreground in one sequence
let input = "\x1B[1;31mbold red\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, input);
}
#[test]
fn test_strips_unsafe_sgr() {
// SGR 8 = hidden text (not in safe list)
let input = "\x1B[8mhidden\x1B[0m";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
// SGR 8 stripped, SGR 0 preserved
assert_eq!(result, "hidden\x1B[0m");
}
// --- C1 control codes ---
#[test]
fn test_strips_c1_control_codes() {
// U+008D = Reverse Index, U+009B = CSI (8-bit)
let input = format!("before{}middle{}after", '\u{008D}', '\u{009B}');
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "beforemiddleafter");
}
// --- Bidi overrides ---
#[test]
fn test_strips_bidi_overrides() {
let input = format!(
"normal{}reversed{}end",
'\u{202E}', // RLO
'\u{202C}' // PDF
);
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "normalreversedend");
}
#[test]
fn test_strips_all_bidi_chars() {
let bidi_chars = [
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}',
'\u{2068}', '\u{2069}',
];
for ch in bidi_chars {
let input = format!("a{ch}b");
let result = sanitize_for_terminal(&input, UrlPolicy::Strip);
assert_eq!(result, "ab", "failed for U+{:04X}", ch as u32);
}
}
// --- OSC sequences ---
#[test]
fn test_strips_osc_sequences() {
// OSC 0 (set title): ESC ] 0 ; title BEL
let input = "before\x1B]0;My Title\x07after";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "beforeafter");
}
// --- OSC 8 hyperlinks ---
#[test]
fn test_url_policy_strip() {
// OSC 8 hyperlink: ESC]8;;url ST text ESC]8;; ST
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "click here done");
}
#[test]
fn test_url_policy_footnote() {
let input = "click \x1B]8;;https://example.com\x07here\x1B]8;;\x07 done";
let result = sanitize_for_terminal(input, UrlPolicy::Footnote);
assert!(result.contains("here [1]"));
assert!(result.contains("[1] https://example.com"));
}
// --- Redaction ---
#[test]
fn test_redact_gitlab_token() {
let redactor = RedactPattern::defaults();
let input = "My token is glpat-AbCdEfGhIjKlMnOpQrStUvWx";
let result = redactor.redact(input);
assert_eq!(result, "My token is [REDACTED]");
}
#[test]
fn test_redact_email() {
let redactor = RedactPattern::defaults();
let input = "Contact user@example.com for details";
let result = redactor.redact(input);
assert_eq!(result, "Contact [REDACTED] for details");
}
#[test]
fn test_redact_bearer_token() {
let redactor = RedactPattern::defaults();
let input = "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI";
let result = redactor.redact(input);
assert!(result.contains("[REDACTED]"));
assert!(!result.contains("eyJ"));
}
// --- Edge cases ---
#[test]
fn test_empty_input() {
assert_eq!(sanitize_for_terminal("", UrlPolicy::Strip), "");
}
#[test]
fn test_safe_content_passthrough() {
let input = "Hello, world! This is normal text.\nWith newlines\tand tabs.";
assert_eq!(sanitize_for_terminal(input, UrlPolicy::Strip), input);
}
#[test]
fn test_trailing_esc() {
let input = "text\x1B";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "text");
}
#[test]
fn test_malformed_csi_does_not_eat_text() {
// ESC [ without a valid final byte before next printable
let input = "a\x1B[b";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
// The malformed CSI is consumed but shouldn't eat "b" as text
// ESC[ is start, 'b' is final byte (0x62 is in 0x40-0x7E range)
// So this is CSI with final byte 'b' (cursor back) — gets stripped
assert_eq!(result, "a");
}
#[test]
fn test_utf8_adjacent_to_escapes() {
let input = "\x1B[1m日本語\x1B[0m text";
let result = sanitize_for_terminal(input, UrlPolicy::Strip);
assert_eq!(result, "\x1B[1m日本語\x1B[0m text");
}
#[test]
fn test_fuzz_no_panic() {
// 1000 random-ish byte sequences — must not panic
for seed in 0u16..1000 {
let mut bytes = Vec::new();
for j in 0..50 {
bytes.push(((seed.wrapping_mul(31).wrapping_add(j)) & 0xFF) as u8);
}
// Best-effort UTF-8
let input = String::from_utf8_lossy(&bytes);
let _ = sanitize_for_terminal(&input, UrlPolicy::Strip);
}
}
}

View File

@@ -0,0 +1,11 @@
#![allow(dead_code)]
//! Command palette state.
/// State for the command palette overlay.
#[derive(Debug, Default)]
pub struct CommandPaletteState {
pub query: String,
pub query_focused: bool,
pub selected_index: usize,
}

View File

@@ -0,0 +1,255 @@
#![allow(dead_code)]
//! Dashboard screen state.
//!
//! The dashboard is the home screen — entity counts, per-project sync
//! status, recent activity, and the last sync summary.
// ---------------------------------------------------------------------------
// EntityCounts
// ---------------------------------------------------------------------------
/// Aggregated entity counts from the local database.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EntityCounts {
pub issues_open: u64,
pub issues_total: u64,
pub mrs_open: u64,
pub mrs_total: u64,
pub discussions: u64,
pub notes_total: u64,
/// Percentage of notes that are system-generated (0-100).
pub notes_system_pct: u8,
pub documents: u64,
pub embeddings: u64,
}
// ---------------------------------------------------------------------------
// ProjectSyncInfo
// ---------------------------------------------------------------------------
/// Per-project sync freshness.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSyncInfo {
pub path: String,
pub minutes_since_sync: u64,
}
// ---------------------------------------------------------------------------
// RecentActivityItem
// ---------------------------------------------------------------------------
/// A recently-updated entity for the activity feed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentActivityItem {
/// "issue" or "mr".
pub entity_type: String,
pub iid: u64,
pub title: String,
pub state: String,
pub minutes_ago: u64,
}
// ---------------------------------------------------------------------------
// LastSyncInfo
// ---------------------------------------------------------------------------
/// Summary of the most recent sync run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LastSyncInfo {
pub status: String,
/// Milliseconds epoch UTC.
pub finished_at: Option<i64>,
pub command: String,
pub error: Option<String>,
}
// ---------------------------------------------------------------------------
// DashboardData
// ---------------------------------------------------------------------------
/// Data returned by the `fetch_dashboard` action.
///
/// Pure data transfer — no rendering or display logic.
#[derive(Debug, Clone, Default)]
pub struct DashboardData {
pub counts: EntityCounts,
pub projects: Vec<ProjectSyncInfo>,
pub recent: Vec<RecentActivityItem>,
pub last_sync: Option<LastSyncInfo>,
}
// ---------------------------------------------------------------------------
// DashboardState
// ---------------------------------------------------------------------------
/// State for the dashboard summary screen.
#[derive(Debug, Default)]
pub struct DashboardState {
pub counts: EntityCounts,
pub projects: Vec<ProjectSyncInfo>,
pub recent: Vec<RecentActivityItem>,
pub last_sync: Option<LastSyncInfo>,
/// Scroll offset for the recent activity list.
pub scroll_offset: usize,
}
impl DashboardState {
/// Apply fresh data from a `fetch_dashboard` result.
///
/// Preserves scroll offset (clamped to new data bounds).
pub fn update(&mut self, data: DashboardData) {
self.counts = data.counts;
self.projects = data.projects;
self.last_sync = data.last_sync;
self.recent = data.recent;
// Clamp scroll offset if the list shrunk.
if !self.recent.is_empty() {
self.scroll_offset = self.scroll_offset.min(self.recent.len() - 1);
} else {
self.scroll_offset = 0;
}
}
/// Scroll the recent activity list down by one.
pub fn scroll_down(&mut self) {
if !self.recent.is_empty() {
self.scroll_offset = (self.scroll_offset + 1).min(self.recent.len() - 1);
}
}
/// Scroll the recent activity list up by one.
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dashboard_state_default() {
let state = DashboardState::default();
assert_eq!(state.counts.issues_total, 0);
assert_eq!(state.scroll_offset, 0);
assert!(state.recent.is_empty());
}
#[test]
fn test_dashboard_state_update_applies_data() {
let mut state = DashboardState::default();
let data = DashboardData {
counts: EntityCounts {
issues_open: 3,
issues_total: 5,
..Default::default()
},
projects: vec![ProjectSyncInfo {
path: "group/project".into(),
minutes_since_sync: 42,
}],
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 1,
title: "Fix bug".into(),
state: "opened".into(),
minutes_ago: 10,
}],
last_sync: None,
};
state.update(data);
assert_eq!(state.counts.issues_open, 3);
assert_eq!(state.counts.issues_total, 5);
assert_eq!(state.projects.len(), 1);
assert_eq!(state.recent.len(), 1);
}
#[test]
fn test_dashboard_state_update_clamps_scroll() {
let mut state = DashboardState {
scroll_offset: 10,
..Default::default()
};
let data = DashboardData {
recent: vec![RecentActivityItem {
entity_type: "issue".into(),
iid: 1,
title: "Only item".into(),
state: "opened".into(),
minutes_ago: 5,
}],
..Default::default()
};
state.update(data);
assert_eq!(state.scroll_offset, 0); // Clamped to len-1 = 0
}
#[test]
fn test_dashboard_state_update_empty_resets_scroll() {
let mut state = DashboardState {
scroll_offset: 5,
..Default::default()
};
state.update(DashboardData::default());
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_down_and_up() {
let mut state = DashboardState::default();
state.recent = (0..5)
.map(|i| RecentActivityItem {
entity_type: "issue".into(),
iid: i,
title: format!("Item {i}"),
state: "opened".into(),
minutes_ago: i,
})
.collect();
assert_eq!(state.scroll_offset, 0);
state.scroll_down();
assert_eq!(state.scroll_offset, 1);
state.scroll_down();
assert_eq!(state.scroll_offset, 2);
state.scroll_up();
assert_eq!(state.scroll_offset, 1);
state.scroll_up();
assert_eq!(state.scroll_offset, 0);
state.scroll_up(); // Can't go below 0
assert_eq!(state.scroll_offset, 0);
}
#[test]
fn test_scroll_down_stops_at_end() {
let mut state = DashboardState::default();
state.recent = vec![RecentActivityItem {
entity_type: "mr".into(),
iid: 1,
title: "Only".into(),
state: "merged".into(),
minutes_ago: 0,
}];
state.scroll_down();
assert_eq!(state.scroll_offset, 0); // Can't scroll past single item
}
#[test]
fn test_scroll_on_empty_is_noop() {
let mut state = DashboardState::default();
state.scroll_down();
assert_eq!(state.scroll_offset, 0);
state.scroll_up();
assert_eq!(state.scroll_offset, 0);
}
}

View File

@@ -0,0 +1,284 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail screen
//! Issue detail screen state.
//!
//! Holds metadata, discussions, cross-references, and UI state for
//! viewing a single issue. Supports progressive hydration: metadata
//! loads first, discussions load async in a second phase.
use crate::message::EntityKey;
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
// ---------------------------------------------------------------------------
// IssueMetadata
// ---------------------------------------------------------------------------
/// Full metadata for a single issue, fetched from the local DB.
#[derive(Debug, Clone)]
pub struct IssueMetadata {
/// Issue IID (project-scoped).
pub iid: i64,
/// Project path (e.g., "group/project").
pub project_path: String,
/// Issue title.
pub title: String,
/// Issue description (markdown).
pub description: String,
/// Current state: "opened" or "closed".
pub state: String,
/// Author username.
pub author: String,
/// Assigned usernames.
pub assignees: Vec<String>,
/// Label names.
pub labels: Vec<String>,
/// Milestone title (if set).
pub milestone: Option<String>,
/// Due date (if set, "YYYY-MM-DD").
pub due_date: Option<String>,
/// Created timestamp (ms epoch).
pub created_at: i64,
/// Updated timestamp (ms epoch).
pub updated_at: i64,
/// GitLab web URL for "open in browser".
pub web_url: String,
/// Discussion count (for display before discussions load).
pub discussion_count: usize,
}
// ---------------------------------------------------------------------------
// IssueDetailData
// ---------------------------------------------------------------------------
/// Bundle returned by the metadata fetch action.
///
/// Metadata + cross-refs load in Phase 1 (fast). Discussions load
/// separately in Phase 2.
#[derive(Debug, Clone)]
pub struct IssueDetailData {
pub metadata: IssueMetadata,
pub cross_refs: Vec<CrossRef>,
}
// ---------------------------------------------------------------------------
// DetailSection
// ---------------------------------------------------------------------------
/// Which section of the detail view has keyboard focus.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DetailSection {
/// Description area (scrollable text).
#[default]
Description,
/// Discussion tree.
Discussions,
/// Cross-references list.
CrossRefs,
}
impl DetailSection {
/// Cycle to the next section.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Description => Self::Discussions,
Self::Discussions => Self::CrossRefs,
Self::CrossRefs => Self::Description,
}
}
/// Cycle to the previous section.
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::Description => Self::CrossRefs,
Self::Discussions => Self::Description,
Self::CrossRefs => Self::Discussions,
}
}
}
// ---------------------------------------------------------------------------
// IssueDetailState
// ---------------------------------------------------------------------------
/// State for the issue detail screen.
#[derive(Debug, Default)]
pub struct IssueDetailState {
/// Entity key for the currently displayed issue.
pub current_key: Option<EntityKey>,
/// Issue metadata (Phase 1 load).
pub metadata: Option<IssueMetadata>,
/// Discussion nodes (Phase 2 async load).
pub discussions: Vec<DiscussionNode>,
/// Whether discussions have finished loading.
pub discussions_loaded: bool,
/// Cross-references (loaded with metadata in Phase 1).
pub cross_refs: Vec<CrossRef>,
/// Discussion tree UI state (expand/collapse, selection).
pub tree_state: DiscussionTreeState,
/// Cross-reference list UI state.
pub cross_ref_state: CrossRefState,
/// Description scroll offset.
pub description_scroll: usize,
/// Active section for keyboard focus.
pub active_section: DetailSection,
}
impl IssueDetailState {
/// Reset state for a new issue.
pub fn load_new(&mut self, key: EntityKey) {
self.current_key = Some(key);
self.metadata = None;
self.discussions.clear();
self.discussions_loaded = false;
self.cross_refs.clear();
self.tree_state = DiscussionTreeState::default();
self.cross_ref_state = CrossRefState::default();
self.description_scroll = 0;
self.active_section = DetailSection::Description;
}
/// Apply Phase 1 data (metadata + cross-refs).
pub fn apply_metadata(&mut self, data: IssueDetailData) {
self.metadata = Some(data.metadata);
self.cross_refs = data.cross_refs;
}
/// Apply Phase 2 data (discussions).
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
self.discussions = discussions;
self.discussions_loaded = true;
}
/// Whether we have metadata loaded for the current key.
#[must_use]
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
/// Cycle to the next section.
pub fn next_section(&mut self) {
self.active_section = self.active_section.next();
}
/// Cycle to the previous section.
pub fn prev_section(&mut self) {
self.active_section = self.active_section.prev();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::common::cross_ref::CrossRefKind;
#[test]
fn test_issue_detail_state_default() {
let state = IssueDetailState::default();
assert!(state.current_key.is_none());
assert!(state.metadata.is_none());
assert!(state.discussions.is_empty());
assert!(!state.discussions_loaded);
assert!(state.cross_refs.is_empty());
assert_eq!(state.active_section, DetailSection::Description);
}
#[test]
fn test_load_new_resets_state() {
let mut state = IssueDetailState {
discussions_loaded: true,
description_scroll: 10,
active_section: DetailSection::CrossRefs,
..IssueDetailState::default()
};
state.load_new(EntityKey::issue(1, 42));
assert_eq!(state.current_key, Some(EntityKey::issue(1, 42)));
assert!(state.metadata.is_none());
assert!(!state.discussions_loaded);
assert_eq!(state.description_scroll, 0);
assert_eq!(state.active_section, DetailSection::Description);
}
#[test]
fn test_apply_metadata() {
let mut state = IssueDetailState::default();
state.load_new(EntityKey::issue(1, 42));
let data = IssueDetailData {
metadata: IssueMetadata {
iid: 42,
project_path: "group/proj".into(),
title: "Fix auth".into(),
description: "Description here".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into()],
milestone: Some("v1.0".into()),
due_date: None,
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/proj/-/issues/42".into(),
discussion_count: 3,
},
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 10),
label: "Fix auth MR".into(),
navigable: true,
}],
};
state.apply_metadata(data);
assert!(state.has_metadata());
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
assert_eq!(state.cross_refs.len(), 1);
}
#[test]
fn test_apply_discussions() {
let mut state = IssueDetailState::default();
assert!(!state.discussions_loaded);
let discussions = vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![],
resolvable: false,
resolved: false,
}];
state.apply_discussions(discussions);
assert!(state.discussions_loaded);
assert_eq!(state.discussions.len(), 1);
}
#[test]
fn test_detail_section_cycling() {
let section = DetailSection::Description;
assert_eq!(section.next(), DetailSection::Discussions);
assert_eq!(section.next().next(), DetailSection::CrossRefs);
assert_eq!(section.next().next().next(), DetailSection::Description);
assert_eq!(section.prev(), DetailSection::CrossRefs);
assert_eq!(section.prev().prev(), DetailSection::Discussions);
}
#[test]
fn test_section_next_prev_round_trip() {
let mut state = IssueDetailState::default();
assert_eq!(state.active_section, DetailSection::Description);
state.next_section();
assert_eq!(state.active_section, DetailSection::Discussions);
state.prev_section();
assert_eq!(state.active_section, DetailSection::Description);
}
}

View File

@@ -0,0 +1,376 @@
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/issue_list
//! Issue list screen state.
//!
//! Uses keyset pagination with a snapshot fence for stable ordering
//! under concurrent sync writes. Filter changes reset the pagination
//! cursor and snapshot fence.
use std::hash::{Hash, Hasher};
// ---------------------------------------------------------------------------
// Cursor (keyset pagination boundary)
// ---------------------------------------------------------------------------
/// Keyset pagination cursor — (updated_at, iid) boundary.
///
/// The next page query uses `WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid)`
/// to avoid OFFSET instability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueCursor {
pub updated_at: i64,
pub iid: i64,
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
/// Structured filter for issue list queries.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueFilter {
pub state: Option<String>,
pub author: Option<String>,
pub assignee: Option<String>,
pub label: Option<String>,
pub milestone: Option<String>,
pub status: Option<String>,
pub free_text: Option<String>,
pub project_id: Option<i64>,
}
impl IssueFilter {
/// Compute a hash for change detection.
pub fn hash_value(&self) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.state.hash(&mut hasher);
self.author.hash(&mut hasher);
self.assignee.hash(&mut hasher);
self.label.hash(&mut hasher);
self.milestone.hash(&mut hasher);
self.status.hash(&mut hasher);
self.free_text.hash(&mut hasher);
self.project_id.hash(&mut hasher);
hasher.finish()
}
/// Whether any filter is active.
pub fn is_active(&self) -> bool {
self.state.is_some()
|| self.author.is_some()
|| self.assignee.is_some()
|| self.label.is_some()
|| self.milestone.is_some()
|| self.status.is_some()
|| self.free_text.is_some()
|| self.project_id.is_some()
}
}
// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------
/// A single row in the issue list.
#[derive(Debug, Clone)]
pub struct IssueListRow {
pub project_path: String,
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub labels: Vec<String>,
pub updated_at: i64,
}
// ---------------------------------------------------------------------------
// Page result
// ---------------------------------------------------------------------------
/// Result from a paginated issue list query.
#[derive(Debug, Clone)]
pub struct IssueListPage {
pub rows: Vec<IssueListRow>,
pub next_cursor: Option<IssueCursor>,
pub total_count: u64,
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
/// Fields available for sorting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortField {
#[default]
UpdatedAt,
Iid,
Title,
State,
Author,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
#[default]
Desc,
Asc,
}
// ---------------------------------------------------------------------------
// IssueListState
// ---------------------------------------------------------------------------
/// State for the issue list screen.
#[derive(Debug, Default)]
pub struct IssueListState {
/// Current page of issue rows.
pub rows: Vec<IssueListRow>,
/// Total count of matching issues.
pub total_count: u64,
/// Selected row index (within current window).
pub selected_index: usize,
/// Scroll offset for the entity table.
pub scroll_offset: usize,
/// Cursor for the next page.
pub next_cursor: Option<IssueCursor>,
/// Whether a prefetch is in flight.
pub prefetch_in_flight: bool,
/// Current filter.
pub filter: IssueFilter,
/// Raw filter input text.
pub filter_input: String,
/// Whether the filter bar has focus.
pub filter_focused: bool,
/// Sort field.
pub sort_field: SortField,
/// Sort direction.
pub sort_order: SortOrder,
/// Snapshot fence: max updated_at from initial load.
pub snapshot_fence: Option<i64>,
/// Hash of the current filter for change detection.
pub filter_hash: u64,
/// Whether Quick Peek is visible.
pub peek_visible: bool,
}
impl IssueListState {
/// Reset pagination state (called when filter changes or on refresh).
pub fn reset_pagination(&mut self) {
self.rows.clear();
self.next_cursor = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.snapshot_fence = None;
self.total_count = 0;
self.prefetch_in_flight = false;
}
/// Apply a new page of results.
pub fn apply_page(&mut self, page: IssueListPage) {
// Set snapshot fence on first page load.
if self.snapshot_fence.is_none() {
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
}
self.rows.extend(page.rows);
self.next_cursor = page.next_cursor;
self.total_count = page.total_count;
self.prefetch_in_flight = false;
}
/// Check if filter changed and reset if needed.
pub fn check_filter_change(&mut self) -> bool {
let new_hash = self.filter.hash_value();
if new_hash != self.filter_hash {
self.filter_hash = new_hash;
self.reset_pagination();
true
} else {
false
}
}
/// Whether the user has scrolled near the end of current data (80% threshold).
pub fn should_prefetch(&self) -> bool {
if self.prefetch_in_flight || self.next_cursor.is_none() {
return false;
}
if self.rows.is_empty() {
return false;
}
let threshold = (self.rows.len() * 4) / 5; // 80%
self.selected_index >= threshold
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(count: usize, has_next: bool) -> IssueListPage {
let rows: Vec<IssueListRow> = (0..count)
.map(|i| IssueListRow {
project_path: "group/project".into(),
iid: (count - i) as i64,
title: format!("Issue {}", count - i),
state: "opened".into(),
author: "taylor".into(),
labels: vec![],
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
})
.collect();
let next_cursor = if has_next {
rows.last().map(|r| IssueCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
IssueListPage {
rows,
next_cursor,
total_count: if has_next {
(count * 2) as u64
} else {
count as u64
},
}
}
#[test]
fn test_apply_page_sets_snapshot_fence() {
let mut state = IssueListState::default();
let page = sample_page(5, false);
state.apply_page(page);
assert_eq!(state.rows.len(), 5);
assert!(state.snapshot_fence.is_some());
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
}
#[test]
fn test_apply_page_appends() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
assert_eq!(state.rows.len(), 5);
state.apply_page(sample_page(3, false));
assert_eq!(state.rows.len(), 8);
}
#[test]
fn test_reset_pagination_clears_state() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
state.selected_index = 3;
state.reset_pagination();
assert!(state.rows.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.next_cursor.is_none());
assert!(state.snapshot_fence.is_none());
}
#[test]
fn test_check_filter_change_detects_change() {
let mut state = IssueListState::default();
state.filter_hash = state.filter.hash_value();
state.filter.state = Some("opened".into());
assert!(state.check_filter_change());
}
#[test]
fn test_check_filter_change_no_change() {
let mut state = IssueListState::default();
state.filter_hash = state.filter.hash_value();
assert!(!state.check_filter_change());
}
#[test]
fn test_should_prefetch() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 4; // 40% — no prefetch
assert!(!state.should_prefetch());
state.selected_index = 8; // 80% — prefetch
assert!(state.should_prefetch());
}
#[test]
fn test_should_prefetch_no_next_page() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, false));
state.selected_index = 9;
assert!(!state.should_prefetch());
}
#[test]
fn test_should_prefetch_already_in_flight() {
let mut state = IssueListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 9;
state.prefetch_in_flight = true;
assert!(!state.should_prefetch());
}
#[test]
fn test_issue_filter_is_active() {
let empty = IssueFilter::default();
assert!(!empty.is_active());
let active = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
assert!(active.is_active());
}
#[test]
fn test_issue_filter_hash_deterministic() {
let f1 = IssueFilter {
state: Some("opened".into()),
author: Some("taylor".into()),
..Default::default()
};
let f2 = f1.clone();
assert_eq!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_issue_filter_hash_differs() {
let f1 = IssueFilter {
state: Some("opened".into()),
..Default::default()
};
let f2 = IssueFilter {
state: Some("closed".into()),
..Default::default()
};
assert_ne!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_snapshot_fence_not_overwritten_on_second_page() {
let mut state = IssueListState::default();
state.apply_page(sample_page(5, true));
let fence = state.snapshot_fence;
state.apply_page(sample_page(3, false));
assert_eq!(
state.snapshot_fence, fence,
"Fence should not change on second page"
);
}
}

View File

@@ -0,0 +1,344 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Top-level state composition for the TUI.
//!
//! Each screen has its own state struct. State is preserved when
//! navigating away — screens are never cleared on pop.
//!
//! [`LoadState`] enables stale-while-revalidate: screens show the last
//! available data during a refresh, with a spinner indicating the load.
//!
//! [`ScreenIntent`] is the pure return type from state handlers — they
//! never spawn async tasks directly. The intent is interpreted by
//! [`LoreApp`](crate::app::LoreApp) which dispatches through the
//! [`TaskSupervisor`](crate::task_supervisor::TaskSupervisor).
pub mod command_palette;
pub mod dashboard;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
pub mod search;
pub mod sync;
pub mod timeline;
pub mod who;
use std::collections::{HashMap, HashSet};
use crate::message::Screen;
// Re-export screen states for convenience.
pub use command_palette::CommandPaletteState;
pub use dashboard::DashboardState;
pub use issue_detail::IssueDetailState;
pub use issue_list::IssueListState;
pub use mr_detail::MrDetailState;
pub use mr_list::MrListState;
pub use search::SearchState;
pub use sync::SyncState;
pub use timeline::TimelineState;
pub use who::WhoState;
// ---------------------------------------------------------------------------
// LoadState
// ---------------------------------------------------------------------------
/// Loading state for a screen's data.
///
/// Enables stale-while-revalidate: screens render their last data while
/// showing a spinner when `Refreshing`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LoadState {
/// No load in progress, data is current (or screen was never loaded).
#[default]
Idle,
/// First load — no data to show yet, display a full-screen spinner.
LoadingInitial,
/// Background refresh — show existing data with a spinner indicator.
Refreshing,
/// Load failed — display the error alongside any stale data.
Error(String),
}
impl LoadState {
/// Whether data is currently being loaded.
#[must_use]
pub fn is_loading(&self) -> bool {
matches!(self, Self::LoadingInitial | Self::Refreshing)
}
}
// ---------------------------------------------------------------------------
// ScreenLoadStateMap
// ---------------------------------------------------------------------------
/// Tracks per-screen load state.
///
/// Returns [`LoadState::Idle`] for screens that haven't been tracked.
/// Automatically removes entries set to `Idle` to prevent unbounded growth.
#[derive(Debug, Default)]
pub struct ScreenLoadStateMap {
map: HashMap<Screen, LoadState>,
/// Screens that have had a load state set at least once.
visited: HashSet<Screen>,
}
impl ScreenLoadStateMap {
/// Get the load state for a screen (defaults to `Idle`).
#[must_use]
pub fn get(&self, screen: &Screen) -> &LoadState {
static IDLE: LoadState = LoadState::Idle;
self.map.get(screen).unwrap_or(&IDLE)
}
/// Set the load state for a screen.
///
/// Setting to `Idle` removes the entry to prevent map growth.
pub fn set(&mut self, screen: Screen, state: LoadState) {
self.visited.insert(screen.clone());
if state == LoadState::Idle {
self.map.remove(&screen);
} else {
self.map.insert(screen, state);
}
}
/// Whether this screen has ever had a load initiated.
#[must_use]
pub fn was_visited(&self, screen: &Screen) -> bool {
self.visited.contains(screen)
}
/// Whether any screen is currently loading.
#[must_use]
pub fn any_loading(&self) -> bool {
self.map.values().any(LoadState::is_loading)
}
}
// ---------------------------------------------------------------------------
// ScreenIntent
// ---------------------------------------------------------------------------
/// Pure return type from screen state handlers.
///
/// State handlers must never spawn async work directly — they return
/// an intent that [`LoreApp`] interprets and dispatches through the
/// [`TaskSupervisor`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenIntent {
/// No action needed.
None,
/// Navigate to a new screen.
Navigate(Screen),
/// Screen data needs re-querying (e.g., filter changed).
RequeryNeeded(Screen),
}
// ---------------------------------------------------------------------------
// ScopeContext
// ---------------------------------------------------------------------------
/// Global scope filters applied across all screens.
///
/// When a project filter is active, all data queries scope to that
/// project. The TUI shows the active scope in the status bar.
#[derive(Debug, Default)]
pub struct ScopeContext {
/// Active project filter (project_id).
pub project_id: Option<i64>,
/// Human-readable project name for display.
pub project_name: Option<String>,
}
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
/// Top-level state composition for the TUI.
///
/// Each field holds one screen's state. State is preserved when
/// navigating away and restored on return.
#[derive(Debug, Default)]
pub struct AppState {
// Per-screen states.
pub dashboard: DashboardState,
pub issue_list: IssueListState,
pub issue_detail: IssueDetailState,
pub mr_list: MrListState,
pub mr_detail: MrDetailState,
pub search: SearchState,
pub timeline: TimelineState,
pub who: WhoState,
pub sync: SyncState,
pub command_palette: CommandPaletteState,
// Cross-cutting state.
pub global_scope: ScopeContext,
pub load_state: ScreenLoadStateMap,
pub error_toast: Option<String>,
pub show_help: bool,
pub terminal_size: (u16, u16),
}
impl AppState {
/// Set a screen's load state.
pub fn set_loading(&mut self, screen: Screen, state: LoadState) {
self.load_state.set(screen, state);
}
/// Set the global error toast.
pub fn set_error(&mut self, msg: String) {
self.error_toast = Some(msg);
}
/// Clear the global error toast.
pub fn clear_error(&mut self) {
self.error_toast = None;
}
/// Whether any text input is currently focused.
#[must_use]
pub fn has_text_focus(&self) -> bool {
self.issue_list.filter_focused
|| self.mr_list.filter_focused
|| self.search.query_focused
|| self.command_palette.query_focused
}
/// Remove focus from all text inputs.
pub fn blur_text_focus(&mut self) {
self.issue_list.filter_focused = false;
self.mr_list.filter_focused = false;
self.search.query_focused = false;
self.command_palette.query_focused = false;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_state_default_idle() {
let map = ScreenLoadStateMap::default();
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
}
#[test]
fn test_load_state_set_and_get() {
let mut map = ScreenLoadStateMap::default();
map.set(Screen::Dashboard, LoadState::LoadingInitial);
assert_eq!(*map.get(&Screen::Dashboard), LoadState::LoadingInitial);
assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle);
}
#[test]
fn test_load_state_set_idle_removes_entry() {
let mut map = ScreenLoadStateMap::default();
map.set(Screen::Dashboard, LoadState::Refreshing);
assert_eq!(map.map.len(), 1);
map.set(Screen::Dashboard, LoadState::Idle);
assert_eq!(map.map.len(), 0);
assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle);
}
#[test]
fn test_any_loading() {
let mut map = ScreenLoadStateMap::default();
assert!(!map.any_loading());
map.set(Screen::Dashboard, LoadState::LoadingInitial);
assert!(map.any_loading());
map.set(Screen::Dashboard, LoadState::Error("oops".into()));
assert!(!map.any_loading());
}
#[test]
fn test_load_state_is_loading() {
assert!(!LoadState::Idle.is_loading());
assert!(LoadState::LoadingInitial.is_loading());
assert!(LoadState::Refreshing.is_loading());
assert!(!LoadState::Error("x".into()).is_loading());
}
#[test]
fn test_app_state_default_compiles() {
let state = AppState::default();
assert!(!state.show_help);
assert!(state.error_toast.is_none());
assert_eq!(state.terminal_size, (0, 0));
}
#[test]
fn test_app_state_set_error_and_clear() {
let mut state = AppState::default();
state.set_error("db busy".into());
assert_eq!(state.error_toast.as_deref(), Some("db busy"));
state.clear_error();
assert!(state.error_toast.is_none());
}
#[test]
fn test_app_state_has_text_focus() {
let mut state = AppState::default();
assert!(!state.has_text_focus());
state.search.query_focused = true;
assert!(state.has_text_focus());
}
#[test]
fn test_app_state_blur_text_focus() {
let mut state = AppState::default();
state.issue_list.filter_focused = true;
state.mr_list.filter_focused = true;
state.search.query_focused = true;
state.command_palette.query_focused = true;
state.blur_text_focus();
assert!(!state.has_text_focus());
assert!(!state.issue_list.filter_focused);
assert!(!state.mr_list.filter_focused);
assert!(!state.search.query_focused);
assert!(!state.command_palette.query_focused);
}
#[test]
fn test_app_state_set_loading() {
let mut state = AppState::default();
state.set_loading(Screen::IssueList, LoadState::Refreshing);
assert_eq!(
*state.load_state.get(&Screen::IssueList),
LoadState::Refreshing
);
}
#[test]
fn test_screen_intent_variants() {
let none = ScreenIntent::None;
let nav = ScreenIntent::Navigate(Screen::IssueList);
let requery = ScreenIntent::RequeryNeeded(Screen::Search);
assert_eq!(none, ScreenIntent::None);
assert_eq!(nav, ScreenIntent::Navigate(Screen::IssueList));
assert_eq!(requery, ScreenIntent::RequeryNeeded(Screen::Search));
}
#[test]
fn test_scope_context_default() {
let scope = ScopeContext::default();
assert!(scope.project_id.is_none());
assert!(scope.project_name.is_none());
}
}

View File

@@ -0,0 +1,387 @@
#![allow(dead_code)] // Phase 2: consumed by MR Detail screen
//! Merge request detail screen state.
//!
//! Holds MR metadata, file changes, discussions, cross-references,
//! and UI state. Supports progressive hydration identical to
//! Issue Detail: metadata loads first, discussions load async.
use crate::message::EntityKey;
use crate::view::common::cross_ref::{CrossRef, CrossRefState};
use crate::view::common::discussion_tree::{DiscussionNode, DiscussionTreeState};
// ---------------------------------------------------------------------------
// MrMetadata
// ---------------------------------------------------------------------------
/// Full metadata for a single merge request, fetched from the local DB.
#[derive(Debug, Clone)]
pub struct MrMetadata {
/// MR IID (project-scoped).
pub iid: i64,
/// Project path (e.g., "group/project").
pub project_path: String,
/// MR title.
pub title: String,
/// MR description (markdown).
pub description: String,
/// Current state: "opened", "merged", "closed", "locked".
pub state: String,
/// Whether this is a draft/WIP MR.
pub draft: bool,
/// Author username.
pub author: String,
/// Assigned usernames.
pub assignees: Vec<String>,
/// Reviewer usernames.
pub reviewers: Vec<String>,
/// Label names.
pub labels: Vec<String>,
/// Source branch name.
pub source_branch: String,
/// Target branch name.
pub target_branch: String,
/// Detailed merge status (e.g., "mergeable", "checking").
pub merge_status: String,
/// Created timestamp (ms epoch).
pub created_at: i64,
/// Updated timestamp (ms epoch).
pub updated_at: i64,
/// Merged timestamp (ms epoch), if merged.
pub merged_at: Option<i64>,
/// GitLab web URL.
pub web_url: String,
/// Discussion count (for display before discussions load).
pub discussion_count: usize,
/// File change count.
pub file_change_count: usize,
}
// ---------------------------------------------------------------------------
// FileChange
// ---------------------------------------------------------------------------
/// A file changed in the merge request.
#[derive(Debug, Clone)]
pub struct FileChange {
/// Previous file path (if renamed).
pub old_path: Option<String>,
/// New/current file path.
pub new_path: String,
/// Type of change.
pub change_type: FileChangeType,
}
/// The type of file change in an MR.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChangeType {
Added,
Modified,
Deleted,
Renamed,
}
impl FileChangeType {
/// Short icon for display.
#[must_use]
pub const fn icon(&self) -> &str {
match self {
Self::Added => "+",
Self::Modified => "~",
Self::Deleted => "-",
Self::Renamed => "R",
}
}
/// Parse from DB string.
#[must_use]
pub fn parse_db(s: &str) -> Self {
match s {
"added" => Self::Added,
"deleted" => Self::Deleted,
"renamed" => Self::Renamed,
_ => Self::Modified,
}
}
}
// ---------------------------------------------------------------------------
// MrDetailData
// ---------------------------------------------------------------------------
/// Bundle returned by the metadata fetch action.
///
/// Metadata + cross-refs + file changes load in Phase 1 (fast).
/// Discussions load separately in Phase 2.
#[derive(Debug, Clone)]
pub struct MrDetailData {
pub metadata: MrMetadata,
pub cross_refs: Vec<CrossRef>,
pub file_changes: Vec<FileChange>,
}
// ---------------------------------------------------------------------------
// MrTab
// ---------------------------------------------------------------------------
/// Active tab in the MR detail view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrTab {
/// Overview: description + cross-refs.
#[default]
Overview,
/// File changes list.
Files,
/// Discussions (general + diff).
Discussions,
}
impl MrTab {
/// Cycle to the next tab.
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Overview => Self::Files,
Self::Files => Self::Discussions,
Self::Discussions => Self::Overview,
}
}
/// Cycle to the previous tab.
#[must_use]
pub fn prev(self) -> Self {
match self {
Self::Overview => Self::Discussions,
Self::Files => Self::Overview,
Self::Discussions => Self::Files,
}
}
/// Human-readable label.
#[must_use]
pub const fn label(&self) -> &str {
match self {
Self::Overview => "Overview",
Self::Files => "Files",
Self::Discussions => "Discussions",
}
}
}
// ---------------------------------------------------------------------------
// MrDetailState
// ---------------------------------------------------------------------------
/// State for the MR detail screen.
#[derive(Debug, Default)]
pub struct MrDetailState {
/// Entity key for the currently displayed MR.
pub current_key: Option<EntityKey>,
/// MR metadata (Phase 1 load).
pub metadata: Option<MrMetadata>,
/// File changes (loaded with metadata in Phase 1).
pub file_changes: Vec<FileChange>,
/// Discussion nodes (Phase 2 async load).
pub discussions: Vec<DiscussionNode>,
/// Whether discussions have finished loading.
pub discussions_loaded: bool,
/// Cross-references (loaded with metadata in Phase 1).
pub cross_refs: Vec<CrossRef>,
/// Discussion tree UI state.
pub tree_state: DiscussionTreeState,
/// Cross-reference list UI state.
pub cross_ref_state: CrossRefState,
/// Description scroll offset.
pub description_scroll: usize,
/// File list selected index.
pub file_selected: usize,
/// File list scroll offset.
pub file_scroll: usize,
/// Active tab.
pub active_tab: MrTab,
}
impl MrDetailState {
/// Reset state for a new MR.
pub fn load_new(&mut self, key: EntityKey) {
self.current_key = Some(key);
self.metadata = None;
self.file_changes.clear();
self.discussions.clear();
self.discussions_loaded = false;
self.cross_refs.clear();
self.tree_state = DiscussionTreeState::default();
self.cross_ref_state = CrossRefState::default();
self.description_scroll = 0;
self.file_selected = 0;
self.file_scroll = 0;
self.active_tab = MrTab::Overview;
}
/// Apply Phase 1 data (metadata + cross-refs + file changes).
pub fn apply_metadata(&mut self, data: MrDetailData) {
self.metadata = Some(data.metadata);
self.cross_refs = data.cross_refs;
self.file_changes = data.file_changes;
}
/// Apply Phase 2 data (discussions).
pub fn apply_discussions(&mut self, discussions: Vec<DiscussionNode>) {
self.discussions = discussions;
self.discussions_loaded = true;
}
/// Whether we have metadata loaded.
#[must_use]
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
/// Switch to the next tab.
pub fn next_tab(&mut self) {
self.active_tab = self.active_tab.next();
}
/// Switch to the previous tab.
pub fn prev_tab(&mut self) {
self.active_tab = self.active_tab.prev();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::common::cross_ref::CrossRefKind;
#[test]
fn test_mr_detail_state_default() {
let state = MrDetailState::default();
assert!(state.current_key.is_none());
assert!(state.metadata.is_none());
assert!(state.discussions.is_empty());
assert!(!state.discussions_loaded);
assert!(state.file_changes.is_empty());
assert_eq!(state.active_tab, MrTab::Overview);
}
#[test]
fn test_load_new_resets_state() {
let mut state = MrDetailState {
discussions_loaded: true,
description_scroll: 10,
active_tab: MrTab::Files,
..MrDetailState::default()
};
state.load_new(EntityKey::mr(1, 42));
assert_eq!(state.current_key, Some(EntityKey::mr(1, 42)));
assert!(state.metadata.is_none());
assert!(!state.discussions_loaded);
assert_eq!(state.description_scroll, 0);
assert_eq!(state.active_tab, MrTab::Overview);
}
#[test]
fn test_apply_metadata() {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 42));
let data = MrDetailData {
metadata: MrMetadata {
iid: 42,
project_path: "group/proj".into(),
title: "Fix auth".into(),
description: "MR description".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec!["backend".into()],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/proj/-/merge_requests/42".into(),
discussion_count: 2,
file_change_count: 3,
},
cross_refs: vec![CrossRef {
kind: CrossRefKind::RelatedIssue,
entity_key: EntityKey::issue(1, 10),
label: "Related issue".into(),
navigable: true,
}],
file_changes: vec![FileChange {
old_path: None,
new_path: "src/auth.rs".into(),
change_type: FileChangeType::Modified,
}],
};
state.apply_metadata(data);
assert!(state.has_metadata());
assert_eq!(state.metadata.as_ref().unwrap().iid, 42);
assert_eq!(state.cross_refs.len(), 1);
assert_eq!(state.file_changes.len(), 1);
}
#[test]
fn test_tab_cycling() {
let tab = MrTab::Overview;
assert_eq!(tab.next(), MrTab::Files);
assert_eq!(tab.next().next(), MrTab::Discussions);
assert_eq!(tab.next().next().next(), MrTab::Overview);
assert_eq!(tab.prev(), MrTab::Discussions);
assert_eq!(tab.prev().prev(), MrTab::Files);
}
#[test]
fn test_tab_labels() {
assert_eq!(MrTab::Overview.label(), "Overview");
assert_eq!(MrTab::Files.label(), "Files");
assert_eq!(MrTab::Discussions.label(), "Discussions");
}
#[test]
fn test_file_change_type_icon() {
assert_eq!(FileChangeType::Added.icon(), "+");
assert_eq!(FileChangeType::Modified.icon(), "~");
assert_eq!(FileChangeType::Deleted.icon(), "-");
assert_eq!(FileChangeType::Renamed.icon(), "R");
}
#[test]
fn test_file_change_type_parse_db() {
assert_eq!(FileChangeType::parse_db("added"), FileChangeType::Added);
assert_eq!(FileChangeType::parse_db("deleted"), FileChangeType::Deleted);
assert_eq!(FileChangeType::parse_db("renamed"), FileChangeType::Renamed);
assert_eq!(
FileChangeType::parse_db("modified"),
FileChangeType::Modified
);
assert_eq!(
FileChangeType::parse_db("unknown"),
FileChangeType::Modified
);
}
#[test]
fn test_next_prev_tab_on_state() {
let mut state = MrDetailState::default();
assert_eq!(state.active_tab, MrTab::Overview);
state.next_tab();
assert_eq!(state.active_tab, MrTab::Files);
state.prev_tab();
assert_eq!(state.active_tab, MrTab::Overview);
}
}

View File

@@ -0,0 +1,422 @@
#![allow(dead_code)] // Phase 2: consumed by LoreApp and view/mr_list
//! Merge request list screen state.
//!
//! Mirrors the issue list pattern with MR-specific filter fields
//! (draft, reviewer, target/source branch). Uses the same keyset
//! pagination with snapshot fence for stable ordering.
use std::hash::{Hash, Hasher};
// ---------------------------------------------------------------------------
// Cursor (keyset pagination boundary)
// ---------------------------------------------------------------------------
/// Keyset pagination cursor — (updated_at, iid) boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrCursor {
pub updated_at: i64,
pub iid: i64,
}
// ---------------------------------------------------------------------------
// Filter
// ---------------------------------------------------------------------------
/// Structured filter for MR list queries.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MrFilter {
pub state: Option<String>,
pub author: Option<String>,
pub reviewer: Option<String>,
pub target_branch: Option<String>,
pub source_branch: Option<String>,
pub label: Option<String>,
pub draft: Option<bool>,
pub free_text: Option<String>,
pub project_id: Option<i64>,
}
impl MrFilter {
/// Compute a hash for change detection.
pub fn hash_value(&self) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.state.hash(&mut hasher);
self.author.hash(&mut hasher);
self.reviewer.hash(&mut hasher);
self.target_branch.hash(&mut hasher);
self.source_branch.hash(&mut hasher);
self.label.hash(&mut hasher);
self.draft.hash(&mut hasher);
self.free_text.hash(&mut hasher);
self.project_id.hash(&mut hasher);
hasher.finish()
}
/// Whether any filter is active.
pub fn is_active(&self) -> bool {
self.state.is_some()
|| self.author.is_some()
|| self.reviewer.is_some()
|| self.target_branch.is_some()
|| self.source_branch.is_some()
|| self.label.is_some()
|| self.draft.is_some()
|| self.free_text.is_some()
|| self.project_id.is_some()
}
}
// ---------------------------------------------------------------------------
// Row
// ---------------------------------------------------------------------------
/// A single row in the MR list.
#[derive(Debug, Clone)]
pub struct MrListRow {
pub project_path: String,
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub target_branch: String,
pub labels: Vec<String>,
pub updated_at: i64,
pub draft: bool,
}
// ---------------------------------------------------------------------------
// Page result
// ---------------------------------------------------------------------------
/// Result from a paginated MR list query.
#[derive(Debug, Clone)]
pub struct MrListPage {
pub rows: Vec<MrListRow>,
pub next_cursor: Option<MrCursor>,
pub total_count: u64,
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
/// Fields available for sorting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrSortField {
#[default]
UpdatedAt,
Iid,
Title,
State,
Author,
TargetBranch,
}
/// Sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MrSortOrder {
#[default]
Desc,
Asc,
}
// ---------------------------------------------------------------------------
// MrListState
// ---------------------------------------------------------------------------
/// State for the MR list screen.
#[derive(Debug, Default)]
pub struct MrListState {
/// Current page of MR rows.
pub rows: Vec<MrListRow>,
/// Total count of matching MRs.
pub total_count: u64,
/// Selected row index (within current window).
pub selected_index: usize,
/// Scroll offset for the entity table.
pub scroll_offset: usize,
/// Cursor for the next page.
pub next_cursor: Option<MrCursor>,
/// Whether a prefetch is in flight.
pub prefetch_in_flight: bool,
/// Current filter.
pub filter: MrFilter,
/// Raw filter input text.
pub filter_input: String,
/// Whether the filter bar has focus.
pub filter_focused: bool,
/// Sort field.
pub sort_field: MrSortField,
/// Sort direction.
pub sort_order: MrSortOrder,
/// Snapshot fence: max updated_at from initial load.
pub snapshot_fence: Option<i64>,
/// Hash of the current filter for change detection.
pub filter_hash: u64,
/// Whether Quick Peek is visible.
pub peek_visible: bool,
}
impl MrListState {
/// Reset pagination state (called when filter changes or on refresh).
pub fn reset_pagination(&mut self) {
self.rows.clear();
self.next_cursor = None;
self.selected_index = 0;
self.scroll_offset = 0;
self.snapshot_fence = None;
self.total_count = 0;
self.prefetch_in_flight = false;
}
/// Apply a new page of results.
pub fn apply_page(&mut self, page: MrListPage) {
// Set snapshot fence on first page load.
if self.snapshot_fence.is_none() {
self.snapshot_fence = page.rows.first().map(|r| r.updated_at);
}
self.rows.extend(page.rows);
self.next_cursor = page.next_cursor;
self.total_count = page.total_count;
self.prefetch_in_flight = false;
}
/// Check if filter changed and reset if needed.
pub fn check_filter_change(&mut self) -> bool {
let new_hash = self.filter.hash_value();
if new_hash != self.filter_hash {
self.filter_hash = new_hash;
self.reset_pagination();
true
} else {
false
}
}
/// Whether the user has scrolled near the end of current data (80% threshold).
pub fn should_prefetch(&self) -> bool {
if self.prefetch_in_flight || self.next_cursor.is_none() {
return false;
}
if self.rows.is_empty() {
return false;
}
let threshold = (self.rows.len() * 4) / 5; // 80%
self.selected_index >= threshold
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn sample_page(count: usize, has_next: bool) -> MrListPage {
let rows: Vec<MrListRow> = (0..count)
.map(|i| MrListRow {
project_path: "group/project".into(),
iid: (count - i) as i64,
title: format!("MR {}", count - i),
state: "opened".into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 1_700_000_000_000 - (i as i64 * 60_000),
draft: i % 3 == 0,
})
.collect();
let next_cursor = if has_next {
rows.last().map(|r| MrCursor {
updated_at: r.updated_at,
iid: r.iid,
})
} else {
None
};
MrListPage {
rows,
next_cursor,
total_count: if has_next {
(count * 2) as u64
} else {
count as u64
},
}
}
#[test]
fn test_apply_page_sets_snapshot_fence() {
let mut state = MrListState::default();
let page = sample_page(5, false);
state.apply_page(page);
assert_eq!(state.rows.len(), 5);
assert!(state.snapshot_fence.is_some());
assert_eq!(state.snapshot_fence.unwrap(), 1_700_000_000_000);
}
#[test]
fn test_apply_page_appends() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
assert_eq!(state.rows.len(), 5);
state.apply_page(sample_page(3, false));
assert_eq!(state.rows.len(), 8);
}
#[test]
fn test_reset_pagination_clears_state() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
state.selected_index = 3;
state.reset_pagination();
assert!(state.rows.is_empty());
assert_eq!(state.selected_index, 0);
assert!(state.next_cursor.is_none());
assert!(state.snapshot_fence.is_none());
}
#[test]
fn test_check_filter_change_detects_change() {
let mut state = MrListState::default();
state.filter_hash = state.filter.hash_value();
state.filter.state = Some("opened".into());
assert!(state.check_filter_change());
}
#[test]
fn test_check_filter_change_no_change() {
let mut state = MrListState::default();
state.filter_hash = state.filter.hash_value();
assert!(!state.check_filter_change());
}
#[test]
fn test_should_prefetch() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 4; // 40% -- no prefetch
assert!(!state.should_prefetch());
state.selected_index = 8; // 80% -- prefetch
assert!(state.should_prefetch());
}
#[test]
fn test_should_prefetch_no_next_page() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, false));
state.selected_index = 9;
assert!(!state.should_prefetch());
}
#[test]
fn test_should_prefetch_already_in_flight() {
let mut state = MrListState::default();
state.apply_page(sample_page(10, true));
state.selected_index = 9;
state.prefetch_in_flight = true;
assert!(!state.should_prefetch());
}
#[test]
fn test_mr_filter_is_active() {
let empty = MrFilter::default();
assert!(!empty.is_active());
let active = MrFilter {
state: Some("opened".into()),
..Default::default()
};
assert!(active.is_active());
let draft_active = MrFilter {
draft: Some(true),
..Default::default()
};
assert!(draft_active.is_active());
}
#[test]
fn test_mr_filter_hash_deterministic() {
let f1 = MrFilter {
state: Some("opened".into()),
author: Some("taylor".into()),
..Default::default()
};
let f2 = f1.clone();
assert_eq!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_mr_filter_hash_differs() {
let f1 = MrFilter {
state: Some("opened".into()),
..Default::default()
};
let f2 = MrFilter {
state: Some("merged".into()),
..Default::default()
};
assert_ne!(f1.hash_value(), f2.hash_value());
}
#[test]
fn test_snapshot_fence_not_overwritten_on_second_page() {
let mut state = MrListState::default();
state.apply_page(sample_page(5, true));
let fence = state.snapshot_fence;
state.apply_page(sample_page(3, false));
assert_eq!(
state.snapshot_fence, fence,
"Fence should not change on second page"
);
}
#[test]
fn test_mr_filter_reviewer_field() {
let f = MrFilter {
reviewer: Some("alice".into()),
..Default::default()
};
assert!(f.is_active());
assert_ne!(f.hash_value(), MrFilter::default().hash_value());
}
#[test]
fn test_mr_filter_target_branch_field() {
let f = MrFilter {
target_branch: Some("main".into()),
..Default::default()
};
assert!(f.is_active());
}
#[test]
fn test_mr_list_row_draft_field() {
let row = MrListRow {
project_path: "g/p".into(),
iid: 1,
title: "Draft MR".into(),
state: "opened".into(),
author: "taylor".into(),
target_branch: "main".into(),
labels: vec![],
updated_at: 0,
draft: true,
};
assert!(row.draft);
}
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
//! Search screen state.
use crate::message::SearchResult;
/// State for the search screen.
#[derive(Debug, Default)]
pub struct SearchState {
pub query: String,
pub query_focused: bool,
pub results: Vec<SearchResult>,
pub selected_index: usize,
}

View File

@@ -0,0 +1,15 @@
#![allow(dead_code)]
//! Sync screen state.
/// State for the sync progress/summary screen.
#[derive(Debug, Default)]
pub struct SyncState {
pub stage: String,
pub current: u64,
pub total: u64,
pub log_lines: Vec<String>,
pub completed: bool,
pub elapsed_ms: Option<u64>,
pub error: Option<String>,
}

View File

@@ -0,0 +1,12 @@
#![allow(dead_code)]
//! Timeline screen state.
use crate::message::TimelineEvent;
/// State for the timeline screen.
#[derive(Debug, Default)]
pub struct TimelineState {
pub events: Vec<TimelineEvent>,
pub scroll_offset: u16,
}

View File

@@ -0,0 +1,12 @@
#![allow(dead_code)]
//! Who (people intelligence) screen state.
use crate::message::WhoResult;
/// State for the who/people screen.
#[derive(Debug, Default)]
pub struct WhoState {
pub result: Option<WhoResult>,
pub scroll_offset: u16,
}

View File

@@ -0,0 +1,380 @@
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
//! Centralized background task management with dedup and cancellation.
//!
//! All background work (DB queries, sync, search) flows through
//! [`TaskSupervisor`]. Submitting a task with a key that already has an
//! active handle cancels the previous task via its [`CancelToken`] and
//! bumps the generation counter.
//!
//! Generation IDs enable stale-result detection: when an async result
//! arrives, [`is_current`] checks whether the result's generation
//! matches the latest submission for that key.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use crate::message::Screen;
// ---------------------------------------------------------------------------
// TaskKey
// ---------------------------------------------------------------------------
/// Deduplication key for background tasks.
///
/// Two tasks with the same key cannot run concurrently — submitting a
/// new task with an existing key cancels the previous one.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum TaskKey {
/// Load data for a specific screen.
LoadScreen(Screen),
/// Global search query.
Search,
/// Sync stream (only one at a time).
SyncStream,
/// Re-query after filter change on a specific screen.
FilterRequery(Screen),
}
// ---------------------------------------------------------------------------
// TaskPriority
// ---------------------------------------------------------------------------
/// Priority levels for task scheduling.
///
/// Lower numeric value = higher priority.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TaskPriority {
/// User-initiated input (highest priority).
Input = 0,
/// Navigation-triggered data load.
Navigation = 1,
/// Background refresh / prefetch (lowest priority).
Background = 2,
}
// ---------------------------------------------------------------------------
// CancelToken
// ---------------------------------------------------------------------------
/// Thread-safe cooperative cancellation flag.
///
/// Background tasks poll [`is_cancelled`] periodically and exit early
/// when it returns `true`.
#[derive(Debug)]
pub struct CancelToken {
cancelled: AtomicBool,
}
impl CancelToken {
/// Create a new, non-cancelled token.
#[must_use]
pub fn new() -> Self {
Self {
cancelled: AtomicBool::new(false),
}
}
/// Signal cancellation.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
/// Check whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
}
impl Default for CancelToken {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// InterruptHandle
// ---------------------------------------------------------------------------
/// Opaque handle for interrupting a rusqlite operation.
///
/// Wraps the rusqlite `InterruptHandle` so the supervisor can cancel
/// long-running queries. This is only set for tasks that lease a reader
/// connection from [`DbManager`](crate::db::DbManager).
pub struct InterruptHandle {
handle: rusqlite::InterruptHandle,
}
impl std::fmt::Debug for InterruptHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InterruptHandle").finish_non_exhaustive()
}
}
impl InterruptHandle {
/// Wrap a rusqlite interrupt handle.
#[must_use]
pub fn new(handle: rusqlite::InterruptHandle) -> Self {
Self { handle }
}
/// Interrupt the associated SQLite operation.
pub fn interrupt(&self) {
self.handle.interrupt();
}
}
// ---------------------------------------------------------------------------
// TaskHandle
// ---------------------------------------------------------------------------
/// Handle returned when a task is submitted.
///
/// Callers use this to pass the generation ID into async work so
/// results can be tagged and checked for staleness.
#[derive(Debug)]
pub struct TaskHandle {
/// Dedup key for this task.
pub key: TaskKey,
/// Monotonically increasing generation for stale detection.
pub generation: u64,
/// Cooperative cancellation token (shared with the supervisor).
pub cancel: Arc<CancelToken>,
/// Optional SQLite interrupt handle for long queries.
pub interrupt: Option<InterruptHandle>,
}
// ---------------------------------------------------------------------------
// TaskSupervisor
// ---------------------------------------------------------------------------
/// Manages background tasks with deduplication and cancellation.
///
/// Only one task per [`TaskKey`] can be active. Submitting a new task
/// with an existing key cancels the previous one (via its cancel token
/// and optional interrupt handle) before registering the new handle.
pub struct TaskSupervisor {
active: HashMap<TaskKey, TaskHandle>,
next_generation: AtomicU64,
}
impl TaskSupervisor {
/// Create a new supervisor with no active tasks.
#[must_use]
pub fn new() -> Self {
Self {
active: HashMap::new(),
next_generation: AtomicU64::new(1),
}
}
/// Submit a new task, cancelling any existing task with the same key.
///
/// Returns a [`TaskHandle`] with a fresh generation ID and a shared
/// cancel token. The caller clones the `Arc<CancelToken>` and passes
/// it into the async work.
pub fn submit(&mut self, key: TaskKey) -> &TaskHandle {
// Cancel existing task with this key, if any.
if let Some(old) = self.active.remove(&key) {
old.cancel.cancel();
if let Some(interrupt) = &old.interrupt {
interrupt.interrupt();
}
}
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let cancel = Arc::new(CancelToken::new());
let handle = TaskHandle {
key: key.clone(),
generation,
cancel,
interrupt: None,
};
self.active.insert(key.clone(), handle);
self.active.get(&key).expect("just inserted")
}
/// Check whether a generation is current for a given key.
///
/// Returns `true` only if the key has an active handle with the
/// specified generation.
#[must_use]
pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool {
self.active
.get(key)
.is_some_and(|h| h.generation == generation)
}
/// Mark a task as complete, removing its handle.
///
/// Only removes the handle if the generation matches the active one.
/// This prevents a late-arriving completion from removing a newer
/// task's handle.
pub fn complete(&mut self, key: &TaskKey, generation: u64) {
if self.is_current(key, generation) {
self.active.remove(key);
}
}
/// Cancel all active tasks.
///
/// Used during shutdown to ensure background work stops promptly.
pub fn cancel_all(&mut self) {
for (_, handle) in self.active.drain() {
handle.cancel.cancel();
if let Some(interrupt) = &handle.interrupt {
interrupt.interrupt();
}
}
}
/// Number of currently active tasks.
#[must_use]
pub fn active_count(&self) -> usize {
self.active.len()
}
}
impl Default for TaskSupervisor {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_submit_cancels_previous() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let cancel1 = sup.active.get(&TaskKey::Search).unwrap().cancel.clone();
let gen2 = sup.submit(TaskKey::Search).generation;
// First task's token should be cancelled.
assert!(cancel1.is_cancelled());
// Second task should have a different (higher) generation.
assert!(gen2 > gen1);
// Only one active task for this key.
assert_eq!(sup.active_count(), 1);
}
#[test]
fn test_is_current_after_supersede() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let gen2 = sup.submit(TaskKey::Search).generation;
assert!(!sup.is_current(&TaskKey::Search, gen1));
assert!(sup.is_current(&TaskKey::Search, gen2));
}
#[test]
fn test_complete_removes_handle() {
let mut sup = TaskSupervisor::new();
let generation = sup.submit(TaskKey::Search).generation;
assert_eq!(sup.active_count(), 1);
sup.complete(&TaskKey::Search, generation);
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_complete_ignores_stale() {
let mut sup = TaskSupervisor::new();
let gen1 = sup.submit(TaskKey::Search).generation;
let gen2 = sup.submit(TaskKey::Search).generation;
// Completing with old generation should NOT remove the newer handle.
sup.complete(&TaskKey::Search, gen1);
assert_eq!(sup.active_count(), 1);
assert!(sup.is_current(&TaskKey::Search, gen2));
}
#[test]
fn test_generation_monotonic() {
let mut sup = TaskSupervisor::new();
let g1 = sup.submit(TaskKey::Search).generation;
let g2 = sup.submit(TaskKey::SyncStream).generation;
let g3 = sup.submit(TaskKey::Search).generation;
assert!(g1 < g2);
assert!(g2 < g3);
}
#[test]
fn test_different_keys_coexist() {
let mut sup = TaskSupervisor::new();
sup.submit(TaskKey::Search);
sup.submit(TaskKey::SyncStream);
sup.submit(TaskKey::LoadScreen(Screen::Dashboard));
assert_eq!(sup.active_count(), 3);
}
#[test]
fn test_cancel_all() {
let mut sup = TaskSupervisor::new();
let cancel_search = {
sup.submit(TaskKey::Search);
sup.active.get(&TaskKey::Search).unwrap().cancel.clone()
};
let cancel_sync = {
sup.submit(TaskKey::SyncStream);
sup.active.get(&TaskKey::SyncStream).unwrap().cancel.clone()
};
sup.cancel_all();
assert!(cancel_search.is_cancelled());
assert!(cancel_sync.is_cancelled());
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_cancel_token_default_is_not_cancelled() {
let token = CancelToken::new();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled());
}
#[test]
fn test_cancel_token_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CancelToken>();
assert_send_sync::<Arc<CancelToken>>();
}
#[test]
fn test_task_supervisor_default() {
let sup = TaskSupervisor::default();
assert_eq!(sup.active_count(), 0);
}
#[test]
fn test_filter_requery_key_distinct_per_screen() {
let mut sup = TaskSupervisor::new();
sup.submit(TaskKey::FilterRequery(Screen::IssueList));
sup.submit(TaskKey::FilterRequery(Screen::MrList));
assert_eq!(sup.active_count(), 2);
}
}

View File

@@ -0,0 +1,251 @@
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
//! Flexoki-based theme for the lore TUI.
//!
//! Uses FrankenTUI's `AdaptiveColor::adaptive(light, dark)` for automatic
//! light/dark mode switching. The palette is [Flexoki](https://stephango.com/flexoki)
//! by Steph Ango, designed in Oklab perceptual color space for balanced contrast.
use ftui::{AdaptiveColor, Color, PackedRgba, Style, Theme};
// ---------------------------------------------------------------------------
// Flexoki palette constants
// ---------------------------------------------------------------------------
// Base tones
const PAPER: Color = Color::rgb(0xFF, 0xFC, 0xF0);
const BASE_50: Color = Color::rgb(0xF2, 0xF0, 0xE5);
const BASE_100: Color = Color::rgb(0xE6, 0xE4, 0xD9);
const BASE_200: Color = Color::rgb(0xCE, 0xCD, 0xC3);
const BASE_300: Color = Color::rgb(0xB7, 0xB5, 0xAC);
const BASE_400: Color = Color::rgb(0x9F, 0x9D, 0x96);
const BASE_500: Color = Color::rgb(0x87, 0x85, 0x80);
const BASE_600: Color = Color::rgb(0x6F, 0x6E, 0x69);
const BASE_700: Color = Color::rgb(0x57, 0x56, 0x53);
const BASE_800: Color = Color::rgb(0x40, 0x3E, 0x3C);
const BASE_850: Color = Color::rgb(0x34, 0x33, 0x31);
const BASE_900: Color = Color::rgb(0x28, 0x27, 0x26);
const BLACK: Color = Color::rgb(0x10, 0x0F, 0x0F);
// Accent colors — light-600 (for light mode)
const RED_600: Color = Color::rgb(0xAF, 0x30, 0x29);
const ORANGE_600: Color = Color::rgb(0xBC, 0x52, 0x15);
const YELLOW_600: Color = Color::rgb(0xAD, 0x83, 0x01);
const GREEN_600: Color = Color::rgb(0x66, 0x80, 0x0B);
const CYAN_600: Color = Color::rgb(0x24, 0x83, 0x7B);
const BLUE_600: Color = Color::rgb(0x20, 0x5E, 0xA6);
const PURPLE_600: Color = Color::rgb(0x5E, 0x40, 0x9D);
// Accent colors — dark-400 (for dark mode)
const RED_400: Color = Color::rgb(0xD1, 0x4D, 0x41);
const ORANGE_400: Color = Color::rgb(0xDA, 0x70, 0x2C);
const YELLOW_400: Color = Color::rgb(0xD0, 0xA2, 0x15);
const GREEN_400: Color = Color::rgb(0x87, 0x9A, 0x39);
const CYAN_400: Color = Color::rgb(0x3A, 0xA9, 0x9F);
const BLUE_400: Color = Color::rgb(0x43, 0x85, 0xBE);
const PURPLE_400: Color = Color::rgb(0x8B, 0x7E, 0xC8);
const MAGENTA_400: Color = Color::rgb(0xCE, 0x5D, 0x97);
// Muted fallback as PackedRgba (for Style::fg)
const MUTED_PACKED: PackedRgba = PackedRgba::rgb(0x87, 0x85, 0x80);
// ---------------------------------------------------------------------------
// build_theme
// ---------------------------------------------------------------------------
/// Build the lore TUI theme with Flexoki adaptive colors.
///
/// Each of the 19 semantic slots gets an `AdaptiveColor::adaptive(light, dark)`
/// pair. FrankenTUI detects the terminal background and resolves accordingly.
#[must_use]
pub fn build_theme() -> Theme {
Theme::builder()
.primary(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
.secondary(AdaptiveColor::adaptive(CYAN_600, CYAN_400))
.accent(AdaptiveColor::adaptive(PURPLE_600, PURPLE_400))
.background(AdaptiveColor::adaptive(PAPER, BLACK))
.surface(AdaptiveColor::adaptive(BASE_50, BASE_900))
.overlay(AdaptiveColor::adaptive(BASE_100, BASE_850))
.text(AdaptiveColor::adaptive(BASE_700, BASE_200))
.text_muted(AdaptiveColor::adaptive(BASE_500, BASE_500))
.text_subtle(AdaptiveColor::adaptive(BASE_400, BASE_600))
.success(AdaptiveColor::adaptive(GREEN_600, GREEN_400))
.warning(AdaptiveColor::adaptive(YELLOW_600, YELLOW_400))
.error(AdaptiveColor::adaptive(RED_600, RED_400))
.info(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
.border(AdaptiveColor::adaptive(BASE_300, BASE_700))
.border_focused(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
.selection_bg(AdaptiveColor::adaptive(BASE_100, BASE_800))
.selection_fg(AdaptiveColor::adaptive(BASE_700, BASE_100))
.scrollbar_track(AdaptiveColor::adaptive(BASE_50, BASE_900))
.scrollbar_thumb(AdaptiveColor::adaptive(BASE_300, BASE_700))
.build()
}
// ---------------------------------------------------------------------------
// State colors
// ---------------------------------------------------------------------------
/// Map a GitLab entity state to a display color.
///
/// Returns fixed (non-adaptive) colors — state indicators should be
/// consistent regardless of light/dark mode.
#[must_use]
pub fn state_color(state: &str) -> Color {
match state {
"opened" => GREEN_400,
"closed" => RED_400,
"merged" => PURPLE_400,
"locked" => YELLOW_400,
_ => BASE_500,
}
}
// ---------------------------------------------------------------------------
// Event type colors
// ---------------------------------------------------------------------------
/// Map a timeline event type to a display color.
#[must_use]
pub fn event_color(event_type: &str) -> Color {
match event_type {
"created" => GREEN_400,
"updated" => BLUE_400,
"closed" => RED_400,
"merged" => PURPLE_400,
"commented" => CYAN_400,
"labeled" => ORANGE_400,
"milestoned" => YELLOW_400,
_ => BASE_500,
}
}
// ---------------------------------------------------------------------------
// Label styling
// ---------------------------------------------------------------------------
/// Convert a GitLab label hex color (e.g., "#FF0000" or "FF0000") to a Style.
///
/// Falls back to muted text color if the hex string is invalid.
#[must_use]
pub fn label_style(hex_color: &str) -> Style {
let packed = parse_hex_to_packed(hex_color).unwrap_or(MUTED_PACKED);
Style::default().fg(packed)
}
/// Parse a hex color string like "#RRGGBB" or "RRGGBB" into a `PackedRgba`.
fn parse_hex_to_packed(s: &str) -> Option<PackedRgba> {
let hex = s.strip_prefix('#').unwrap_or(s);
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(PackedRgba::rgb(r, g, b))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_theme_compiles() {
let theme = build_theme();
// Resolve for dark mode — primary should be Blue-400
let resolved = theme.resolve(true);
assert_eq!(resolved.primary, BLUE_400);
}
#[test]
fn test_build_theme_light_mode() {
let theme = build_theme();
let resolved = theme.resolve(false);
assert_eq!(resolved.primary, BLUE_600);
}
#[test]
fn test_build_theme_all_slots_differ_between_modes() {
let theme = build_theme();
let dark = theme.resolve(true);
let light = theme.resolve(false);
// Background should differ (Paper vs Black)
assert_ne!(dark.background, light.background);
// Text should differ
assert_ne!(dark.text, light.text);
}
#[test]
fn test_state_color_opened_is_green() {
assert_eq!(state_color("opened"), GREEN_400);
}
#[test]
fn test_state_color_closed_is_red() {
assert_eq!(state_color("closed"), RED_400);
}
#[test]
fn test_state_color_merged_is_purple() {
assert_eq!(state_color("merged"), PURPLE_400);
}
#[test]
fn test_state_color_unknown_returns_muted() {
assert_eq!(state_color("unknown"), BASE_500);
}
#[test]
fn test_event_color_created_is_green() {
assert_eq!(event_color("created"), GREEN_400);
}
#[test]
fn test_event_color_unknown_returns_muted() {
assert_eq!(event_color("whatever"), BASE_500);
}
#[test]
fn test_label_style_valid_hex_with_hash() {
let style = label_style("#FF0000");
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
}
#[test]
fn test_label_style_valid_hex_without_hash() {
let style = label_style("00FF00");
assert_eq!(style.fg, Some(PackedRgba::rgb(0x00, 0xFF, 0x00)));
}
#[test]
fn test_label_style_lowercase_hex() {
let style = label_style("#ff0000");
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
}
#[test]
fn test_label_style_invalid_hex_fallback() {
let style = label_style("invalid");
assert_eq!(style.fg, Some(MUTED_PACKED));
}
#[test]
fn test_label_style_empty_fallback() {
let style = label_style("");
assert_eq!(style.fg, Some(MUTED_PACKED));
}
#[test]
fn test_parse_hex_short_string() {
assert!(parse_hex_to_packed("#FFF").is_none());
}
#[test]
fn test_parse_hex_non_hex_chars() {
assert!(parse_hex_to_packed("#GGHHII").is_none());
}
}

View File

@@ -0,0 +1,208 @@
//! Navigation breadcrumb trail ("Dashboard > Issues > #42").
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::navigation::NavigationStack;
/// Render the navigation breadcrumb trail.
///
/// Shows "Dashboard > Issues > Issue" with " > " separators. When the
/// trail exceeds the available width, entries are truncated from the left
/// with a leading "...".
pub fn render_breadcrumb(
frame: &mut Frame<'_>,
area: Rect,
nav: &NavigationStack,
text_color: PackedRgba,
muted_color: PackedRgba,
) {
if area.height == 0 || area.width < 3 {
return;
}
let crumbs = nav.breadcrumbs();
let separator = " > ";
// Build the full breadcrumb string and calculate width.
let full: String = crumbs.join(separator);
let max_width = area.width as usize;
let display = if full.len() <= max_width {
full
} else {
// Truncate from the left: show "... > last_crumbs"
truncate_breadcrumb_left(&crumbs, separator, max_width)
};
let base = Cell {
fg: text_color,
..Cell::default()
};
let muted = Cell {
fg: muted_color,
..Cell::default()
};
// Render each segment with separators in muted color.
let mut x = area.x;
let max_x = area.x.saturating_add(area.width);
if let Some(rest) = display.strip_prefix("...") {
// Render ellipsis in muted, then the rest
x = frame.print_text_clipped(x, area.y, "...", muted, max_x);
if !rest.is_empty() {
render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x);
}
} else {
render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x);
}
}
/// Render breadcrumb text with separators in muted color.
#[allow(clippy::too_many_arguments)]
fn render_crumb_segments(
frame: &mut Frame<'_>,
start_x: u16,
y: u16,
text: &str,
separator: &str,
base: Cell,
muted: Cell,
max_x: u16,
) {
let mut x = start_x;
let parts: Vec<&str> = text.split(separator).collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
x = frame.print_text_clipped(x, y, separator, muted, max_x);
}
x = frame.print_text_clipped(x, y, part, base, max_x);
if x >= max_x {
break;
}
}
}
/// Truncate breadcrumb from the left to fit within max_width.
fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String {
let ellipsis = "...";
// Try showing progressively fewer crumbs from the right.
for skip in 1..crumbs.len() {
let tail = &crumbs[skip..];
let tail_str: String = tail.join(separator);
let candidate = format!("{ellipsis}{separator}{tail_str}");
if candidate.len() <= max_width {
return candidate;
}
}
// Last resort: just the current screen truncated.
let last = crumbs.last().unwrap_or(&"");
if last.len() + ellipsis.len() <= max_width {
return format!("{ellipsis}{last}");
}
// Truly tiny terminal: just ellipsis.
ellipsis.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Screen;
use crate::navigation::NavigationStack;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_breadcrumb_single_screen() {
with_frame!(80, 1, |frame| {
let nav = NavigationStack::new();
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
let cell = frame.buffer.get(0, 0).unwrap();
assert!(
cell.content.as_char() == Some('D'),
"Expected 'D' at (0,0), got {:?}",
cell.content.as_char()
);
});
}
#[test]
fn test_breadcrumb_multi_screen() {
with_frame!(80, 1, |frame| {
let mut nav = NavigationStack::new();
nav.push(Screen::IssueList);
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
let d = frame.buffer.get(0, 0).unwrap();
assert_eq!(d.content.as_char(), Some('D'));
// "Dashboard > Issues" = 'I' at 12
let i_cell = frame.buffer.get(12, 0).unwrap();
assert_eq!(i_cell.content.as_char(), Some('I'));
});
}
#[test]
fn test_breadcrumb_truncation() {
let crumbs = vec!["Dashboard", "Issues", "Issue"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 20);
assert!(
result.starts_with("..."),
"Expected ellipsis prefix, got: {result}"
);
assert!(result.len() <= 20, "Result too long: {result}");
}
#[test]
fn test_breadcrumb_zero_height_noop() {
with_frame!(80, 1, |frame| {
let nav = NavigationStack::new();
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray());
});
}
#[test]
fn test_truncate_breadcrumb_fits() {
let crumbs = vec!["A", "B"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 100);
assert!(result.contains("..."), "Should always add ellipsis");
}
#[test]
fn test_truncate_breadcrumb_single_entry() {
let crumbs = vec!["Dashboard"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 5);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_breadcrumb_shows_last_entries() {
let crumbs = vec!["Dashboard", "Issues", "Issue Detail"];
let result = truncate_breadcrumb_left(&crumbs, " > ", 30);
assert!(result.starts_with("..."));
assert!(result.contains("Issue Detail"));
}
}

View File

@@ -0,0 +1,410 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
//! Cross-reference widget for entity detail screens.
//!
//! Renders a list of linked entities (closing MRs, related issues, mentions)
//! as navigable items. Used in both Issue Detail and MR Detail views.
use std::fmt;
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::message::EntityKey;
// ---------------------------------------------------------------------------
// CrossRefKind
// ---------------------------------------------------------------------------
/// The relationship type between two entities.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossRefKind {
/// MR that closes this issue when merged.
ClosingMr,
/// Issue related via GitLab link.
RelatedIssue,
/// Entity mentioned in a note or description.
MentionedIn,
}
impl CrossRefKind {
/// Short icon/prefix for display.
#[must_use]
pub const fn icon(&self) -> &str {
match self {
Self::ClosingMr => "MR",
Self::RelatedIssue => "REL",
Self::MentionedIn => "REF",
}
}
}
impl fmt::Display for CrossRefKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClosingMr => write!(f, "Closing MR"),
Self::RelatedIssue => write!(f, "Related Issue"),
Self::MentionedIn => write!(f, "Mentioned In"),
}
}
}
// ---------------------------------------------------------------------------
// CrossRef
// ---------------------------------------------------------------------------
/// A single cross-reference to another entity.
#[derive(Debug, Clone)]
pub struct CrossRef {
/// Relationship type.
pub kind: CrossRefKind,
/// Target entity identity.
pub entity_key: EntityKey,
/// Human-readable label (e.g., "Fix authentication flow").
pub label: String,
/// Whether this ref points to an entity in the local DB (navigable).
pub navigable: bool,
}
// ---------------------------------------------------------------------------
// CrossRefState
// ---------------------------------------------------------------------------
/// Rendering state for the cross-reference list.
#[derive(Debug, Clone, Default)]
pub struct CrossRefState {
/// Index of the selected cross-reference.
pub selected: usize,
/// First visible item index.
pub scroll_offset: usize,
}
impl CrossRefState {
/// Move selection down.
pub fn select_next(&mut self, total: usize) {
if total > 0 && self.selected < total - 1 {
self.selected += 1;
}
}
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Color scheme for cross-reference rendering.
pub struct CrossRefColors {
/// Foreground for the kind icon/badge.
pub kind_fg: PackedRgba,
/// Foreground for the label text.
pub label_fg: PackedRgba,
/// Muted foreground for non-navigable refs.
pub muted_fg: PackedRgba,
/// Selected item foreground.
pub selected_fg: PackedRgba,
/// Selected item background.
pub selected_bg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render a list of cross-references within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout per row:
/// ```text
/// [MR] !42 Fix authentication flow
/// [REL] #15 Related auth issue
/// [REF] !99 Mentioned in pipeline MR
/// ```
pub fn render_cross_refs(
frame: &mut Frame<'_>,
refs: &[CrossRef],
state: &CrossRefState,
area: Rect,
colors: &CrossRefColors,
) -> u16 {
if refs.is_empty() || area.height == 0 || area.width < 10 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let visible_count = (area.height as usize).min(refs.len().saturating_sub(state.scroll_offset));
for i in 0..visible_count {
let idx = state.scroll_offset + i;
let Some(cr) = refs.get(idx) else { break };
let y = area.y + i as u16;
let is_selected = idx == state.selected;
// Background fill for selected row.
if is_selected {
frame.draw_rect_filled(
Rect::new(area.x, y, area.width, 1),
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
},
);
}
let mut x = area.x;
// Kind badge: [MR], [REL], [REF]
let badge = format!("[{}]", cr.kind.icon());
let badge_style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else {
Cell {
fg: colors.kind_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, &badge, badge_style, max_x);
// Spacing
x = frame.print_text_clipped(x, y, " ", badge_style, max_x);
// Entity prefix + label
let prefix = match cr.kind {
CrossRefKind::ClosingMr | CrossRefKind::MentionedIn => {
format!("!{} ", cr.entity_key.iid)
}
CrossRefKind::RelatedIssue => {
format!("#{} ", cr.entity_key.iid)
}
};
let label_style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else if cr.navigable {
Cell {
fg: colors.label_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.muted_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, &prefix, label_style, max_x);
let _ = frame.print_text_clipped(x, y, &cr.label, label_style, max_x);
}
visible_count as u16
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_refs() -> Vec<CrossRef> {
vec![
CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 42),
label: "Fix authentication flow".into(),
navigable: true,
},
CrossRef {
kind: CrossRefKind::RelatedIssue,
entity_key: EntityKey::issue(1, 15),
label: "Related auth issue".into(),
navigable: true,
},
CrossRef {
kind: CrossRefKind::MentionedIn,
entity_key: EntityKey::mr(2, 99),
label: "Pipeline improvements".into(),
navigable: false,
},
]
}
fn test_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
label_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
muted_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
}
}
// TDD anchor test from bead spec.
#[test]
fn test_cross_ref_entity_key() {
let cr = CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 42),
label: "Fix auth".into(),
navigable: true,
};
assert_eq!(cr.kind, CrossRefKind::ClosingMr);
assert_eq!(cr.entity_key, EntityKey::mr(1, 42));
}
#[test]
fn test_cross_ref_kind_display() {
assert_eq!(CrossRefKind::ClosingMr.to_string(), "Closing MR");
assert_eq!(CrossRefKind::RelatedIssue.to_string(), "Related Issue");
assert_eq!(CrossRefKind::MentionedIn.to_string(), "Mentioned In");
}
#[test]
fn test_cross_ref_kind_icon() {
assert_eq!(CrossRefKind::ClosingMr.icon(), "MR");
assert_eq!(CrossRefKind::RelatedIssue.icon(), "REL");
assert_eq!(CrossRefKind::MentionedIn.icon(), "REF");
}
#[test]
fn test_cross_ref_state_navigation() {
let mut state = CrossRefState::default();
assert_eq!(state.selected, 0);
state.select_next(3);
assert_eq!(state.selected, 1);
state.select_next(3);
assert_eq!(state.selected, 2);
// Can't go past end.
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
state.select_prev();
assert_eq!(state.selected, 0);
// Can't go before start.
state.select_prev();
assert_eq!(state.selected, 0);
}
#[test]
fn test_render_cross_refs_no_panic() {
with_frame!(80, 10, |frame| {
let refs = sample_refs();
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 10),
&test_colors(),
);
assert_eq!(rows, 3);
});
}
#[test]
fn test_render_cross_refs_empty() {
with_frame!(80, 10, |frame| {
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&[],
&state,
Rect::new(0, 0, 80, 10),
&test_colors(),
);
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_cross_refs_tiny_area() {
with_frame!(5, 1, |frame| {
let refs = sample_refs();
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 5, 1),
&test_colors(),
);
// Too narrow (< 10), should bail.
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_cross_refs_with_scroll() {
with_frame!(80, 2, |frame| {
let refs = sample_refs();
let state = CrossRefState {
selected: 2,
scroll_offset: 1,
};
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 2),
&test_colors(),
);
// 2 visible (indices 1 and 2).
assert_eq!(rows, 2);
});
}
#[test]
fn test_render_cross_refs_non_navigable() {
with_frame!(80, 5, |frame| {
let refs = vec![CrossRef {
kind: CrossRefKind::MentionedIn,
entity_key: EntityKey::mr(2, 99),
label: "Non-local entity".into(),
navigable: false,
}];
let state = CrossRefState::default();
let rows = render_cross_refs(
&mut frame,
&refs,
&state,
Rect::new(0, 0, 80, 5),
&test_colors(),
);
assert_eq!(rows, 1);
});
}
}

View File

@@ -0,0 +1,979 @@
#![allow(dead_code)] // Phase 2: consumed by Issue Detail + MR Detail screens
//! Discussion tree widget for entity detail screens.
//!
//! Renders threaded conversations from GitLab issues/MRs. Discussions are
//! top-level expandable nodes, with notes as children. Supports expand/collapse
//! persistence, system note styling, and diff note file path rendering.
use std::collections::HashSet;
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
/// A single discussion thread (top-level node).
#[derive(Debug, Clone)]
pub struct DiscussionNode {
/// GitLab discussion ID (used as expand/collapse key).
pub discussion_id: String,
/// Notes within this discussion, ordered by position.
pub notes: Vec<NoteNode>,
/// Whether this discussion is resolvable (MR discussions only).
pub resolvable: bool,
/// Whether this discussion has been resolved.
pub resolved: bool,
}
impl DiscussionNode {
/// Summary line for collapsed display.
fn summary(&self) -> String {
let first = self.notes.first();
let author = first.map_or("unknown", |n| n.author.as_str());
let note_count = self.notes.len();
let resolved_tag = if self.resolved { " [resolved]" } else { "" };
if note_count == 1 {
format!("{author}{resolved_tag}")
} else {
format!("{author} ({note_count} notes){resolved_tag}")
}
}
/// First line of the first note body, sanitized and truncated.
fn preview(&self, max_chars: usize) -> String {
self.notes
.first()
.and_then(|n| n.body.lines().next())
.map(|line| {
let sanitized = sanitize_for_terminal(line, UrlPolicy::Strip);
if sanitized.len() > max_chars {
let trunc = max_chars.saturating_sub(3);
// Find the last valid char boundary at or before `trunc`
// to avoid panicking on multi-byte UTF-8 (emoji, CJK).
let safe_end = sanitized
.char_indices()
.take_while(|&(i, _)| i <= trunc)
.last()
.map_or(0, |(i, c)| i + c.len_utf8());
format!("{}...", &sanitized[..safe_end])
} else {
sanitized
}
})
.unwrap_or_default()
}
}
/// A single note within a discussion.
#[derive(Debug, Clone)]
pub struct NoteNode {
/// Author username.
pub author: String,
/// Note body (markdown text from GitLab).
pub body: String,
/// Creation timestamp in milliseconds since epoch.
pub created_at: i64,
/// Whether this is a system-generated note.
pub is_system: bool,
/// Whether this is a diff/code review note.
pub is_diff_note: bool,
/// File path for diff notes.
pub diff_file_path: Option<String>,
/// New line number for diff notes.
pub diff_new_line: Option<i64>,
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
/// Rendering state for the discussion tree.
#[derive(Debug, Clone, Default)]
pub struct DiscussionTreeState {
/// Index of the selected discussion (0-based).
pub selected: usize,
/// First visible row index for scrolling.
pub scroll_offset: usize,
/// Set of expanded discussion IDs.
pub expanded: HashSet<String>,
}
impl DiscussionTreeState {
/// Move selection down.
pub fn select_next(&mut self, total: usize) {
if total > 0 && self.selected < total - 1 {
self.selected += 1;
}
}
/// Move selection up.
pub fn select_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
/// Toggle expand/collapse for the selected discussion.
pub fn toggle_selected(&mut self, discussions: &[DiscussionNode]) {
if let Some(d) = discussions.get(self.selected) {
let id = &d.discussion_id;
if self.expanded.contains(id) {
self.expanded.remove(id);
} else {
self.expanded.insert(id.clone());
}
}
}
/// Whether a discussion is expanded.
#[must_use]
pub fn is_expanded(&self, discussion_id: &str) -> bool {
self.expanded.contains(discussion_id)
}
}
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
/// Color scheme for discussion tree rendering.
pub struct DiscussionTreeColors {
/// Author name foreground.
pub author_fg: PackedRgba,
/// Timestamp foreground.
pub timestamp_fg: PackedRgba,
/// Note body foreground.
pub body_fg: PackedRgba,
/// System note foreground (muted).
pub system_fg: PackedRgba,
/// Diff file path foreground.
pub diff_path_fg: PackedRgba,
/// Resolved indicator foreground.
pub resolved_fg: PackedRgba,
/// Tree guide characters.
pub guide_fg: PackedRgba,
/// Selected discussion background.
pub selected_fg: PackedRgba,
/// Selected discussion background.
pub selected_bg: PackedRgba,
/// Expand/collapse indicator.
pub expand_fg: PackedRgba,
}
// ---------------------------------------------------------------------------
// Relative time formatting
// ---------------------------------------------------------------------------
/// Format a timestamp as a human-readable relative time string.
///
/// Uses the provided `Clock` for deterministic rendering in tests.
#[must_use]
pub fn format_relative_time(epoch_ms: i64, clock: &dyn Clock) -> String {
let now_ms = clock.now_ms();
let diff_ms = now_ms.saturating_sub(epoch_ms);
if diff_ms < 0 {
return "just now".to_string();
}
let seconds = diff_ms / 1_000;
let minutes = seconds / 60;
let hours = minutes / 60;
let days = hours / 24;
let weeks = days / 7;
let months = days / 30;
if seconds < 60 {
"just now".to_string()
} else if minutes < 60 {
format!("{minutes}m ago")
} else if hours < 24 {
format!("{hours}h ago")
} else if days < 7 {
format!("{days}d ago")
} else if weeks < 4 {
format!("{weeks}w ago")
} else {
format!("{months}mo ago")
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Maximum indent depth for nested content (notes within discussions).
const INDENT: u16 = 4;
/// Render a discussion tree within the given area.
///
/// Returns the number of rows consumed.
///
/// Layout:
/// ```text
/// > alice (3 notes) [resolved] <- collapsed discussion
/// First line of note body preview...
///
/// v bob (2 notes) <- expanded discussion
/// | bob · 3h ago
/// | This is the first note body...
/// |
/// | alice · 1h ago <- diff note
/// | diff src/auth.rs:42
/// | Code review comment about...
/// ```
pub fn render_discussion_tree(
frame: &mut Frame<'_>,
discussions: &[DiscussionNode],
state: &DiscussionTreeState,
area: Rect,
colors: &DiscussionTreeColors,
clock: &dyn Clock,
) -> u16 {
if discussions.is_empty() || area.height == 0 || area.width < 15 {
return 0;
}
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let y_max = area.y.saturating_add(area.height);
// Pre-compute all visual rows to support scroll offset.
let rows = compute_visual_rows_with_clock(
discussions,
state,
max_x.saturating_sub(area.x) as usize,
clock,
);
// Apply scroll offset.
let visible_rows = rows
.iter()
.skip(state.scroll_offset)
.take(area.height as usize);
for row in visible_rows {
if y >= y_max {
break;
}
match row {
VisualRow::DiscussionHeader {
disc_idx,
expanded,
summary,
preview,
} => {
let is_selected = *disc_idx == state.selected;
// Background fill for selected.
if is_selected {
frame.draw_rect_filled(
Rect::new(area.x, y, area.width, 1),
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
},
);
}
let style = if is_selected {
Cell {
fg: colors.selected_fg,
bg: colors.selected_bg,
..Cell::default()
}
} else {
Cell {
fg: colors.author_fg,
..Cell::default()
}
};
let indicator = if *expanded { "v " } else { "> " };
let mut x = frame.print_text_clipped(area.x, y, indicator, style, max_x);
x = frame.print_text_clipped(x, y, summary, style, max_x);
// Show preview on same line for collapsed.
if !expanded && !preview.is_empty() {
let preview_style = if is_selected {
style
} else {
Cell {
fg: colors.timestamp_fg,
..Cell::default()
}
};
x = frame.print_text_clipped(x, y, " - ", preview_style, max_x);
let _ = frame.print_text_clipped(x, y, preview, preview_style, max_x);
}
y += 1;
}
VisualRow::NoteHeader {
author,
relative_time,
is_system,
..
} => {
let style = if *is_system {
Cell {
fg: colors.system_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.author_fg,
..Cell::default()
}
};
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let indent_x = area.x.saturating_add(INDENT);
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
x = frame.print_text_clipped(x.max(indent_x), y, author, style, max_x);
let time_style = Cell {
fg: colors.timestamp_fg,
..Cell::default()
};
x = frame.print_text_clipped(x, y, " · ", time_style, max_x);
let _ = frame.print_text_clipped(x, y, relative_time, time_style, max_x);
y += 1;
}
VisualRow::DiffPath { file_path, line } => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let path_style = Cell {
fg: colors.diff_path_fg,
..Cell::default()
};
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
let indent_x = area.x.saturating_add(INDENT);
x = x.max(indent_x);
let location = match line {
Some(l) => format!("diff {file_path}:{l}"),
None => format!("diff {file_path}"),
};
let _ = frame.print_text_clipped(x, y, &location, path_style, max_x);
y += 1;
}
VisualRow::BodyLine { text, is_system } => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let body_style = if *is_system {
Cell {
fg: colors.system_fg,
..Cell::default()
}
} else {
Cell {
fg: colors.body_fg,
..Cell::default()
}
};
let mut x = frame.print_text_clipped(area.x, y, " | ", guide_style, max_x);
let indent_x = area.x.saturating_add(INDENT);
x = x.max(indent_x);
let _ = frame.print_text_clipped(x, y, text, body_style, max_x);
y += 1;
}
VisualRow::Separator => {
let guide_style = Cell {
fg: colors.guide_fg,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x, y, " |", guide_style, max_x);
y += 1;
}
}
}
y.saturating_sub(area.y)
}
// ---------------------------------------------------------------------------
// Visual row computation
// ---------------------------------------------------------------------------
/// Pre-computed visual row for the discussion tree.
///
/// We flatten the tree into rows to support scroll offset correctly.
#[derive(Debug)]
enum VisualRow {
/// Discussion header (collapsed or expanded).
DiscussionHeader {
disc_idx: usize,
expanded: bool,
summary: String,
preview: String,
},
/// Note author + timestamp line.
NoteHeader {
author: String,
relative_time: String,
is_system: bool,
},
/// Diff note file path line.
DiffPath {
file_path: String,
line: Option<i64>,
},
/// Note body text line.
BodyLine { text: String, is_system: bool },
/// Blank separator between notes.
Separator,
}
/// Maximum body lines shown per note to prevent one huge note from
/// consuming the entire viewport.
const MAX_BODY_LINES: usize = 10;
/// Compute visual rows with relative timestamps from the clock.
fn compute_visual_rows_with_clock(
discussions: &[DiscussionNode],
state: &DiscussionTreeState,
available_width: usize,
clock: &dyn Clock,
) -> Vec<VisualRow> {
let mut rows = Vec::new();
let preview_max = available_width.saturating_sub(40).max(20);
for (idx, disc) in discussions.iter().enumerate() {
let expanded = state.is_expanded(&disc.discussion_id);
rows.push(VisualRow::DiscussionHeader {
disc_idx: idx,
expanded,
summary: disc.summary(),
preview: if expanded {
String::new()
} else {
disc.preview(preview_max)
},
});
if expanded {
for (note_idx, note) in disc.notes.iter().enumerate() {
if note_idx > 0 {
rows.push(VisualRow::Separator);
}
rows.push(VisualRow::NoteHeader {
author: note.author.clone(),
relative_time: format_relative_time(note.created_at, clock),
is_system: note.is_system,
});
if note.is_diff_note
&& let Some(ref path) = note.diff_file_path
{
rows.push(VisualRow::DiffPath {
file_path: path.clone(),
line: note.diff_new_line,
});
}
let sanitized = sanitize_for_terminal(&note.body, UrlPolicy::Strip);
for (line_idx, line) in sanitized.lines().enumerate() {
if line_idx >= MAX_BODY_LINES {
rows.push(VisualRow::BodyLine {
text: "...".to_string(),
is_system: note.is_system,
});
break;
}
rows.push(VisualRow::BodyLine {
text: line.to_string(),
is_system: note.is_system,
});
}
}
}
}
rows
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_note(author: &str, body: &str, created_at: i64) -> NoteNode {
NoteNode {
author: author.into(),
body: body.into(),
created_at,
is_system: false,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}
}
fn system_note(body: &str, created_at: i64) -> NoteNode {
NoteNode {
author: "system".into(),
body: body.into(),
created_at,
is_system: true,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}
}
fn diff_note(author: &str, body: &str, path: &str, line: i64, created_at: i64) -> NoteNode {
NoteNode {
author: author.into(),
body: body.into(),
created_at,
is_system: false,
is_diff_note: true,
diff_file_path: Some(path.into()),
diff_new_line: Some(line),
}
}
fn sample_discussions() -> Vec<DiscussionNode> {
vec![
DiscussionNode {
discussion_id: "disc-1".into(),
notes: vec![
sample_note("alice", "This looks good overall", 1_700_000_000_000),
sample_note("bob", "Agreed, but one concern", 1_700_000_060_000),
],
resolvable: false,
resolved: false,
},
DiscussionNode {
discussion_id: "disc-2".into(),
notes: vec![diff_note(
"charlie",
"This function needs error handling",
"src/auth.rs",
42,
1_700_000_120_000,
)],
resolvable: true,
resolved: true,
},
DiscussionNode {
discussion_id: "disc-3".into(),
notes: vec![system_note("changed the description", 1_700_000_180_000)],
resolvable: false,
resolved: false,
},
]
}
fn test_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
timestamp_fg: PackedRgba::rgb(0x87, 0x87, 0x80),
body_fg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
system_fg: PackedRgba::rgb(0x6F, 0x6E, 0x69),
diff_path_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
resolved_fg: PackedRgba::rgb(0x87, 0x96, 0x6B),
guide_fg: PackedRgba::rgb(0x40, 0x40, 0x3C),
selected_fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),
selected_bg: PackedRgba::rgb(0xCE, 0xCD, 0xC3),
expand_fg: PackedRgba::rgb(0xDA, 0x70, 0x2C),
}
}
// Clock set to 1h after the last sample note.
fn test_clock() -> FakeClock {
FakeClock::from_ms(1_700_000_180_000 + 3_600_000)
}
#[test]
fn test_format_relative_time_just_now() {
let clock = FakeClock::from_ms(1_000_000);
assert_eq!(format_relative_time(1_000_000, &clock), "just now");
assert_eq!(format_relative_time(999_990, &clock), "just now");
}
#[test]
fn test_format_relative_time_minutes() {
let clock = FakeClock::from_ms(1_000_000 + 5 * 60 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "5m ago");
}
#[test]
fn test_format_relative_time_hours() {
let clock = FakeClock::from_ms(1_000_000 + 3 * 3_600 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "3h ago");
}
#[test]
fn test_format_relative_time_days() {
let clock = FakeClock::from_ms(1_000_000 + 2 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2d ago");
}
#[test]
fn test_format_relative_time_weeks() {
let clock = FakeClock::from_ms(1_000_000 + 14 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2w ago");
}
#[test]
fn test_format_relative_time_months() {
let clock = FakeClock::from_ms(1_000_000 + 60 * 86_400 * 1_000);
assert_eq!(format_relative_time(1_000_000, &clock), "2mo ago");
}
#[test]
fn test_discussion_node_summary() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![
sample_note("alice", "body", 0),
sample_note("bob", "reply", 1000),
],
resolvable: false,
resolved: false,
};
assert_eq!(disc.summary(), "alice (2 notes)");
}
#[test]
fn test_discussion_node_summary_single() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "body", 0)],
resolvable: false,
resolved: false,
};
assert_eq!(disc.summary(), "alice");
}
#[test]
fn test_discussion_node_summary_resolved() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "body", 0)],
resolvable: true,
resolved: true,
};
assert_eq!(disc.summary(), "alice [resolved]");
}
#[test]
fn test_discussion_node_preview() {
let disc = DiscussionNode {
discussion_id: "d1".into(),
notes: vec![sample_note("alice", "First line\nSecond line", 0)],
resolvable: false,
resolved: false,
};
assert_eq!(disc.preview(50), "First line");
}
#[test]
fn test_discussion_tree_state_navigation() {
let mut state = DiscussionTreeState::default();
assert_eq!(state.selected, 0);
state.select_next(3);
assert_eq!(state.selected, 1);
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_next(3);
assert_eq!(state.selected, 2);
state.select_prev();
assert_eq!(state.selected, 1);
state.select_prev();
assert_eq!(state.selected, 0);
state.select_prev();
assert_eq!(state.selected, 0);
}
#[test]
fn test_discussion_tree_state_toggle() {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
assert!(!state.is_expanded("disc-1"));
state.toggle_selected(&discussions);
assert!(state.is_expanded("disc-1"));
state.toggle_selected(&discussions);
assert!(!state.is_expanded("disc-1"));
}
#[test]
fn test_render_discussion_tree_collapsed_no_panic() {
with_frame!(80, 20, |frame| {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
// 3 discussions, all collapsed = 3 rows.
assert_eq!(rows, 3);
});
}
#[test]
fn test_render_discussion_tree_expanded_no_panic() {
with_frame!(80, 30, |frame| {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
state.expanded.insert("disc-1".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 30),
&test_colors(),
&clock,
);
// disc-1 expanded: header + 2 notes (each: header + body line) + separator between
// = 1 + (1+1) + 1 + (1+1) = 6 rows from disc-1
// disc-2 collapsed: 1 row
// disc-3 collapsed: 1 row
// Total: 8
assert!(rows >= 6); // At least disc-1 content + 2 collapsed.
});
}
#[test]
fn test_render_discussion_tree_empty() {
with_frame!(80, 20, |frame| {
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&[],
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_discussion_tree_tiny_area() {
with_frame!(10, 2, |frame| {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 10, 2),
&test_colors(),
&clock,
);
// Too narrow (< 15), should bail.
assert_eq!(rows, 0);
});
}
#[test]
fn test_render_discussion_tree_with_diff_note() {
with_frame!(80, 30, |frame| {
let discussions = vec![DiscussionNode {
discussion_id: "diff-disc".into(),
notes: vec![diff_note(
"reviewer",
"Add error handling here",
"src/main.rs",
42,
1_700_000_000_000,
)],
resolvable: true,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("diff-disc".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 30),
&test_colors(),
&clock,
);
// header + note header + diff path + body line = 4
assert!(rows >= 3);
});
}
#[test]
fn test_render_discussion_tree_system_note() {
with_frame!(80, 20, |frame| {
let discussions = vec![DiscussionNode {
discussion_id: "sys-disc".into(),
notes: vec![system_note("changed the description", 1_700_000_000_000)],
resolvable: false,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("sys-disc".into());
let clock = test_clock();
let rows = render_discussion_tree(
&mut frame,
&discussions,
&state,
Rect::new(0, 0, 80, 20),
&test_colors(),
&clock,
);
assert!(rows >= 2);
});
}
#[test]
fn test_compute_visual_rows_collapsed() {
let discussions = sample_discussions();
let state = DiscussionTreeState::default();
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// 3 collapsed headers.
assert_eq!(rows.len(), 3);
assert!(matches!(
rows[0],
VisualRow::DiscussionHeader {
expanded: false,
..
}
));
}
#[test]
fn test_compute_visual_rows_expanded() {
let discussions = sample_discussions();
let mut state = DiscussionTreeState::default();
state.expanded.insert("disc-1".into());
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// disc-1: header + note1 (header + body) + separator + note2 (header + body) = 6
// disc-2: 1 header
// disc-3: 1 header
// Total: 8
assert!(rows.len() >= 6);
assert!(matches!(
rows[0],
VisualRow::DiscussionHeader { expanded: true, .. }
));
}
#[test]
fn test_long_body_truncation() {
let long_body = (0..20)
.map(|i| format!("Line {i} of a very long discussion note"))
.collect::<Vec<_>>()
.join("\n");
let discussions = vec![DiscussionNode {
discussion_id: "long".into(),
notes: vec![sample_note("alice", &long_body, 1_700_000_000_000)],
resolvable: false,
resolved: false,
}];
let mut state = DiscussionTreeState::default();
state.expanded.insert("long".into());
let clock = test_clock();
let rows = compute_visual_rows_with_clock(&discussions, &state, 80, &clock);
// Header + note header + MAX_BODY_LINES + 1 ("...") = 1 + 1 + 10 + 1 = 13
let body_lines: Vec<_> = rows
.iter()
.filter(|r| matches!(r, VisualRow::BodyLine { .. }))
.collect();
// Should cap at MAX_BODY_LINES + 1 (for the "..." truncation line).
assert!(body_lines.len() <= MAX_BODY_LINES + 1);
}
#[test]
fn test_preview_multibyte_utf8_no_panic() {
// Emoji are 4 bytes each. Truncating at a byte boundary that falls
// inside a multi-byte char must not panic.
let disc = DiscussionNode {
discussion_id: "d-utf8".into(),
notes: vec![sample_note(
"alice",
"Hello 🌍🌎🌏 world of emoji 🎉🎊🎈",
0,
)],
resolvable: false,
resolved: false,
};
// max_chars=10 would land inside the first emoji's bytes.
let preview = disc.preview(10);
assert!(preview.ends_with("..."));
assert!(preview.len() <= 20); // char-bounded + "..."
// Edge: max_chars smaller than a single multi-byte char.
let disc2 = DiscussionNode {
discussion_id: "d-utf8-2".into(),
notes: vec![sample_note("bob", "🌍🌎🌏", 0)],
resolvable: false,
resolved: false,
};
let preview2 = disc2.preview(3);
assert!(preview2.ends_with("..."));
}
}

View 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);
}
}

View File

@@ -0,0 +1,132 @@
//! Floating error toast at bottom-right.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
/// Render a floating error toast at the bottom-right of the area.
///
/// The toast has a colored background and truncates long messages.
pub fn render_error_toast(
frame: &mut Frame<'_>,
area: Rect,
msg: &str,
error_bg: PackedRgba,
error_fg: PackedRgba,
) {
if area.height < 3 || area.width < 10 || msg.is_empty() {
return;
}
// Toast dimensions: message + padding, max 60 chars or half screen.
let max_toast_width = (area.width / 2).clamp(20, 60);
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
let trunc_len = max_toast_width.saturating_sub(7) as usize;
// Find a char boundary at or before trunc_len to avoid panicking
// on multi-byte UTF-8 (e.g., emoji or CJK in error messages).
let safe_end = msg
.char_indices()
.take_while(|&(i, _)| i <= trunc_len)
.last()
.map_or(0, |(i, c)| i + c.len_utf8())
.min(msg.len());
format!(" {}... ", &msg[..safe_end])
} else {
format!(" {msg} ")
};
let toast_width = toast_text.len() as u16;
let toast_height: u16 = 1;
// Position: bottom-right with 1-cell margin.
let x = area.right().saturating_sub(toast_width + 1);
let y = area.bottom().saturating_sub(toast_height + 1);
let toast_rect = Rect::new(x, y, toast_width, toast_height);
// Fill background.
let bg_cell = Cell {
bg: error_bg,
..Cell::default()
};
frame.draw_rect_filled(toast_rect, bg_cell);
// Render text.
let text_cell = Cell {
fg: error_fg,
bg: error_bg,
..Cell::default()
};
frame.print_text_clipped(x, y, &toast_text, text_cell, area.right());
}
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn red_bg() -> PackedRgba {
PackedRgba::rgb(0xFF, 0x00, 0x00)
}
#[test]
fn test_error_toast_renders() {
with_frame!(80, 24, |frame| {
render_error_toast(
&mut frame,
Rect::new(0, 0, 80, 24),
"Database is busy",
red_bg(),
white(),
);
let y = 22u16;
let has_content = (40..80u16).any(|x| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
});
assert!(has_content, "Expected error toast at bottom-right");
});
}
#[test]
fn test_error_toast_empty_message_noop() {
with_frame!(80, 24, |frame| {
render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white());
let has_content = (0..80u16).any(|x| {
(0..24u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(!has_content, "Empty message should render nothing");
});
}
#[test]
fn test_error_toast_truncates_long_message() {
with_frame!(80, 24, |frame| {
let long_msg = "A".repeat(200);
render_error_toast(
&mut frame,
Rect::new(0, 0, 80, 24),
&long_msg,
red_bg(),
white(),
);
});
}
}

View 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 田
}
}

View File

@@ -0,0 +1,173 @@
//! Centered modal listing keybindings for the current screen.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::commands::CommandRegistry;
use crate::message::Screen;
/// Render a centered help overlay listing keybindings for the current screen.
///
/// The overlay is a bordered modal that lists all commands from the
/// registry that are available on the current screen.
#[allow(clippy::too_many_arguments)]
pub fn render_help_overlay(
frame: &mut Frame<'_>,
area: Rect,
registry: &CommandRegistry,
screen: &Screen,
border_color: PackedRgba,
text_color: PackedRgba,
muted_color: PackedRgba,
scroll_offset: usize,
) {
if area.height < 5 || area.width < 20 {
return;
}
// Overlay dimensions: 60% of screen, capped.
let overlay_width = (area.width * 3 / 5).clamp(30, 70);
let overlay_height = (area.height * 3 / 5).clamp(8, 30);
let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2;
let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2;
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
// Draw border.
let border_cell = Cell {
fg: border_color,
..Cell::default()
};
frame.draw_border(
overlay_rect,
ftui::render::drawing::BorderChars::ROUNDED,
border_cell,
);
// Title.
let title = " Help (? to close) ";
let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2;
let title_cell = Cell {
fg: border_color,
..Cell::default()
};
frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right());
// Inner content area (inside border).
let inner = Rect::new(
overlay_x + 2,
overlay_y + 1,
overlay_width.saturating_sub(4),
overlay_height.saturating_sub(2),
);
// Get commands for this screen.
let commands = registry.help_entries(screen);
let visible_lines = inner.height as usize;
let key_cell = Cell {
fg: text_color,
..Cell::default()
};
let desc_cell = Cell {
fg: muted_color,
..Cell::default()
};
for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() {
if i >= visible_lines {
break;
}
let y = inner.y + i as u16;
// Key binding label (left).
let key_label = cmd
.keybinding
.as_ref()
.map_or_else(String::new, |kb| kb.display());
let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right());
// Spacer + description (right).
let desc_x = label_end.saturating_add(2);
if desc_x < inner.right() {
frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right());
}
}
// Scroll indicator if needed.
if commands.len() > visible_lines + scroll_offset {
let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len());
let ind_x = inner.right().saturating_sub(indicator.len() as u16);
let ind_y = overlay_rect.bottom().saturating_sub(1);
frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_help_overlay_renders_border() {
with_frame!(80, 24, |frame| {
let registry = build_registry();
render_help_overlay(
&mut frame,
Rect::new(0, 0, 80, 24),
&registry,
&Screen::Dashboard,
gray(),
white(),
gray(),
0,
);
// The overlay should have non-empty cells in the center area.
let has_content = (20..60u16).any(|x| {
(8..16u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(has_content, "Expected help overlay in center area");
});
}
#[test]
fn test_help_overlay_tiny_terminal_noop() {
with_frame!(15, 4, |frame| {
let registry = build_registry();
render_help_overlay(
&mut frame,
Rect::new(0, 0, 15, 4),
&registry,
&Screen::Dashboard,
gray(),
white(),
gray(),
0,
);
});
}
}

View File

@@ -0,0 +1,179 @@
//! Loading spinner indicators (full-screen and corner).
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::state::LoadState;
/// Braille spinner frames for loading animation.
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/// Select spinner frame from tick count.
#[must_use]
pub(crate) fn spinner_char(tick: u64) -> char {
SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()]
}
/// Render a loading indicator.
///
/// - `LoadingInitial`: centered full-screen spinner with "Loading..."
/// - `Refreshing`: subtle spinner in top-right corner
/// - Other states: no-op
pub fn render_loading(
frame: &mut Frame<'_>,
area: Rect,
load_state: &LoadState,
text_color: PackedRgba,
muted_color: PackedRgba,
tick: u64,
) {
match load_state {
LoadState::LoadingInitial => {
render_centered_spinner(frame, area, "Loading...", text_color, tick);
}
LoadState::Refreshing => {
render_corner_spinner(frame, area, muted_color, tick);
}
_ => {}
}
}
/// Render a centered spinner with message.
fn render_centered_spinner(
frame: &mut Frame<'_>,
area: Rect,
message: &str,
color: PackedRgba,
tick: u64,
) {
if area.height == 0 || area.width < 5 {
return;
}
let spinner = spinner_char(tick);
let text = format!("{spinner} {message}");
let text_len = text.len() as u16;
// Center horizontally and vertically.
let x = area
.x
.saturating_add(area.width.saturating_sub(text_len) / 2);
let y = area.y.saturating_add(area.height / 2);
let cell = Cell {
fg: color,
..Cell::default()
};
frame.print_text_clipped(x, y, &text, cell, area.right());
}
/// Render a subtle spinner in the top-right corner.
fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) {
if area.width < 2 || area.height == 0 {
return;
}
let spinner = spinner_char(tick);
let x = area.right().saturating_sub(2);
let y = area.y;
let cell = Cell {
fg: color,
..Cell::default()
};
frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right());
}
#[cfg(test)]
mod tests {
use super::*;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_loading_initial_renders_spinner() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::LoadingInitial,
white(),
gray(),
0,
);
let center_y = 12u16;
let has_content = (0..80u16).any(|x| {
let cell = frame.buffer.get(x, center_y).unwrap();
!cell.is_empty()
});
assert!(has_content, "Expected loading spinner at center row");
});
}
#[test]
fn test_loading_refreshing_renders_corner() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::Refreshing,
white(),
gray(),
0,
);
let cell = frame.buffer.get(78, 0).unwrap();
assert!(!cell.is_empty(), "Expected corner spinner");
});
}
#[test]
fn test_loading_idle_noop() {
with_frame!(80, 24, |frame| {
render_loading(
&mut frame,
Rect::new(0, 0, 80, 24),
&LoadState::Idle,
white(),
gray(),
0,
);
let has_content = (0..80u16).any(|x| {
(0..24u16).any(|y| {
let cell = frame.buffer.get(x, y).unwrap();
!cell.is_empty()
})
});
assert!(!has_content, "Idle state should render nothing");
});
}
#[test]
fn test_spinner_animation_cycles() {
let frame0 = spinner_char(0);
let frame1 = spinner_char(1);
let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64);
assert_ne!(frame0, frame1, "Adjacent frames should differ");
assert_eq!(frame0, frame_wrap, "Should wrap around");
}
}

View File

@@ -0,0 +1,28 @@
//! Common widgets shared across all TUI screens.
//!
//! Each widget is a pure rendering function — writes directly into the
//! [`Frame`] buffer using ftui's `Draw` trait. No state mutation,
//! no side effects.
mod breadcrumb;
pub mod cross_ref;
pub mod discussion_tree;
pub mod entity_table;
mod error_toast;
pub mod filter_bar;
mod help_overlay;
mod loading;
mod status_bar;
pub use breadcrumb::render_breadcrumb;
pub use cross_ref::{CrossRef, CrossRefColors, CrossRefKind, CrossRefState, render_cross_refs};
pub use discussion_tree::{
DiscussionNode, DiscussionTreeColors, DiscussionTreeState, NoteNode, format_relative_time,
render_discussion_tree,
};
pub use entity_table::{ColumnDef, EntityTableState, TableColors, TableRow, render_entity_table};
pub use error_toast::render_error_toast;
pub use filter_bar::{FilterBarColors, FilterBarState, render_filter_bar};
pub use help_overlay::render_help_overlay;
pub use loading::render_loading;
pub use status_bar::render_status_bar;

View File

@@ -0,0 +1,173 @@
//! Bottom status bar with key hints and mode indicator.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::commands::CommandRegistry;
use crate::message::{InputMode, Screen};
/// Render the bottom status bar with key hints and mode indicator.
///
/// Layout: `[mode] ─── [key hints]`
///
/// Key hints are sourced from the [`CommandRegistry`] filtered to the
/// current screen, showing only the most important bindings.
#[allow(clippy::too_many_arguments)]
pub fn render_status_bar(
frame: &mut Frame<'_>,
area: Rect,
registry: &CommandRegistry,
screen: &Screen,
mode: &InputMode,
bar_bg: PackedRgba,
text_color: PackedRgba,
accent_color: PackedRgba,
) {
if area.height == 0 || area.width < 5 {
return;
}
// Fill the bar background.
let bg_cell = Cell {
bg: bar_bg,
..Cell::default()
};
frame.draw_rect_filled(area, bg_cell);
let mode_label = match mode {
InputMode::Normal => "NORMAL",
InputMode::Text => "INPUT",
InputMode::Palette => "PALETTE",
InputMode::GoPrefix { .. } => "g...",
};
// Left side: mode indicator.
let mode_cell = Cell {
fg: accent_color,
bg: bar_bg,
..Cell::default()
};
let mut x = frame.print_text_clipped(
area.x.saturating_add(1),
area.y,
mode_label,
mode_cell,
area.right(),
);
// Spacer.
x = x.saturating_add(2);
// Right side: key hints from registry (formatted as "key:action").
let hints = registry.status_hints(screen);
let hint_cell = Cell {
fg: text_color,
bg: bar_bg,
..Cell::default()
};
let key_cell = Cell {
fg: accent_color,
bg: bar_bg,
..Cell::default()
};
for hint in &hints {
if x >= area.right().saturating_sub(1) {
break;
}
// Split "q:quit" into key part and description part.
if let Some((key_part, desc_part)) = hint.split_once(':') {
x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right());
x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right());
x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right());
} else {
x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right());
}
x = x.saturating_add(2);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::build_registry;
use crate::message::Screen;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn white() -> PackedRgba {
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
}
fn gray() -> PackedRgba {
PackedRgba::rgb(0x80, 0x80, 0x80)
}
#[test]
fn test_status_bar_renders_mode() {
with_frame!(80, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 80, 1),
&registry,
&Screen::Dashboard,
&InputMode::Normal,
gray(),
white(),
white(),
);
let n_cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(n_cell.content.as_char(), Some('N'));
});
}
#[test]
fn test_status_bar_text_mode() {
with_frame!(80, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 80, 1),
&registry,
&Screen::Search,
&InputMode::Text,
gray(),
white(),
white(),
);
let i_cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(i_cell.content.as_char(), Some('I'));
});
}
#[test]
fn test_status_bar_narrow_terminal() {
with_frame!(4, 1, |frame| {
let registry = build_registry();
render_status_bar(
&mut frame,
Rect::new(0, 0, 4, 1),
&registry,
&Screen::Dashboard,
&InputMode::Normal,
gray(),
white(),
white(),
);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.is_empty());
});
}
}

View 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, 90119): 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)");
});
}
}

View File

@@ -0,0 +1,626 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Issue detail screen view.
//!
//! Composes metadata header, description, discussion tree, and
//! cross-references into a scrollable detail layout. Supports
//! progressive hydration: metadata renders immediately while
//! discussions load async in Phase 2.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette — will use injected Theme in a later phase)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F); // bg
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
// ---------------------------------------------------------------------------
// Color constructors
// ---------------------------------------------------------------------------
fn discussion_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: CYAN,
timestamp_fg: TEXT_MUTED,
body_fg: TEXT,
system_fg: TEXT_MUTED,
diff_path_fg: GREEN,
resolved_fg: TEXT_MUTED,
guide_fg: BORDER,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
expand_fg: ACCENT,
}
}
fn cross_ref_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: ACCENT,
label_fg: TEXT,
muted_fg: TEXT_MUTED,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full issue detail screen.
///
/// Layout:
/// ```text
/// Row 0: #42 Fix authentication flow (title bar)
/// Row 1: opened | alice | backend, security (metadata row)
/// Row 2: Milestone: v1.0 | Due: 2026-03-01 (optional)
/// Row 3: ─────────────────────────────────── (separator)
/// Row 4..N: Description text... (scrollable)
/// ─────────────────────────────────── (separator)
/// Discussions (3) (section header)
/// ▶ alice: Fixed the login flow... (collapsed)
/// ▼ bob: I think we should also... (expanded)
/// bob: body line 1...
/// ─────────────────────────────────── (separator)
/// Cross References (section header)
/// [MR] !10 Fix authentication MR
/// ```
pub fn render_issue_detail(
frame: &mut Frame<'_>,
state: &IssueDetailState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 3 || area.width < 10 {
return;
}
let Some(ref meta) = state.metadata else {
// No metadata yet — the loading spinner handles this.
return;
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
// --- Title bar ---
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x);
// --- Optional milestone / due date row ---
if meta.milestone.is_some() || meta.due_date.is_some() {
y = render_milestone_row(frame, meta, area.x, y, max_x);
}
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
let bottom = area.y.saturating_add(area.height);
if y >= bottom {
return;
}
// Remaining space is split between description, discussions, and cross-refs.
let remaining = bottom.saturating_sub(y);
// Compute section heights based on content.
let desc_lines = count_description_lines(meta, area.width);
let disc_count = state.discussions.len();
let xref_count = state.cross_refs.len();
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count);
// --- Description section ---
if desc_h > 0 {
let desc_area = Rect::new(area.x, y, area.width, desc_h);
let is_focused = state.active_section == DetailSection::Description;
render_description(frame, meta, state.description_scroll, desc_area, is_focused);
y += desc_h;
}
// --- Separator before discussions ---
if (disc_h > 0 || xref_h > 0) && y < bottom {
y = render_separator(frame, area.x, y, area.width);
}
// --- Discussions section ---
if disc_h > 0 && y < bottom {
let header_h = 1;
let is_focused = state.active_section == DetailSection::Discussions;
// Section header.
render_section_header(
frame,
&format!("Discussions ({})", state.discussions.len()),
area.x,
y,
max_x,
is_focused,
);
y += header_h;
if !state.discussions_loaded {
// Still loading.
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "Loading discussions...", style, max_x);
y += 1;
} else if state.discussions.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No discussions", style, max_x);
y += 1;
} else {
let tree_height = disc_h.saturating_sub(header_h);
if tree_height > 0 {
let tree_area = Rect::new(area.x, y, area.width, tree_height);
let rendered = render_discussion_tree(
frame,
&state.discussions,
&state.tree_state,
tree_area,
&discussion_colors(),
clock,
);
y += rendered;
}
}
}
// --- Separator before cross-refs ---
if xref_h > 0 && y < bottom {
y = render_separator(frame, area.x, y, area.width);
}
// --- Cross-references section ---
if xref_h > 0 && y < bottom {
let is_focused = state.active_section == DetailSection::CrossRefs;
render_section_header(
frame,
&format!("Cross References ({})", state.cross_refs.len()),
area.x,
y,
max_x,
is_focused,
);
y += 1;
if state.cross_refs.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No cross-references", style, max_x);
} else {
let refs_height = xref_h.saturating_sub(1); // minus header
if refs_height > 0 {
let refs_area = Rect::new(area.x, y, area.width, refs_height);
let _ = render_cross_refs(
frame,
&state.cross_refs,
&state.cross_ref_state,
refs_area,
&cross_ref_colors(),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Sub-renderers
// ---------------------------------------------------------------------------
/// Render the issue title bar: `#42 Fix authentication flow`
fn render_title_bar(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let iid_text = format!("#{} ", meta.iid);
let iid_style = Cell {
fg: ACCENT,
..Cell::default()
};
let title_style = Cell {
fg: TEXT,
..Cell::default()
};
let cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
y + 1
}
/// Render the metadata row: `opened | alice | backend, security`
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
"closed" => RED,
_ => TEXT_MUTED,
};
let state_style = Cell {
fg: state_fg,
..Cell::default()
};
let muted_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
if !meta.labels.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let labels_text = meta.labels.join(", ");
let _ = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
}
if !meta.assignees.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let assignees_text = format!("-> {}", meta.assignees.join(", "));
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x);
}
y + 1
}
/// Render optional milestone / due date row.
fn render_milestone_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let mut cx = x;
if let Some(ref ms) = meta.milestone {
cx = frame.print_text_clipped(cx, y, "Milestone: ", muted, max_x);
let val_style = Cell {
fg: TEXT,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, ms, val_style, max_x);
}
if let Some(ref due) = meta.due_date {
if cx > x {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
}
cx = frame.print_text_clipped(cx, y, "Due: ", muted, max_x);
let val_style = Cell {
fg: TEXT,
..Cell::default()
};
let _ = frame.print_text_clipped(cx, y, due, val_style, max_x);
}
y + 1
}
/// Render a horizontal separator line.
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
let sep_style = Cell {
fg: BORDER,
..Cell::default()
};
let line: String = "\u{2500}".repeat(width as usize);
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
y + 1
}
/// Render a section header with focus indicator.
fn render_section_header(
frame: &mut Frame<'_>,
label: &str,
x: u16,
y: u16,
max_x: u16,
is_focused: bool,
) {
if is_focused {
let style = Cell {
fg: SELECTED_FG,
bg: SELECTED_BG,
..Cell::default()
};
// Fill the row with selected background.
frame.draw_rect_filled(Rect::new(x, y, max_x.saturating_sub(x), 1), style);
let _ = frame.print_text_clipped(x, y, label, style, max_x);
} else {
let style = Cell {
fg: ACCENT,
..Cell::default()
};
let _ = frame.print_text_clipped(x, y, label, style, max_x);
}
}
/// Render the description section.
fn render_description(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
scroll: usize,
area: Rect,
_is_focused: bool,
) {
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
let lines: Vec<&str> = safe_desc.lines().collect();
let text_style = Cell {
fg: TEXT,
..Cell::default()
};
let max_x = area.x.saturating_add(area.width);
for (i, line) in lines
.iter()
.skip(scroll)
.take(area.height as usize)
.enumerate()
{
let y = area.y + i as u16;
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
}
}
/// Count the number of visible description lines for layout allocation.
fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize {
if meta.description.is_empty() {
return 0;
}
// Rough estimate: count newlines. Proper word-wrap would need unicode width.
meta.description.lines().count().max(1)
}
/// Allocate vertical space between description, discussions, and cross-refs.
///
/// Priority: description gets min(content, 40%), discussions get most of the
/// remaining space, cross-refs get a fixed portion at the bottom.
fn allocate_sections(
available: u16,
desc_lines: usize,
_disc_count: usize,
xref_count: usize,
) -> (u16, u16, u16) {
if available == 0 {
return (0, 0, 0);
}
let total = available as usize;
// Cross-refs: 1 header + count, max 25% of space.
let xref_need = if xref_count > 0 {
(1 + xref_count).min(total / 4)
} else {
0
};
let after_xref = total.saturating_sub(xref_need);
// Description: up to 40% of remaining, but at least the content lines.
let desc_max = after_xref * 2 / 5;
let desc_alloc = desc_lines.min(desc_max).min(after_xref);
// Discussions: everything else.
let disc_alloc = after_xref.saturating_sub(desc_alloc);
(desc_alloc as u16, disc_alloc as u16, xref_need as u16)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::EntityKey;
use crate::state::issue_detail::{IssueDetailData, IssueMetadata};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_metadata() -> IssueMetadata {
IssueMetadata {
iid: 42,
project_path: "group/project".into(),
title: "Fix authentication flow".into(),
description: "The login page has a bug.\nSteps to reproduce:\n1. Go to /login\n2. Enter credentials\n3. Click submit".into(),
state: "opened".into(),
author: "alice".into(),
assignees: vec!["bob".into()],
labels: vec!["backend".into(), "security".into()],
milestone: Some("v1.0".into()),
due_date: Some("2026-03-01".into()),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
web_url: "https://gitlab.com/group/project/-/issues/42".into(),
discussion_count: 2,
}
}
fn sample_state_with_metadata() -> IssueDetailState {
let mut state = IssueDetailState::default();
state.load_new(EntityKey::issue(1, 42));
state.apply_metadata(IssueDetailData {
metadata: sample_metadata(),
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::mr(1, 10),
label: "Fix auth MR".into(),
navigable: true,
}],
});
state
}
#[test]
fn test_render_issue_detail_no_metadata_no_panic() {
with_frame!(80, 24, |frame| {
let state = IssueDetailState::default();
let clock = FakeClock::from_ms(1_700_000_000_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_with_metadata_no_panic() {
with_frame!(80, 24, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_tiny_area() {
with_frame!(5, 2, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 5, 2), &clock);
// Should bail early, no panic.
});
}
#[test]
fn test_render_issue_detail_with_discussions() {
with_frame!(80, 40, |frame| {
let mut state = sample_state_with_metadata();
state.apply_discussions(vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![NoteNode {
author: "alice".into(),
body: "I found the bug".into(),
created_at: 1_700_000_000_000,
is_system: false,
is_diff_note: false,
diff_file_path: None,
diff_new_line: None,
}],
resolvable: false,
resolved: false,
}]);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 40), &clock);
});
}
#[test]
fn test_render_issue_detail_discussions_loading() {
with_frame!(80, 24, |frame| {
let state = sample_state_with_metadata();
// discussions_loaded is false by default after load_new.
assert!(!state.discussions_loaded);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_issue_detail_narrow_terminal() {
with_frame!(30, 10, |frame| {
let state = sample_state_with_metadata();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
});
}
#[test]
fn test_allocate_sections_empty() {
assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0));
}
#[test]
fn test_allocate_sections_balanced() {
let (d, disc, x) = allocate_sections(20, 5, 3, 2);
assert!(d > 0);
assert!(disc > 0);
assert!(x > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_allocate_sections_no_xrefs() {
let (d, disc, x) = allocate_sections(20, 5, 3, 0);
assert_eq!(x, 0);
assert_eq!(d + disc, 20);
}
#[test]
fn test_allocate_sections_no_discussions() {
let (d, disc, x) = allocate_sections(20, 5, 0, 2);
assert!(d > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_count_description_lines() {
let meta = sample_metadata();
let lines = count_description_lines(&meta, 80);
assert_eq!(lines, 5); // 5 lines in the sample description
}
#[test]
fn test_count_description_lines_empty() {
let mut meta = sample_metadata();
meta.description = String::new();
assert_eq!(count_description_lines(&meta, 80), 0);
}
}

View 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);
}
}

View File

@@ -0,0 +1,202 @@
#![allow(dead_code)] // Phase 1: screen content renders added in Phase 2+
//! Top-level view dispatch for the lore TUI.
//!
//! [`render_screen`] is the entry point called from `LoreApp::view()`.
//! It composes the layout: breadcrumb bar, screen content area, status
//! bar, and optional overlays (help, error toast).
pub mod common;
pub mod dashboard;
pub mod issue_detail;
pub mod issue_list;
pub mod mr_detail;
pub mod mr_list;
use ftui::layout::{Constraint, Flex};
use ftui::render::cell::PackedRgba;
use ftui::render::frame::Frame;
use crate::app::LoreApp;
use crate::message::Screen;
use common::{
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
};
use dashboard::render_dashboard;
use issue_detail::render_issue_detail;
use issue_list::render_issue_list;
use mr_detail::render_mr_detail;
use mr_list::render_mr_list;
// ---------------------------------------------------------------------------
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
// ---------------------------------------------------------------------------
// render_screen
// ---------------------------------------------------------------------------
/// Top-level view dispatch: composes breadcrumb + content + status bar + overlays.
///
/// Called from `LoreApp::view()`. The layout is:
/// ```text
/// +-----------------------------------+
/// | Breadcrumb (1 row) |
/// +-----------------------------------+
/// | |
/// | Screen content (fill) |
/// | |
/// +-----------------------------------+
/// | Status bar (1 row) |
/// +-----------------------------------+
/// ```
///
/// Overlays (help, error toast) render on top of existing content.
pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
let bounds = frame.bounds();
if bounds.width < 3 || bounds.height < 3 {
return; // Terminal too small to render anything useful.
}
// Split vertically: breadcrumb (1) | content (fill) | status bar (1).
let regions = Flex::vertical()
.constraints([
Constraint::Fixed(1), // breadcrumb
Constraint::Fill, // content
Constraint::Fixed(1), // status bar
])
.split(bounds);
let breadcrumb_area = regions[0];
let content_area = regions[1];
let status_area = regions[2];
let screen = app.navigation.current();
// --- Breadcrumb ---
render_breadcrumb(frame, breadcrumb_area, &app.navigation, TEXT, TEXT_MUTED);
// --- Screen content ---
let load_state = app.state.load_state.get(screen);
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
// Per-screen content dispatch (other screens wired in later phases).
if screen == &Screen::Dashboard {
render_dashboard(frame, &app.state.dashboard, content_area);
} else if screen == &Screen::IssueList {
render_issue_list(frame, &app.state.issue_list, content_area);
} else if screen == &Screen::MrList {
render_mr_list(frame, &app.state.mr_list, content_area);
} else if matches!(screen, Screen::IssueDetail(_)) {
render_issue_detail(frame, &app.state.issue_detail, content_area, &*app.clock);
} else if matches!(screen, Screen::MrDetail(_)) {
render_mr_detail(frame, &app.state.mr_detail, content_area, &*app.clock);
}
// --- Status bar ---
render_status_bar(
frame,
status_area,
&app.command_registry,
screen,
&app.input_mode,
BG_SURFACE,
TEXT,
ACCENT,
);
// --- Overlays (render last, on top of everything) ---
// Error toast.
if let Some(ref error_msg) = app.state.error_toast {
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
}
// Help overlay.
if app.state.show_help {
render_help_overlay(
frame,
bounds,
&app.command_registry,
screen,
BORDER,
TEXT,
TEXT_MUTED,
0, // scroll_offset — tracked in future phase
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::app::LoreApp;
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
#[test]
fn test_render_screen_does_not_panic() {
with_frame!(80, 24, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
});
}
#[test]
fn test_render_screen_tiny_terminal_noop() {
with_frame!(2, 2, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
// Should not panic — early return for tiny terminals.
});
}
#[test]
fn test_render_screen_with_error_toast() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.state.set_error("test error".into());
render_screen(&mut frame, &app);
// Should render without panicking.
});
}
#[test]
fn test_render_screen_with_help_overlay() {
with_frame!(80, 24, |frame| {
let mut app = LoreApp::new();
app.state.show_help = true;
render_screen(&mut frame, &app);
// Should render without panicking.
});
}
#[test]
fn test_render_screen_narrow_terminal() {
with_frame!(20, 5, |frame| {
let app = LoreApp::new();
render_screen(&mut frame, &app);
});
}
}

View File

@@ -0,0 +1,635 @@
#![allow(dead_code)] // Phase 2: consumed by view/mod.rs screen dispatch
//! Merge request detail screen view.
//!
//! Composes metadata header, tab bar (Overview / Files / Discussions),
//! and tab content. Supports progressive hydration: metadata + file
//! changes render immediately while discussions load async.
use ftui::core::geometry::Rect;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
use crate::view::common::discussion_tree::{DiscussionTreeColors, render_discussion_tree};
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C);
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39);
const RED: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29);
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F);
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15);
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80);
const SELECTED_FG: PackedRgba = PackedRgba::rgb(0x10, 0x0F, 0x0F);
const SELECTED_BG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3);
// ---------------------------------------------------------------------------
// Color constructors
// ---------------------------------------------------------------------------
fn discussion_colors() -> DiscussionTreeColors {
DiscussionTreeColors {
author_fg: CYAN,
timestamp_fg: TEXT_MUTED,
body_fg: TEXT,
system_fg: TEXT_MUTED,
diff_path_fg: GREEN,
resolved_fg: TEXT_MUTED,
guide_fg: BORDER,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
expand_fg: ACCENT,
}
}
fn cross_ref_colors() -> CrossRefColors {
CrossRefColors {
kind_fg: ACCENT,
label_fg: TEXT,
muted_fg: TEXT_MUTED,
selected_fg: SELECTED_FG,
selected_bg: SELECTED_BG,
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
/// Render the full MR detail screen.
///
/// Layout:
/// ```text
/// Row 0: !10 Fix auth flow (title bar)
/// Row 1: opened | alice | fix-auth -> main (metadata row)
/// Row 2: [Overview] [Files (3)] [Discussions] (tab bar)
/// Row 3: ──────────────────────────────────── (separator)
/// Row 4..N: Tab-specific content
/// ```
pub fn render_mr_detail(
frame: &mut Frame<'_>,
state: &MrDetailState,
area: Rect,
clock: &dyn Clock,
) {
if area.height < 4 || area.width < 10 {
return;
}
let Some(ref meta) = state.metadata else {
return;
};
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
// --- Title bar ---
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x);
// --- Tab bar ---
y = render_tab_bar(frame, state, area.x, y, max_x);
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
let bottom = area.y.saturating_add(area.height);
if y >= bottom {
return;
}
let content_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
match state.active_tab {
MrTab::Overview => render_overview_tab(frame, state, meta, content_area, clock),
MrTab::Files => render_files_tab(frame, state, content_area),
MrTab::Discussions => render_discussions_tab(frame, state, content_area, clock),
}
}
// ---------------------------------------------------------------------------
// Sub-renderers
// ---------------------------------------------------------------------------
/// Render `!10 Fix auth flow` (or `!10 [Draft] Fix auth flow`).
fn render_title_bar(frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, max_x: u16) -> u16 {
let iid_text = format!("!{} ", meta.iid);
let iid_style = Cell {
fg: ACCENT,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &iid_text, iid_style, max_x);
if meta.draft {
let draft_style = Cell {
fg: YELLOW,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, "[Draft] ", draft_style, max_x);
}
let title_style = Cell {
fg: TEXT,
..Cell::default()
};
let safe_title = sanitize_for_terminal(&meta.title, UrlPolicy::Strip);
let _ = frame.print_text_clipped(cx, y, &safe_title, title_style, max_x);
y + 1
}
/// Render `opened | alice | fix-auth -> main | mergeable`.
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &MrMetadata,
x: u16,
y: u16,
max_x: u16,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
"merged" => CYAN,
"closed" => RED,
_ => TEXT_MUTED,
};
let state_style = Cell {
fg: state_fg,
..Cell::default()
};
let muted = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let author_style = Cell {
fg: CYAN,
..Cell::default()
};
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x);
if !meta.merge_status.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let status_fg = if meta.merge_status == "mergeable" {
GREEN
} else {
YELLOW
};
let status_style = Cell {
fg: status_fg,
..Cell::default()
};
let _ = frame.print_text_clipped(cx, y, &meta.merge_status, status_style, max_x);
}
y + 1
}
/// Render tab bar: `[Overview] [Files (3)] [Discussions (2)]`.
fn render_tab_bar(frame: &mut Frame<'_>, state: &MrDetailState, x: u16, y: u16, max_x: u16) -> u16 {
let tabs = [
(MrTab::Overview, "Overview".to_string()),
(
MrTab::Files,
format!("Files ({})", state.file_changes.len()),
),
(
MrTab::Discussions,
format!("Discussions ({})", state.discussions.len()),
),
];
let mut cx = x;
for (tab, label) in &tabs {
if *tab == state.active_tab {
let style = Cell {
fg: SELECTED_FG,
bg: SELECTED_BG,
..Cell::default()
};
let text = format!(" {label} ");
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
} else {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let text = format!(" {label} ");
cx = frame.print_text_clipped(cx, y, &text, style, max_x);
}
// Tab separator.
let sep = Cell {
fg: BORDER,
..Cell::default()
};
cx = frame.print_text_clipped(cx, y, " ", sep, max_x);
}
y + 1
}
/// Render horizontal separator.
fn render_separator(frame: &mut Frame<'_>, x: u16, y: u16, width: u16) -> u16 {
let sep_style = Cell {
fg: BORDER,
..Cell::default()
};
let line: String = "\u{2500}".repeat(width as usize);
let _ = frame.print_text_clipped(x, y, &line, sep_style, x.saturating_add(width));
y + 1
}
// ---------------------------------------------------------------------------
// Tab content renderers
// ---------------------------------------------------------------------------
/// Overview tab: description + cross-references.
fn render_overview_tab(
frame: &mut Frame<'_>,
state: &MrDetailState,
meta: &MrMetadata,
area: Rect,
_clock: &dyn Clock,
) {
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
// --- Description ---
let safe_desc = sanitize_for_terminal(&meta.description, UrlPolicy::Strip);
let lines: Vec<&str> = safe_desc.lines().collect();
let text_style = Cell {
fg: TEXT,
..Cell::default()
};
for line in lines
.iter()
.skip(state.description_scroll)
.take((bottom.saturating_sub(y)) as usize)
{
let _ = frame.print_text_clipped(area.x, y, line, text_style, max_x);
y += 1;
}
if y >= bottom {
return;
}
// --- Separator ---
y = render_separator(frame, area.x, y, area.width);
if y >= bottom {
return;
}
// --- Cross-references ---
if !state.cross_refs.is_empty() {
let header_style = Cell {
fg: ACCENT,
..Cell::default()
};
let header = format!("Cross References ({})", state.cross_refs.len());
let _ = frame.print_text_clipped(area.x, y, &header, header_style, max_x);
y += 1;
if y < bottom {
let refs_area = Rect::new(area.x, y, area.width, bottom.saturating_sub(y));
let _ = render_cross_refs(
frame,
&state.cross_refs,
&state.cross_ref_state,
refs_area,
&cross_ref_colors(),
);
}
}
}
/// Files tab: list of changed files with change type indicators.
fn render_files_tab(frame: &mut Frame<'_>, state: &MrDetailState, area: Rect) {
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
let bottom = area.y.saturating_add(area.height);
if state.file_changes.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, y, "No file changes", style, max_x);
return;
}
for (i, fc) in state
.file_changes
.iter()
.skip(state.file_scroll)
.take((bottom.saturating_sub(y)) as usize)
.enumerate()
{
let is_selected = i + state.file_scroll == state.file_selected;
let (fg, bg) = if is_selected {
(SELECTED_FG, SELECTED_BG)
} else {
(TEXT, PackedRgba::TRANSPARENT)
};
if is_selected {
let sel_cell = Cell {
fg,
bg,
..Cell::default()
};
frame.draw_rect_filled(Rect::new(area.x, y, area.width, 1), sel_cell);
}
// Change type icon.
let icon_fg = match fc.change_type {
FileChangeType::Added => GREEN,
FileChangeType::Deleted => RED,
FileChangeType::Modified => YELLOW,
FileChangeType::Renamed => CYAN,
};
let icon_style = Cell {
fg: if is_selected { fg } else { icon_fg },
bg,
..Cell::default()
};
let mut cx = frame.print_text_clipped(area.x, y, fc.change_type.icon(), icon_style, max_x);
cx = frame.print_text_clipped(cx, y, " ", icon_style, max_x);
// File path.
let path_style = Cell {
fg,
bg,
..Cell::default()
};
let display_path = if fc.change_type == FileChangeType::Renamed {
if let Some(ref old) = fc.old_path {
format!("{old} -> {}", fc.new_path)
} else {
fc.new_path.clone()
}
} else {
fc.new_path.clone()
};
let _ = frame.print_text_clipped(cx, y, &display_path, path_style, max_x);
y += 1;
}
}
/// Discussions tab: all discussions rendered via the tree widget.
fn render_discussions_tab(
frame: &mut Frame<'_>,
state: &MrDetailState,
area: Rect,
clock: &dyn Clock,
) {
let max_x = area.x.saturating_add(area.width);
if !state.discussions_loaded {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ =
frame.print_text_clipped(area.x + 1, area.y, "Loading discussions...", style, max_x);
return;
}
if state.discussions.is_empty() {
let style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let _ = frame.print_text_clipped(area.x + 1, area.y, "No discussions", style, max_x);
return;
}
let _ = render_discussion_tree(
frame,
&state.discussions,
&state.tree_state,
area,
&discussion_colors(),
clock,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::FakeClock;
use crate::message::EntityKey;
use crate::state::mr_detail::{FileChange, FileChangeType, MrDetailData, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRef, CrossRefKind};
use crate::view::common::discussion_tree::{DiscussionNode, NoteNode};
use ftui::render::grapheme_pool::GraphemePool;
macro_rules! with_frame {
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
let mut pool = GraphemePool::new();
let mut $frame = Frame::new($width, $height, &mut pool);
$body
}};
}
fn sample_mr_metadata() -> MrMetadata {
MrMetadata {
iid: 10,
project_path: "group/project".into(),
title: "Fix authentication flow".into(),
description: "This MR fixes the login bug.\nSee issue #42.".into(),
state: "opened".into(),
draft: false,
author: "alice".into(),
assignees: vec!["bob".into()],
reviewers: vec!["carol".into()],
labels: vec!["backend".into()],
source_branch: "fix-auth".into(),
target_branch: "main".into(),
merge_status: "mergeable".into(),
created_at: 1_700_000_000_000,
updated_at: 1_700_000_060_000,
merged_at: None,
web_url: "https://gitlab.com/group/project/-/merge_requests/10".into(),
discussion_count: 1,
file_change_count: 2,
}
}
fn sample_mr_state() -> MrDetailState {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![CrossRef {
kind: CrossRefKind::ClosingMr,
entity_key: EntityKey::issue(1, 42),
label: "Auth bug".into(),
navigable: true,
}],
file_changes: vec![
FileChange {
old_path: None,
new_path: "src/auth.rs".into(),
change_type: FileChangeType::Modified,
},
FileChange {
old_path: None,
new_path: "src/lib.rs".into(),
change_type: FileChangeType::Added,
},
],
});
state
}
#[test]
fn test_render_mr_detail_no_metadata() {
with_frame!(80, 24, |frame| {
let state = MrDetailState::default();
let clock = FakeClock::from_ms(1_700_000_000_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_overview_tab() {
with_frame!(80, 24, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_files_tab() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_discussions_tab_loading() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Discussions;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_discussions_tab_with_data() {
with_frame!(80, 30, |frame| {
let mut state = sample_mr_state();
state.active_tab = MrTab::Discussions;
state.apply_discussions(vec![DiscussionNode {
discussion_id: "d1".into(),
notes: vec![NoteNode {
author: "alice".into(),
body: "Looks good".into(),
created_at: 1_700_000_020_000,
is_system: false,
is_diff_note: true,
diff_file_path: Some("src/auth.rs".into()),
diff_new_line: Some(42),
}],
resolvable: true,
resolved: false,
}]);
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 30), &clock);
});
}
#[test]
fn test_render_mr_detail_draft() {
with_frame!(80, 24, |frame| {
let mut state = sample_mr_state();
state.metadata.as_mut().unwrap().draft = true;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_tiny_area() {
with_frame!(5, 3, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 5, 3), &clock);
});
}
#[test]
fn test_render_mr_detail_narrow_terminal() {
with_frame!(30, 10, |frame| {
let state = sample_mr_state();
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 30, 10), &clock);
});
}
#[test]
fn test_render_files_empty() {
with_frame!(80, 24, |frame| {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![],
file_changes: vec![],
});
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_files_with_rename() {
with_frame!(80, 24, |frame| {
let mut state = MrDetailState::default();
state.load_new(EntityKey::mr(1, 10));
state.apply_metadata(MrDetailData {
metadata: sample_mr_metadata(),
cross_refs: vec![],
file_changes: vec![FileChange {
old_path: Some("src/old.rs".into()),
new_path: "src/new.rs".into(),
change_type: FileChangeType::Renamed,
}],
});
state.active_tab = MrTab::Files;
let clock = FakeClock::from_ms(1_700_000_060_000);
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
}

View 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);
}
}

View File

@@ -0,0 +1,140 @@
Your iteration 4 plan is already strong. The highest-impact revisions are around query shape, transaction boundaries, and contract stability for agents.
1. **Switch discussions query to a two-phase page-first architecture**
Analysis: Current `ranked_notes` runs over every filtered discussion before `LIMIT`, which can explode on project-wide queries. A page-first plan keeps complexity proportional to `limit`, improves tail latency, and reduces memory churn.
```diff
@@ ## 3c. SQL Query
-Core query uses a CTE + ranked-notes rollup (window function) to avoid per-row correlated
-subqueries.
+Core query is split into two phases for scalability:
+1) `paged_discussions` applies filters/sort/LIMIT and returns only page IDs.
+2) Note rollups and optional `--include-notes` expansion run only for those page IDs.
+This bounds note scanning to visible results and stabilizes latency on large projects.
-WITH filtered_discussions AS (
+WITH filtered_discussions AS (
...
),
-ranked_notes AS (
+paged_discussions AS (
+ SELECT id
+ FROM filtered_discussions
+ ORDER BY COALESCE({sort_column}, 0) {order}, id {order}
+ LIMIT ?
+),
+ranked_notes AS (
...
- WHERE n.discussion_id IN (SELECT id FROM filtered_discussions)
+ WHERE n.discussion_id IN (SELECT id FROM paged_discussions)
)
```
2. **Move snapshot transaction ownership to handlers (not query helpers)**
Analysis: This avoids nested transaction edge cases, keeps function signatures clean, and guarantees one snapshot across count + page + include-notes + serialization metadata.
```diff
@@ ## Cross-cutting: snapshot consistency
-Wrap `query_notes` and `query_discussions` in a deferred read transaction.
+Open one deferred read transaction in each handler (`handle_notes`, `handle_discussions`)
+and pass `&Transaction` into query helpers. Query helpers do not open/commit transactions.
+This guarantees a single snapshot across all subqueries and avoids nested tx pitfalls.
-pub fn query_discussions(conn: &Connection, ...)
+pub fn query_discussions(tx: &rusqlite::Transaction<'_>, ...)
```
3. **Add immutable input filter `--project-id` across notes/discussions/show**
Analysis: You already expose `gitlab_project_id` because paths are mutable; input should support the same immutable selector. This removes failure modes after project renames/transfers.
```diff
@@ ## 3a. CLI Args
+ /// Filter by immutable GitLab project ID
+ #[arg(long, help_heading = "Filters", conflicts_with = "project")]
+ pub project_id: Option<i64>,
@@ ## Bridge Contract
+Input symmetry rule: commands that accept `--project` should also accept `--project-id`.
+If both are present, return usage error (exit code 2).
```
4. **Enforce bridge fields for nested notes in `discussions --include-notes`**
Analysis: Current guardrail is entity-level; nested notes can still lose required IDs under aggressive filtering. This is a contract hole for write-bridging.
```diff
@@ ### Field Filtering Guardrail
-In robot mode, `filter_fields` MUST force-include Bridge Contract fields...
+In robot mode, `filter_fields` MUST force-include Bridge Contract fields at all returned levels:
+- discussion row fields
+- nested note fields when `discussions --include-notes` is used
+const BRIDGE_FIELDS_DISCUSSION_NOTES: &[&str] = &[
+ "project_path", "gitlab_project_id", "noteable_type", "parent_iid",
+ "gitlab_discussion_id", "gitlab_note_id",
+];
```
5. **Make ambiguity preflight scope-aware and machine-actionable**
Analysis: Current preflight checks only `gitlab_discussion_id`, which can produce false ambiguity when additional filters already narrow to one project. Also, agents need structured candidates, not only free-text.
```diff
@@ ### Ambiguity Guardrail
-SELECT DISTINCT p.path_with_namespace
+SELECT DISTINCT p.path_with_namespace, p.gitlab_project_id
FROM discussions d
JOIN projects p ON p.id = d.project_id
-WHERE d.gitlab_discussion_id = ?
+WHERE d.gitlab_discussion_id = ?
+ /* plus active scope filters: noteable_type, for_issue/for_mr, since/path when present */
LIMIT 3
-Return LoreError::Ambiguous with message
+Return LoreError::Ambiguous with structured details:
+`{ code, message, candidates:[{project_path, gitlab_project_id}], suggestion }`
```
6. **Add `--contains` filter to `discussions`**
Analysis: This is a high-utility agent workflow gap. Agents frequently need “find thread by text then reply”; forcing a separate `notes` search round-trip is unnecessary.
```diff
@@ ## 3a. CLI Args
+ /// Filter discussions whose notes contain text
+ #[arg(long, help_heading = "Filters")]
+ pub contains: Option<String>,
@@ ## 3d. Filters struct
+ pub contains: Option<String>,
@@ ## 3d. Where-clause construction
+- `path` -> EXISTS (...)
+- `path` -> EXISTS (...)
+- `contains` -> EXISTS (
+ SELECT 1 FROM notes n
+ WHERE n.discussion_id = d.id
+ AND n.body LIKE ?
+ )
```
7. **Promote two baseline indexes from “candidate” to “required”**
Analysis: These are directly hit by new primary paths; waiting for post-merge profiling risks immediate perf cliffs in real usage.
```diff
@@ ## 3h. Query-plan validation
-Candidate indexes (add only if EXPLAIN QUERY PLAN shows they're needed):
-- discussions(project_id, gitlab_discussion_id)
-- notes(discussion_id, created_at DESC, id DESC)
+Required baseline indexes for this feature:
+- discussions(project_id, gitlab_discussion_id)
+- notes(discussion_id, created_at DESC, id DESC)
+Keep other indexes conditional on EXPLAIN QUERY PLAN.
```
8. **Add schema versioning and remove contradictory rejected items**
Analysis: `robot-docs` contract drift is a long-term agent risk; explicit schema versions let clients fail safely. Also, rejected items currently contradict active sections, which creates implementation ambiguity.
```diff
@@ ## 4. Fix Robot-Docs Response Schemas
"meta": {"elapsed_ms": "int", ...}
+"meta": {"elapsed_ms":"int", ..., "schema_version":"string"}
+
+Schema version policy:
+- bump minor on additive fields
+- bump major on removals/renames
+- expose per-command versions in `robot-docs`
@@ ## Rejected Recommendations
-- Add `gitlab_note_id` to show-command note detail structs ... rejected ...
-- Add `gitlab_discussion_id` to show-command discussion detail structs ... rejected ...
-- Add `gitlab_project_id` to show-command discussion detail structs ... rejected ...
+Remove stale rejected entries that conflict with accepted workstreams in this plan iteration.
```
If you want, I can produce a fully rewritten iteration 5 plan document that applies all of the above edits cleanly end-to-end.

View File

@@ -2,7 +2,7 @@
plan: true
title: ""
status: iterating
iteration: 4
iteration: 5
target_iterations: 8
beads_revision: 0
related_plans: []
@@ -52,8 +52,9 @@ output.
### Field Filtering Guardrail
In robot mode, `filter_fields` **MUST** force-include Bridge Contract fields even when the
caller passes a narrower `--fields` list. This prevents agents from accidentally stripping
the identifiers they need for write operations.
caller passes a narrower `--fields` list. This applies at **all nesting levels**: both the
top-level entity fields and nested sub-entities (e.g., notes inside `discussions --include-notes`).
This prevents agents from accidentally stripping the identifiers they need for write operations.
**Implementation**: Add a `BRIDGE_FIELDS` constant map per entity type. In `filter_fields()`,
when operating in robot mode, union the caller's requested fields with the bridge set before
@@ -69,70 +70,127 @@ const BRIDGE_FIELDS_DISCUSSIONS: &[&str] = &[
"project_path", "gitlab_project_id", "noteable_type", "parent_iid",
"gitlab_discussion_id",
];
// Applied to nested notes within discussions --include-notes
const BRIDGE_FIELDS_DISCUSSION_NOTES: &[&str] = &[
"project_path", "gitlab_project_id", "noteable_type", "parent_iid",
"gitlab_discussion_id", "gitlab_note_id",
];
```
In `filter_fields`, when entity is `"notes"` or `"discussions"`, merge the bridge set into the
requested fields before filtering the JSON value. This is a ~5-line change to the existing
function.
requested fields before filtering the JSON value. For `"discussions"`, also apply
`BRIDGE_FIELDS_DISCUSSION_NOTES` to each element of the nested `notes` array. This is a ~10-line
change to the existing function.
### Snapshot Consistency (Cross-Cutting)
Multi-query commands (`handle_notes`, `handle_discussions`) **MUST** execute all their queries
within a single deferred read transaction. This guarantees snapshot consistency when a concurrent
sync/ingest is modifying the database.
**Transaction ownership lives in handlers, not query helpers.** Each handler opens one deferred
read transaction and passes it to query helpers. Query helpers accept `&Connection` (which
`Transaction` derefs to via `std::ops::Deref`) so they remain testable with plain connections
in unit tests. This avoids nested transaction edge cases and guarantees a single snapshot across
count + page + include-notes + serialization.
```rust
// In handle_notes / handle_discussions:
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
let result = query_notes(&tx, &filters, &config)?;
// ... serialize ...
tx.commit()?; // read-only, but closes cleanly
```
Query helpers keep their `conn: &Connection` signature — `Transaction<'_>` implements
`Deref<Target = Connection>`, so `&tx` coerces to `&Connection` at call sites.
### Ambiguity Guardrail
When filtering by `gitlab_discussion_id` (on either `notes` or `discussions` commands) without
`--project`, if the query matches discussions in multiple projects:
- Return an `Ambiguous` error (exit code 18, matching existing convention)
- Include matching project paths in the error message
- Include matching project paths **and `gitlab_project_id`s** in a structured candidates list
- Suggest retry with `--project <path>`
**Implementation**: Run a **preflight distinct-project check** before the main list query
executes its `LIMIT`. This is critical because a post-query check on the paginated result set
can silently miss cross-project ambiguity when `LIMIT` truncates results to rows from a single
project. The preflight query is cheap (hits the `gitlab_discussion_id` index, returns at most
a few rows) and eliminates non-deterministic write-targeting risk.
**Implementation**: Run a **scope-aware preflight distinct-project check** before the main list
query executes its `LIMIT`. The preflight applies active scope filters (noteable_type, since,
for_issue/for_mr) alongside the discussion ID check, so it won't produce false ambiguity when
other filters already narrow to one project. This is critical because a post-query check on the
paginated result set can silently miss cross-project ambiguity when `LIMIT` truncates results to
rows from a single project. The preflight query is cheap (hits the `gitlab_discussion_id` index,
returns at most a few rows) and eliminates non-deterministic write-targeting risk.
```sql
-- Preflight ambiguity check (runs before main query)
SELECT DISTINCT p.path_with_namespace
-- Preflight ambiguity check (runs before main query, includes active scope filters)
SELECT DISTINCT p.path_with_namespace, p.gitlab_project_id
FROM discussions d
JOIN projects p ON p.id = d.project_id
WHERE d.gitlab_discussion_id = ?
-- scope filters applied dynamically:
-- AND d.noteable_type = ? (when --noteable-type present)
-- AND d.merge_request_id = (SELECT ...) (when --for-mr present)
-- AND d.issue_id = (SELECT ...) (when --for-issue present)
LIMIT 3
```
If more than one project is found, return `LoreError::Ambiguous` (exit code 18) with the
distinct project paths and suggestion to retry with `--project <path>`.
If more than one project is found, return `LoreError::Ambiguous` (exit code 18) with structured
candidates for machine consumption:
```rust
// In query_notes / query_discussions, before executing the main query:
if let Some(ref disc_id) = filters.gitlab_discussion_id {
if filters.project.is_none() {
let distinct_projects: Vec<String> = conn
let candidates: Vec<(String, i64)> = conn
.prepare(
"SELECT DISTINCT p.path_with_namespace \
"SELECT DISTINCT p.path_with_namespace, p.gitlab_project_id \
FROM discussions d \
JOIN projects p ON p.id = d.project_id \
WHERE d.gitlab_discussion_id = ? \
LIMIT 3"
// Note: add scope filter clauses dynamically
)?
.query_map([disc_id], |row| row.get(0))?
.query_map([disc_id], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?;
if distinct_projects.len() > 1 {
if candidates.len() > 1 {
return Err(LoreError::Ambiguous {
message: format!(
"Discussion ID matches {} projects: {}. Use --project to disambiguate.",
distinct_projects.len(),
distinct_projects.join(", ")
"Discussion ID matches {} projects. Use --project to disambiguate.",
candidates.len(),
),
candidates: candidates.into_iter()
.map(|(path, id)| AmbiguousCandidate { project_path: path, gitlab_project_id: id })
.collect(),
});
}
}
}
```
In robot mode, the error serializes as:
```json
{
"error": {
"code": "AMBIGUOUS",
"message": "Discussion ID matches 2 projects. Use --project to disambiguate.",
"candidates": [
{"project_path": "group/repo-a", "gitlab_project_id": 42},
{"project_path": "group/repo-b", "gitlab_project_id": 99}
],
"suggestion": "lore -J discussions --gitlab-discussion-id <id> --project <path>",
"actions": ["lore -J discussions --gitlab-discussion-id <id> --project group/repo-a"]
}
}
```
This gives agents machine-actionable candidates: they can pick a project and retry immediately
without parsing free-text error messages.
#### 1h. Wrap `query_notes` in a read transaction
Wrap the count query and page query in a deferred read transaction per the Snapshot Consistency
cross-cutting requirement. See the Bridge Contract section for the pattern.
Per the Snapshot Consistency cross-cutting requirement, `handle_notes` opens a deferred read
transaction and passes it to `query_notes`. See the Snapshot Consistency section for the pattern.
### Tests
@@ -337,6 +395,7 @@ fn notes_ambiguous_gitlab_discussion_id_across_projects() {
// (this can happen since IDs are per-project)
// Filter by gitlab_discussion_id without --project
// Assert LoreError::Ambiguous is returned with both project paths
// Assert candidates include gitlab_project_id for machine consumption
}
```
@@ -352,6 +411,19 @@ fn notes_ambiguity_preflight_not_defeated_by_limit() {
}
```
#### Test 8: Ambiguity preflight respects scope filters (no false positives)
```rust
#[test]
fn notes_ambiguity_preflight_respects_scope_filters() {
let conn = create_test_db();
// Insert 2 projects, each with a discussion sharing the same gitlab_discussion_id
// But one is Issue-type and the other MergeRequest-type
// Filter by gitlab_discussion_id + --noteable-type MergeRequest (narrows to 1 project)
// Assert NO ambiguity error — scope filters disambiguate
}
```
---
## 2. Add `gitlab_discussion_id` to Show Command Discussion Groups
@@ -644,6 +716,9 @@ lore -J discussions --gitlab-discussion-id 6a9c1750b37d
# List unresolved threads with latest 2 notes inline (fewer round-trips)
lore -J discussions --for-mr 99 --resolution unresolved --include-notes 2
# Find discussions containing specific text
lore -J discussions --for-mr 99 --contains "prefer the approach"
```
### Response Schema
@@ -801,6 +876,10 @@ pub struct DiscussionsArgs {
#[arg(long, value_enum, help_heading = "Filters")]
pub noteable_type: Option<NoteableTypeFilter>,
/// Filter discussions whose notes contain text (case-insensitive LIKE match)
#[arg(long, help_heading = "Filters")]
pub contains: Option<String>,
/// Include up to N latest notes per discussion (0 = none, default; clamped to 20)
#[arg(long, default_value = "0", help_heading = "Output")]
pub include_notes: usize,
@@ -925,7 +1004,7 @@ The `included_note_count` is set to `notes.len()` and `has_more_notes` is set to
`note_count > included_note_count` during the JSON conversion, providing per-discussion
truncation signals.
#### 3c. SQL Query
#### 3c. SQL Query — Two-Phase Page-First Architecture
**File**: `src/cli/commands/list.rs`
@@ -935,21 +1014,29 @@ pub fn query_discussions(
filters: &DiscussionListFilters,
config: &Config,
) -> Result<DiscussionListResult> {
// Wrap all queries in a deferred read transaction for snapshot consistency
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
// NOTE: Transaction is managed by the handler (handle_discussions).
// This function receives &Connection (which Transaction derefs to via `std::ops::Deref`).
// Preflight ambiguity check (if gitlab_discussion_id without project)
// ... see Ambiguity Guardrail section ...
// Main query + count query ...
// ... note expansion query (if include_notes > 0) ...
tx.commit()?;
// Phase 1: Filter + sort + LIMIT to get page IDs
// Phase 2: Note rollups only for paged results
// Phase 3: Optional --include-notes expansion (separate query)
}
```
Core query uses a CTE + ranked-notes rollup (window function) to avoid per-row correlated
subqueries. The `ROW_NUMBER()` approach produces a single scan over the notes table, which
is more predictable than repeated LIMIT 1 sub-selects at scale (200K+ discussions):
The query uses a **two-phase page-first architecture** for scalability:
1. **Phase 1** (`paged_discussions`): Apply all filters, sort, and LIMIT to produce just the
discussion IDs for the current page. This bounds the result set before any note scanning.
2. **Phase 2** (`ranked_notes` + `note_rollup`): Run note aggregation only for the paged
discussion IDs. This ensures note scanning is proportional to `--limit`, not to the total
filtered discussion count.
This architecture prevents the performance cliff that occurs on project-wide queries with
thousands of discussions: instead of scanning notes for all filtered discussions (potentially
200K+), we scan only for the 50 (or whatever `--limit` is) that will actually be returned.
```sql
WITH filtered_discussions AS (
@@ -961,6 +1048,14 @@ WITH filtered_discussions AS (
JOIN projects p ON d.project_id = p.id
{where_sql}
),
-- Phase 1: Page-first — apply sort + LIMIT before note scanning
paged_discussions AS (
SELECT id
FROM filtered_discussions
ORDER BY COALESCE({sort_column}, 0) {order}, id {order}
LIMIT ?
),
-- Phase 2: Note rollups only for paged results
ranked_notes AS (
SELECT
n.discussion_id,
@@ -980,7 +1075,7 @@ ranked_notes AS (
n.created_at, n.id
) AS rn_first_position
FROM notes n
WHERE n.discussion_id IN (SELECT id FROM filtered_discussions)
WHERE n.discussion_id IN (SELECT id FROM paged_discussions)
),
note_rollup AS (
SELECT
@@ -1012,12 +1107,12 @@ SELECT
nr.position_new_path,
nr.position_new_line
FROM filtered_discussions fd
JOIN paged_discussions pd ON fd.id = pd.id
JOIN projects p ON fd.project_id = p.id
LEFT JOIN issues i ON fd.issue_id = i.id
LEFT JOIN merge_requests m ON fd.merge_request_id = m.id
LEFT JOIN note_rollup nr ON nr.discussion_id = fd.id
ORDER BY COALESCE({sort_column}, 0) {order}, fd.id {order}
LIMIT ?
```
**Dual window function rationale**: The `ranked_notes` CTE uses two separate `ROW_NUMBER()`
@@ -1028,12 +1123,11 @@ displacing the first human author/body, and prevents a non-positioned note from
the file location. The `MAX(CASE WHEN rn_xxx = 1 ...)` pattern extracts the correct value
from each independently-ranked sequence.
**Performance rationale**: The CTE pre-filters discussions before joining notes. The
`ranked_notes` CTE uses `ROW_NUMBER()` (a single pass over the notes index) instead of
correlated `(SELECT ... LIMIT 1)` sub-selects per discussion. For MR-scoped queries
(50-200 discussions) the performance is equivalent. For project-wide scans with thousands
of discussions, the window function approach avoids repeated index probes and produces a
more predictable query plan.
**Page-first scalability rationale**: The `paged_discussions` CTE applies LIMIT before note
scanning. For MR-scoped queries (50-200 discussions) the performance is equivalent to the
non-paged approach. For project-wide scans with thousands of discussions, the page-first
architecture avoids scanning notes for discussions that won't appear in the result, keeping
latency proportional to `--limit` rather than to the total filtered count.
**Note on ordering**: The `COALESCE({sort_column}, 0)` with tiebreaker `fd.id` ensures
deterministic ordering even when timestamps are NULL (partial sync states). The `id`
@@ -1042,6 +1136,10 @@ tiebreaker is cheap (primary key) and prevents unstable sort output.
**Note on SQLite FILTER syntax**: SQLite does not support `COUNT(*) FILTER (WHERE ...)`.
Use `SUM(CASE WHEN ... THEN 1 ELSE 0 END)` instead (as shown above).
**Count query**: The total_count query runs separately against `filtered_discussions` (without
the LIMIT) using `SELECT COUNT(*) FROM filtered_discussions`. This is needed for `has_more`
metadata. The count uses the same filter CTEs but omits notes entirely.
#### 3c-ii. Note expansion query (--include-notes)
When `include_notes > 0`, after the main discussion query, run a **single batched query**
@@ -1103,6 +1201,7 @@ pub struct DiscussionListFilters {
pub since: Option<String>,
pub path: Option<String>,
pub noteable_type: Option<NoteableTypeFilter>,
pub contains: Option<String>,
pub sort: DiscussionSortField,
pub order: SortDirection,
pub include_notes: usize,
@@ -1117,6 +1216,7 @@ Where-clause construction uses `match` on typed enums — never raw string inter
- `since``d.first_note_at >= ?` (using `parse_since()`)
- `path``EXISTS (SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.position_new_path LIKE ?)`
- `noteable_type` → match: `Issue``d.noteable_type = 'Issue'`, `MergeRequest``d.noteable_type = 'MergeRequest'`
- `contains``EXISTS (SELECT 1 FROM notes n WHERE n.discussion_id = d.id AND n.body LIKE '%' || ? || '%')`
#### 3e. Handler wiring
@@ -1128,7 +1228,7 @@ Add match arm:
Some(Commands::Discussions(args)) => handle_discussions(cli.config.as_deref(), args, robot_mode),
```
Handler function:
Handler function (with transaction ownership):
```rust
fn handle_discussions(
@@ -1143,6 +1243,10 @@ fn handle_discussions(
let effective_limit = args.limit.min(500);
let effective_include_notes = args.include_notes.min(20);
// Snapshot consistency: one transaction across all queries
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
let filters = DiscussionListFilters {
limit: effective_limit,
project: args.project,
@@ -1153,12 +1257,15 @@ fn handle_discussions(
since: args.since,
path: args.path,
noteable_type: args.noteable_type,
contains: args.contains,
sort: args.sort,
order: args.order,
include_notes: effective_include_notes,
};
let result = query_discussions(&conn, &filters, &config)?;
let result = query_discussions(&tx, &filters, &config)?;
tx.commit()?; // read-only, but closes cleanly
let format = if robot_mode && args.format == "table" {
"json"
@@ -1247,7 +1354,7 @@ CSV view: all fields, following same pattern as `print_list_notes_csv`.
.collect(),
```
#### 3h. Query-plan validation
#### 3h. Query-plan validation and indexes
Before merging the discussions command, capture `EXPLAIN QUERY PLAN` output for the three
primary query patterns:
@@ -1255,17 +1362,26 @@ primary query patterns:
- `--project <path> --since 7d --sort last-note`
- `--gitlab-discussion-id <id>`
If plans show table scans on `notes` or `discussions` for these patterns, add targeted indexes
to the `MIGRATIONS` array in `src/core/db.rs`:
**Required baseline index** (directly hit by `--include-notes` expansion, which runs a
`ROW_NUMBER() OVER (PARTITION BY discussion_id ORDER BY created_at DESC, id DESC)` window
on the notes table):
**Candidate indexes** (add only if EXPLAIN QUERY PLAN shows they're needed):
```sql
CREATE INDEX IF NOT EXISTS idx_notes_discussion_created_desc
ON notes(discussion_id, created_at DESC, id DESC);
```
This index is non-negotiable because the include-notes expansion query's performance is
directly proportional to how efficiently it can scan notes per discussion. Without it, SQLite
falls back to a full table scan of the 282K-row notes table for each batch.
**Conditional indexes** (add only if EXPLAIN QUERY PLAN shows they're needed):
- `discussions(project_id, gitlab_discussion_id)` — for ambiguity preflight + direct ID lookup
- `discussions(merge_request_id, last_note_at, id)` — for MR-scoped + sorted queries
- `notes(discussion_id, created_at DESC, id DESC)` — for `--include-notes` expansion
- `notes(discussion_id, is_system, created_at, id)` — for ranked_notes CTE ordering
This is a measured approach: profile first, add indexes only where the query plan demands them.
No speculative index creation.
This is a measured approach: one required index for the critical new path, remaining indexes
added only where the query plan demands them.
### Tests
@@ -1500,7 +1616,7 @@ fn discussions_ambiguous_gitlab_discussion_id_across_projects() {
};
let result = query_discussions(&conn, &filters, &Config::default());
assert!(result.is_err());
// Error should be Ambiguous with both project paths
// Error should be Ambiguous with both project paths and gitlab_project_ids
}
```
@@ -1579,6 +1695,99 @@ fn discussions_first_note_rollup_skips_system_notes() {
}
```
#### Test 15: --contains filter returns matching discussions
```rust
#[test]
fn query_discussions_contains_filter() {
let conn = create_test_db();
insert_project(&conn, 1);
insert_mr(&conn, 1, 1, 99, "Test MR");
insert_discussion(&conn, 1, "disc-match", 1, None, Some(1), "MergeRequest");
insert_discussion(&conn, 2, "disc-nomatch", 1, None, Some(1), "MergeRequest");
insert_note_in_discussion(&conn, 1, 500, 1, 1, "alice", "I really do prefer this approach");
insert_note_in_discussion(&conn, 2, 501, 2, 1, "bob", "Looks good to me");
let filters = DiscussionListFilters {
contains: Some("really do prefer".to_string()),
..DiscussionListFilters::default_for_mr(99)
};
let result = query_discussions(&conn, &filters, &Config::default()).unwrap();
assert_eq!(result.discussions.len(), 1);
assert_eq!(result.discussions[0].gitlab_discussion_id, "disc-match");
}
```
#### Test 16: Nested note bridge fields survive --fields filtering in robot mode
```rust
#[test]
fn discussions_nested_note_bridge_fields_forced_in_robot_mode() {
// When discussions --include-notes returns nested notes,
// bridge fields on nested notes must survive --fields filtering
let mut value = serde_json::json!({
"data": {
"discussions": [{
"gitlab_discussion_id": "abc",
"noteable_type": "MergeRequest",
"parent_iid": 99,
"project_path": "group/repo",
"gitlab_project_id": 42,
"note_count": 1,
"notes": [{
"body": "test note",
"project_path": "group/repo",
"gitlab_project_id": 42,
"noteable_type": "MergeRequest",
"parent_iid": 99,
"gitlab_discussion_id": "abc",
"gitlab_note_id": 500
}]
}]
}
});
// Agent requests only "body" on notes — bridge fields must still appear
filter_fields_robot(
&mut value,
"discussions",
&["note_count".to_string()],
);
let note = &value["data"]["discussions"][0]["notes"][0];
assert!(note.get("gitlab_discussion_id").is_some());
assert!(note.get("gitlab_note_id").is_some());
assert!(note.get("gitlab_project_id").is_some());
}
```
#### Test 17: Ambiguity preflight respects scope filters (no false positives)
```rust
#[test]
fn discussions_ambiguity_preflight_respects_scope_filters() {
let conn = create_test_db();
insert_project(&conn, 1); // "group/repo-a"
insert_project(&conn, 2); // "group/repo-b"
// Same gitlab_discussion_id in both projects
// But different noteable_types
insert_discussion(&conn, 1, "shared-id", 1, Some(1), None, "Issue");
insert_discussion(&conn, 2, "shared-id", 2, None, Some(1), "MergeRequest");
// Filter by noteable_type narrows to one project — should NOT fire ambiguity
let filters = DiscussionListFilters {
gitlab_discussion_id: Some("shared-id".to_string()),
noteable_type: Some(NoteableTypeFilter::MergeRequest),
project: None,
..DiscussionListFilters::default()
};
let result = query_discussions(&conn, &filters, &Config::default());
assert!(result.is_ok());
assert_eq!(result.unwrap().discussions.len(), 1);
}
```
---
## 4. Fix Robot-Docs Response Schemas
@@ -1629,6 +1838,7 @@ With:
"--since <period>",
"--path <filepath>",
"--noteable-type <Issue|MergeRequest>",
"--contains <text>",
"--include-notes <N>",
"--sort <first-note|last-note>",
"--order <asc|desc>",
@@ -1831,14 +2041,13 @@ Changes 1 and 2 can be done in parallel. Change 4 must come last since it docume
final schema of all preceding changes.
**Cross-cutting**: The Bridge Contract field guardrail (force-including bridge fields in robot
mode) should be implemented as part of Change 1, since it modifies `filter_fields` in
`robot.rs` which all subsequent changes depend on. The `BRIDGE_FIELDS_*` constants are defined
once and reused by Changes 3 and 4.
mode, including nested notes) should be implemented as part of Change 1, since it modifies
`filter_fields` in `robot.rs` which all subsequent changes depend on. The `BRIDGE_FIELDS_*`
constants are defined once and reused by Changes 3 and 4.
**Cross-cutting**: The snapshot consistency pattern (deferred read transaction) should be
implemented in Change 1 for `query_notes` and carried forward to Change 3 for
`query_discussions`. This is a one-line wrapper that provides correctness guarantees with
zero performance cost.
**Cross-cutting**: The snapshot consistency pattern (deferred read transaction in handlers)
should be implemented in Change 1 for `handle_notes` and carried forward to Change 3 for
`handle_discussions`. Transaction ownership lives in handlers; query helpers accept `&Connection`.
---
@@ -1850,40 +2059,52 @@ After all changes:
`gitlab_discussion_id`, `gitlab_note_id`, and `gitlab_project_id` in the response
2. An agent can run `lore -J discussions --for-mr 3929 --resolution unresolved` to see all
open threads with their IDs
3. An agent can run `lore -J mrs 3929` and see `gitlab_discussion_id`, `resolvable`,
3. An agent can run `lore -J discussions --for-mr 3929 --contains "prefer the approach"` to
find threads by text content without a separate `notes` round-trip
4. An agent can run `lore -J mrs 3929` and see `gitlab_discussion_id`, `resolvable`,
`resolved`, and `last_note_at_iso` on each discussion group, plus `gitlab_note_id` on
each note within
4. `lore robot-docs` lists actual field names for all commands
5. All existing tests still pass
6. No clippy warnings (pedantic + nursery)
7. Robot-docs contract tests pass with field-set parity (not just string-contains), preventing
5. `lore robot-docs` lists actual field names for all commands
6. All existing tests still pass
7. No clippy warnings (pedantic + nursery)
8. Robot-docs contract tests pass with field-set parity (not just string-contains), preventing
future schema drift in both directions
8. Bridge Contract fields (`project_path`, `gitlab_project_id`, `noteable_type`, `parent_iid`,
9. Bridge Contract fields (`project_path`, `gitlab_project_id`, `noteable_type`, `parent_iid`,
`gitlab_discussion_id`, `gitlab_note_id`) are present in every applicable read payload
9. Bridge Contract fields survive `--fields` filtering in robot mode (guardrail enforced)
10. `--gitlab-discussion-id` filter works on both `notes` and `discussions` commands
11. `--include-notes N` populates inline notes on `discussions` output via single batched query
12. CLI-level contract integration tests verify bridge fields through the full handler path
13. `gitlab_note_id` is available in notes list output (alongside `gitlab_id` for back-compat)
10. Bridge Contract fields survive `--fields` filtering in robot mode (guardrail enforced),
including nested notes within `discussions --include-notes`
11. `--gitlab-discussion-id` filter works on both `notes` and `discussions` commands
12. `--include-notes N` populates inline notes on `discussions` output via single batched query
13. CLI-level contract integration tests verify bridge fields through the full handler path
14. `gitlab_note_id` is available in notes list output (alongside `gitlab_id` for back-compat)
and in show detail notes, providing a uniform field name across all commands
14. Ambiguity guardrail fires when `--gitlab-discussion-id` matches multiple projects without
15. Ambiguity guardrail fires when `--gitlab-discussion-id` matches multiple projects without
`--project` specified — **including when LIMIT would have hidden the ambiguity** (preflight
query runs before LIMIT)
15. Output guardrails clamp `--limit` to 500 and `--include-notes` to 20; `meta` reports
query runs before LIMIT). Error includes structured candidates with `gitlab_project_id`
for machine consumption
16. Ambiguity preflight is scope-aware: active filters (noteable_type, for_issue/for_mr) are
applied alongside the discussion ID check, preventing false ambiguity when scope already
narrows to one project
17. Output guardrails clamp `--limit` to 500 and `--include-notes` to 20; `meta` reports
effective values and `has_more` truncation flag
16. Discussion and show queries use deterministic ordering (COALESCE + id tiebreaker) to
18. Discussion and show queries use deterministic ordering (COALESCE + id tiebreaker) to
prevent unstable output during partial sync states
17. Per-discussion truncation signals (`included_note_count`, `has_more_notes`) are accurate
19. Per-discussion truncation signals (`included_note_count`, `has_more_notes`) are accurate
for `--include-notes` output
18. Multi-query commands (`query_notes`, `query_discussions`) use deferred read transactions
for snapshot consistency during concurrent ingest
19. Discussion filters (`resolution`, `noteable_type`, `sort`, `order`) use typed enums
20. Multi-query handlers (`handle_notes`, `handle_discussions`) open deferred read transactions;
query helpers accept `&Connection` for snapshot consistency and testability
21. Discussion filters (`resolution`, `noteable_type`, `sort`, `order`) use typed enums
with match-to-SQL mapping — no raw string interpolation in query construction
20. First-note rollup correctly handles discussions with leading system notes — `first_author`
22. First-note rollup correctly handles discussions with leading system notes — `first_author`
and `first_note_body_snippet` always reflect the first non-system note
21. Query plans for primary discussion query patterns (`--for-mr`, `--project --since`,
23. Query plans for primary discussion query patterns (`--for-mr`, `--project --since`,
`--gitlab-discussion-id`) have been validated via EXPLAIN QUERY PLAN; targeted indexes
added only where scans were observed
24. The `notes(discussion_id, created_at DESC, id DESC)` index is present for `--include-notes`
expansion performance
25. Discussion query uses page-first CTE architecture: note rollups scan only the paged result
set, not all filtered discussions, keeping latency proportional to `--limit`
26. `--contains` filter on `discussions` returns only discussions with matching note text
---
@@ -1902,6 +2123,6 @@ After all changes:
- **`--with-write-hints` flag for inline glab endpoint templates** — rejected because this couples lore's read surface to glab's API surface, violating the read/write split principle. The Bridge Contract gives agents the raw identifiers; constructing glab commands is the agent's responsibility. Adding endpoint templates would require lore to track glab API changes, creating an unnecessary maintenance burden.
- **Show-command note ordering change (`ORDER BY COALESCE(position, ...), created_at, id`)** — rejected because show-command note ordering within a discussion thread is out of scope for this plan. The existing ordering works correctly for present data; the defensive COALESCE pattern is applied to discussion-level ordering where it matters for agent workflows.
- **Query-plan validation as a separate numbered workstream** — rejected because it adds delivery overhead without proportional benefit. Query-plan validation is integrated into workstream 3 as a pre-merge validation step (section 3h), with candidate indexes listed but only added when EXPLAIN QUERY PLAN shows they're needed. This keeps the measured approach without inflating the workstream count.
- **Add `gitlab_note_id` to show-command note detail structs** — rejected because show-command note detail structs already have `gitlab_id` (same value as `id`). The field is unambiguous and consistent with the Bridge Contract. Adding `gitlab_note_id` would create a duplicate and increase payload size without benefit.
- **Add `gitlab_discussion_id` to show-command discussion detail structs** — rejected because show-command discussion detail structs already have `gitlab_discussion_id`. The field is unambiguous and consistent with the Bridge Contract. Adding `gitlab_discussion_id` would create a duplicate and increase payload size without benefit.
- **Add `gitlab_project_id` to show-command discussion detail structs** — rejected because show-command discussion detail structs already have `gitlab_project_id`. The field is unambiguous and consistent with the Bridge Contract. Adding `gitlab_project_id` would create a duplicate and increase payload size without benefit.
- **`--project-id` immutable input filter across notes/discussions/show** — rejected because this is scope creep touching every command and changing CLI ergonomics. Agents already get `gitlab_project_id` in output to construct API calls; the input-side concern (project renames breaking `--project`) is theoretical and hasn't been observed in practice. The `--project` flag already supports fuzzy matching which handles most rename scenarios. If real-world evidence surfaces, this can be added later without breaking changes.
- **Schema versioning in robot-docs (`schema_version` field + semver policy)** — rejected because this tool has zero external consumers beyond our own agents, and the contract tests (field-set parity assertions) catch drift at compile time. Schema versioning adds bureaucratic overhead (version bumps, compatibility matrices, deprecation policies) without proportional benefit for an internal tool in early development. If lore gains external consumers, this can be reconsidered.
- **Remove "stale" rejected items that "conflict" with active sections** — rejected because the prior entries about show-command structs were stale from iteration 2 and have been cleaned up independently. The rejected section is cumulative by design — it prevents future reviewers from re-proposing changes that have already been evaluated.

View 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');

View File

@@ -193,6 +193,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
"--as-of",
"--explain-score",
"--include-bots",
"--include-closed",
"--all-history",
],
),

View File

@@ -344,7 +344,14 @@ pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
.map(resolve_since_required)
.transpose()?;
let limit = usize::from(args.limit);
let result = query_workload(&conn, username, project_id, since_ms, limit)?;
let result = query_workload(
&conn,
username,
project_id,
since_ms,
limit,
args.include_closed,
)?;
Ok(WhoRun {
resolved_input: WhoResolvedInput {
mode: "workload".to_string(),
@@ -377,7 +384,7 @@ pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun> {
WhoMode::Active => {
let since_ms = resolve_since(args.since.as_deref(), "7d")?;
let limit = usize::from(args.limit);
let result = query_active(&conn, project_id, since_ms, limit)?;
let result = query_active(&conn, project_id, since_ms, limit, args.include_closed)?;
Ok(WhoRun {
resolved_input: WhoResolvedInput {
mode: "active".to_string(),
@@ -1149,6 +1156,7 @@ fn query_workload(
project_id: Option<i64>,
since_ms: Option<i64>,
limit: usize,
include_closed: bool,
) -> Result<WorkloadResult> {
let limit_plus_one = (limit + 1) as i64;
@@ -1245,7 +1253,14 @@ fn query_workload(
.collect::<std::result::Result<Vec<_>, _>>()?;
// Query 4: Unresolved discussions where user participated
let disc_sql = "SELECT d.noteable_type,
let state_filter = if include_closed {
""
} else {
" AND (i.id IS NULL OR i.state = 'opened')
AND (m.id IS NULL OR m.state = 'opened')"
};
let disc_sql = format!(
"SELECT d.noteable_type,
COALESCE(i.iid, m.iid) AS entity_iid,
(p.path_with_namespace ||
CASE WHEN d.noteable_type = 'MergeRequest' THEN '!' ELSE '#' END ||
@@ -1266,10 +1281,12 @@ fn query_workload(
)
AND (?2 IS NULL OR d.project_id = ?2)
AND (?3 IS NULL OR d.last_note_at >= ?3)
{state_filter}
ORDER BY d.last_note_at DESC
LIMIT ?4";
LIMIT ?4"
);
let mut stmt = conn.prepare_cached(disc_sql)?;
let mut stmt = conn.prepare_cached(&disc_sql)?;
let unresolved_discussions: Vec<WorkloadDiscussion> = stmt
.query_map(
rusqlite::params![username, project_id, since_ms, limit_plus_one],
@@ -1451,35 +1468,63 @@ fn query_active(
project_id: Option<i64>,
since_ms: i64,
limit: usize,
include_closed: bool,
) -> Result<ActiveResult> {
let limit_plus_one = (limit + 1) as i64;
// Total unresolved count -- two static variants
let total_sql_global = "SELECT COUNT(*) FROM discussions d
WHERE d.resolvable = 1 AND d.resolved = 0
AND d.last_note_at >= ?1";
let total_sql_scoped = "SELECT COUNT(*) FROM discussions d
WHERE d.resolvable = 1 AND d.resolved = 0
AND d.last_note_at >= ?1
AND d.project_id = ?2";
let total_unresolved_in_window: u32 = match project_id {
None => conn.query_row(total_sql_global, rusqlite::params![since_ms], |row| {
row.get(0)
})?,
Some(pid) => conn.query_row(total_sql_scoped, rusqlite::params![since_ms, pid], |row| {
row.get(0)
})?,
// State filter for open-entities-only (default behavior)
let state_joins = if include_closed {
""
} else {
" LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests m ON d.merge_request_id = m.id"
};
let state_filter = if include_closed {
""
} else {
" AND (i.id IS NULL OR i.state = 'opened')
AND (m.id IS NULL OR m.state = 'opened')"
};
// Active discussions with context -- two static SQL variants
let sql_global = "
// Total unresolved count -- conditionally built
let total_sql_global = format!(
"SELECT COUNT(*) FROM discussions d
{state_joins}
WHERE d.resolvable = 1 AND d.resolved = 0
AND d.last_note_at >= ?1
{state_filter}"
);
let total_sql_scoped = format!(
"SELECT COUNT(*) FROM discussions d
{state_joins}
WHERE d.resolvable = 1 AND d.resolved = 0
AND d.last_note_at >= ?1
AND d.project_id = ?2
{state_filter}"
);
let total_unresolved_in_window: u32 = match project_id {
None => conn.query_row(&total_sql_global, rusqlite::params![since_ms], |row| {
row.get(0)
})?,
Some(pid) => {
conn.query_row(&total_sql_scoped, rusqlite::params![since_ms, pid], |row| {
row.get(0)
})?
}
};
// Active discussions with context -- conditionally built SQL
let sql_global = format!(
"
WITH picked AS (
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
d.project_id, d.last_note_at
FROM discussions d
{state_joins}
WHERE d.resolvable = 1 AND d.resolved = 0
AND d.last_note_at >= ?1
{state_filter}
ORDER BY d.last_note_at DESC
LIMIT ?2
),
@@ -1520,16 +1565,20 @@ fn query_active(
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
LEFT JOIN participants pa ON pa.discussion_id = p.id
ORDER BY p.last_note_at DESC
";
"
);
let sql_scoped = "
let sql_scoped = format!(
"
WITH picked AS (
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
d.project_id, d.last_note_at
FROM discussions d
{state_joins}
WHERE d.resolvable = 1 AND d.resolved = 0
AND d.last_note_at >= ?1
AND d.project_id = ?2
{state_filter}
ORDER BY d.last_note_at DESC
LIMIT ?3
),
@@ -1570,7 +1619,8 @@ fn query_active(
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
LEFT JOIN participants pa ON pa.discussion_id = p.id
ORDER BY p.last_note_at DESC
";
"
);
// Row-mapping closure shared between both variants
let map_row = |row: &rusqlite::Row| -> rusqlite::Result<ActiveDiscussion> {
@@ -1613,12 +1663,12 @@ fn query_active(
// Select variant first, then prepare exactly one statement
let discussions: Vec<ActiveDiscussion> = match project_id {
None => {
let mut stmt = conn.prepare_cached(sql_global)?;
let mut stmt = conn.prepare_cached(&sql_global)?;
stmt.query_map(rusqlite::params![since_ms, limit_plus_one], &map_row)?
.collect::<std::result::Result<Vec<_>, _>>()?
}
Some(pid) => {
let mut stmt = conn.prepare_cached(sql_scoped)?;
let mut stmt = conn.prepare_cached(&sql_scoped)?;
stmt.query_map(rusqlite::params![since_ms, pid, limit_plus_one], &map_row)?
.collect::<std::result::Result<Vec<_>, _>>()?
}

View File

@@ -54,15 +54,27 @@ fn insert_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str
}
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
insert_issue_with_state(conn, id, project_id, iid, author, "opened");
}
fn insert_issue_with_state(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
) {
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'opened', ?6, ?7, ?8, ?9)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("Issue {iid}"),
state,
author,
now_ms(),
now_ms(),
@@ -134,6 +146,24 @@ fn insert_diffnote(
.unwrap();
}
fn insert_note(conn: &Connection, id: i64, discussion_id: i64, project_id: i64, author: &str) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, 'DiscussionNote', 0, ?5, 'comment', ?6, ?7, ?8)",
rusqlite::params![
id,
id * 10,
discussion_id,
project_id,
author,
now_ms(),
now_ms(),
now_ms()
],
)
.unwrap();
}
fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) {
conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)",
@@ -263,6 +293,7 @@ fn test_is_file_path_discrimination() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
})
.unwrap(),
@@ -286,6 +317,7 @@ fn test_is_file_path_discrimination() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
})
.unwrap(),
@@ -309,6 +341,7 @@ fn test_is_file_path_discrimination() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
})
.unwrap(),
@@ -332,6 +365,7 @@ fn test_is_file_path_discrimination() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
})
.unwrap(),
@@ -355,6 +389,7 @@ fn test_is_file_path_discrimination() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
})
.unwrap(),
@@ -378,6 +413,7 @@ fn test_is_file_path_discrimination() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
})
.unwrap(),
@@ -402,6 +438,7 @@ fn test_detail_rejected_outside_expert_mode() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
};
let mode = resolve_mode(&args).unwrap();
@@ -430,6 +467,7 @@ fn test_detail_allowed_in_expert_mode() {
as_of: None,
explain_score: false,
include_bots: false,
include_closed: false,
all_history: false,
};
let mode = resolve_mode(&args).unwrap();
@@ -579,7 +617,7 @@ fn test_workload_query() {
insert_assignee(&conn, 1, "dev_a");
insert_mr(&conn, 1, 1, 100, "dev_a", "opened");
let result = query_workload(&conn, "dev_a", None, None, 20).unwrap();
let result = query_workload(&conn, "dev_a", None, None, 20, true).unwrap();
assert_eq!(result.assigned_issues.len(), 1);
assert_eq!(result.authored_mrs.len(), 1);
}
@@ -626,7 +664,7 @@ fn test_active_query() {
// Second note by same participant -- note_count should be 2, participants still ["reviewer_b"]
insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up");
let result = query_active(&conn, None, 0, 20).unwrap();
let result = query_active(&conn, None, 0, 20, true).unwrap();
assert_eq!(result.total_unresolved_in_window, 1);
assert_eq!(result.discussions.len(), 1);
assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
@@ -878,7 +916,7 @@ fn test_active_participants_sorted() {
insert_diffnote(&conn, 1, 1, 1, "zebra_user", "src/foo.rs", "note 1");
insert_diffnote(&conn, 2, 1, 1, "alpha_user", "src/foo.rs", "note 2");
let result = query_active(&conn, None, 0, 20).unwrap();
let result = query_active(&conn, None, 0, 20, true).unwrap();
assert_eq!(
result.discussions[0].participants,
vec!["alpha_user", "zebra_user"]
@@ -3265,3 +3303,94 @@ fn test_deterministic_accumulation_order() {
);
}
}
// ─── Tests: include_closed filter ────────────────────────────────────────────
#[test]
fn workload_excludes_closed_entity_discussions() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
// Open issue with unresolved discussion
insert_issue_with_state(&conn, 10, 1, 10, "someone", "opened");
insert_discussion(&conn, 100, 1, None, Some(10), true, false);
insert_note(&conn, 1000, 100, 1, "alice");
// Closed issue with unresolved discussion
insert_issue_with_state(&conn, 20, 1, 20, "someone", "closed");
insert_discussion(&conn, 200, 1, None, Some(20), true, false);
insert_note(&conn, 2000, 200, 1, "alice");
// Default: exclude closed
let result = query_workload(&conn, "alice", None, None, 50, false).unwrap();
assert_eq!(result.unresolved_discussions.len(), 1);
assert_eq!(result.unresolved_discussions[0].entity_iid, 10);
}
#[test]
fn workload_include_closed_flag_shows_all() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 10, "someone", "opened");
insert_discussion(&conn, 100, 1, None, Some(10), true, false);
insert_note(&conn, 1000, 100, 1, "alice");
insert_issue_with_state(&conn, 20, 1, 20, "someone", "closed");
insert_discussion(&conn, 200, 1, None, Some(20), true, false);
insert_note(&conn, 2000, 200, 1, "alice");
let result = query_workload(&conn, "alice", None, None, 50, true).unwrap();
assert_eq!(result.unresolved_discussions.len(), 2);
}
#[test]
fn workload_excludes_merged_mr_discussions() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
// Open MR with unresolved discussion
insert_mr(&conn, 10, 1, 10, "someone", "opened");
insert_discussion(&conn, 100, 1, Some(10), None, true, false);
insert_note(&conn, 1000, 100, 1, "alice");
// Merged MR with unresolved discussion
insert_mr(&conn, 20, 1, 20, "someone", "merged");
insert_discussion(&conn, 200, 1, Some(20), None, true, false);
insert_note(&conn, 2000, 200, 1, "alice");
let result = query_workload(&conn, "alice", None, None, 50, false).unwrap();
assert_eq!(result.unresolved_discussions.len(), 1);
assert_eq!(result.unresolved_discussions[0].entity_iid, 10);
// include_closed shows both
let result = query_workload(&conn, "alice", None, None, 50, true).unwrap();
assert_eq!(result.unresolved_discussions.len(), 2);
}
#[test]
fn active_excludes_closed_entity_discussions() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
// Open issue with unresolved discussion
insert_issue_with_state(&conn, 10, 1, 10, "someone", "opened");
insert_discussion(&conn, 100, 1, None, Some(10), true, false);
insert_note(&conn, 1000, 100, 1, "alice");
// Closed issue with unresolved discussion
insert_issue_with_state(&conn, 20, 1, 20, "someone", "closed");
insert_discussion(&conn, 200, 1, None, Some(20), true, false);
insert_note(&conn, 2000, 200, 1, "alice");
// Default: exclude closed
let result = query_active(&conn, None, 0, 50, false).unwrap();
assert_eq!(result.discussions.len(), 1);
assert_eq!(result.discussions[0].entity_iid, 10);
assert_eq!(result.total_unresolved_in_window, 1);
// include_closed shows both
let result = query_active(&conn, None, 0, 50, true).unwrap();
assert_eq!(result.discussions.len(), 2);
assert_eq!(result.total_unresolved_in_window, 2);
}

View File

@@ -964,6 +964,10 @@ pub struct WhoArgs {
#[arg(long = "include-bots", help_heading = "Scoring")]
pub include_bots: bool,
/// Include discussions on closed issues and merged/closed MRs
#[arg(long, help_heading = "Filters")]
pub include_closed: bool,
/// Remove the default time window (query all history). Conflicts with --since.
#[arg(
long = "all-history",

View File

@@ -89,6 +89,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
"026",
include_str!("../../migrations/026_scoring_indexes.sql"),
),
(
"027",
include_str!("../../migrations/027_tui_list_indexes.sql"),
),
];
pub fn create_connection(db_path: &Path) -> Result<Connection> {

View File

@@ -1,6 +1,7 @@
use rusqlite::Connection;
use super::error::{LoreError, Result};
use super::file_history::resolve_rename_chain;
// ─── SQL Helpers ─────────────────────────────────────────────────────────────
@@ -149,6 +150,16 @@ pub fn build_path_query(
is_prefix: false,
}),
SuffixResult::Ambiguous(candidates) => {
// Check if all candidates are the same file connected by renames.
// resolve_rename_chain requires a concrete project_id.
if let Some(pid) = project_id
&& let Some(resolved) = try_resolve_rename_ambiguity(conn, pid, &candidates)?
{
return Ok(PathQuery {
value: resolved,
is_prefix: false,
});
}
let list = candidates
.iter()
.map(|p| format!(" {p}"))
@@ -239,6 +250,59 @@ pub fn suffix_probe(
}
}
/// Maximum rename hops when resolving ambiguity.
const AMBIGUITY_MAX_RENAME_HOPS: usize = 10;
/// When suffix probe returns multiple paths, check if they are all the same file
/// connected by renames. If so, return the "newest" path (the leaf of the chain
/// that is never renamed away from). Returns `None` if truly ambiguous.
fn try_resolve_rename_ambiguity(
conn: &Connection,
project_id: i64,
candidates: &[String],
) -> Result<Option<String>> {
// BFS from the first candidate to discover the full rename chain.
let chain = resolve_rename_chain(conn, project_id, &candidates[0], AMBIGUITY_MAX_RENAME_HOPS)?;
// If any candidate is NOT in the chain, these are genuinely different files.
if !candidates.iter().all(|c| chain.contains(c)) {
return Ok(None);
}
// All candidates are the same file. Find the "newest" path: the one that
// appears as new_path in a rename but is never old_path in a subsequent rename
// (within the chain). This is the leaf of the rename DAG.
let placeholders: Vec<String> = (0..chain.len()).map(|i| format!("?{}", i + 2)).collect();
let in_clause = placeholders.join(", ");
// Find paths that are old_path in a rename where new_path is also in the chain.
let sql = format!(
"SELECT DISTINCT old_path FROM mr_file_changes \
WHERE project_id = ?1 \
AND change_type = 'renamed' \
AND old_path IN ({in_clause}) \
AND new_path IN ({in_clause})"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(project_id));
for p in &chain {
params.push(Box::new(p.clone()));
}
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let old_paths: Vec<String> = stmt
.query_map(param_refs.as_slice(), |row| row.get(0))?
.filter_map(std::result::Result::ok)
.collect();
// The newest path is a candidate that is NOT an old_path in any intra-chain rename.
let newest = candidates.iter().find(|c| !old_paths.contains(c));
Ok(newest.cloned().or_else(|| Some(candidates[0].clone())))
}
#[cfg(test)]
#[path = "path_resolver_tests.rs"]
mod tests;

View File

@@ -288,3 +288,80 @@ fn test_exact_match_preferred_over_suffix() {
assert_eq!(pq.value, "README.md");
assert!(!pq.is_prefix);
}
fn seed_rename(conn: &Connection, mr_id: i64, project_id: i64, old_path: &str, new_path: &str) {
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
VALUES (?1, ?2, ?3, ?4, 'renamed')",
rusqlite::params![mr_id, project_id, old_path, new_path],
)
.unwrap();
}
// ─── rename-aware ambiguity resolution ──────────────────────────────────────
#[test]
fn test_ambiguity_resolved_by_rename_chain() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
seed_mr(&conn, 2, 1);
// File was at src/old/operators.ts, then renamed to src/new/operators.ts
seed_file_change(&conn, 1, 1, "src/old/operators.ts");
seed_rename(&conn, 2, 1, "src/old/operators.ts", "src/new/operators.ts");
// Bare "operators.ts" matches both paths via suffix probe, but they're
// connected by a rename — should auto-resolve to the newest path.
let pq = build_path_query(&conn, "operators.ts", Some(1)).unwrap();
assert_eq!(pq.value, "src/new/operators.ts");
assert!(!pq.is_prefix);
}
#[test]
fn test_ambiguity_not_resolved_when_genuinely_different_files() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
// Two genuinely different files with the same name (no rename connecting them)
seed_file_change(&conn, 1, 1, "src/utils/helpers.ts");
seed_file_change(&conn, 1, 1, "tests/utils/helpers.ts");
let err = build_path_query(&conn, "helpers.ts", Some(1)).unwrap_err();
assert!(err.to_string().contains("matches multiple paths"));
}
#[test]
fn test_ambiguity_rename_chain_with_three_hops() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
seed_mr(&conn, 2, 1);
seed_mr(&conn, 3, 1);
// File named "config.ts" moved twice: lib/ -> src/ -> src/core/
seed_file_change(&conn, 1, 1, "lib/config.ts");
seed_rename(&conn, 2, 1, "lib/config.ts", "src/config.ts");
seed_rename(&conn, 3, 1, "src/config.ts", "src/core/config.ts");
// "config.ts" matches lib/config.ts, src/config.ts, src/core/config.ts via suffix
let pq = build_path_query(&conn, "config.ts", Some(1)).unwrap();
assert_eq!(pq.value, "src/core/config.ts");
assert!(!pq.is_prefix);
}
#[test]
fn test_ambiguity_rename_without_project_id_stays_ambiguous() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
seed_mr(&conn, 2, 1);
seed_file_change(&conn, 1, 1, "src/old/utils.ts");
seed_rename(&conn, 2, 1, "src/old/utils.ts", "src/new/utils.ts");
// Without project_id, rename resolution is skipped → stays ambiguous
let err = build_path_query(&conn, "utils.ts", None).unwrap_err();
assert!(err.to_string().contains("matches multiple paths"));
}

View File

@@ -39,6 +39,7 @@ use lore::core::dependent_queue::release_all_locked_jobs;
use lore::core::error::{LoreError, RobotErrorOutput};
use lore::core::logging;
use lore::core::metrics::MetricsLayer;
use lore::core::path_resolver::{build_path_query, normalize_repo_path};
use lore::core::paths::{get_config_path, get_db_path, get_log_dir};
use lore::core::project::resolve_project;
use lore::core::shutdown::ShutdownSignal;
@@ -1874,9 +1875,27 @@ fn handle_file_history(
.effective_project(args.project.as_deref())
.map(String::from);
let normalized = normalize_repo_path(&args.path);
// Resolve bare filenames before querying (same path resolution as trace/who)
let db_path_tmp = get_db_path(config.storage.db_path.as_deref());
let conn_tmp = create_connection(&db_path_tmp)?;
let project_id_tmp = project
.as_deref()
.map(|p| resolve_project(&conn_tmp, p))
.transpose()?;
let pq = build_path_query(&conn_tmp, &normalized, project_id_tmp)?;
let resolved_path = if pq.is_prefix {
// Directory prefix — file-history is file-oriented, pass the raw path.
// Don't use pq.value which contains LIKE-escaped metacharacters.
normalized.trim_end_matches('/').to_string()
} else {
pq.value
};
let result = run_file_history(
&config,
&args.path,
&resolved_path,
project.as_deref(),
args.no_follow_renames,
args.merged,
@@ -1901,7 +1920,8 @@ fn handle_trace(
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let (path, line_requested) = parse_trace_path(&args.path);
let (raw_path, line_requested) = parse_trace_path(&args.path);
let normalized = normalize_repo_path(&raw_path);
if line_requested.is_some() && !robot_mode {
eprintln!(
@@ -1920,6 +1940,16 @@ fn handle_trace(
.map(|p| resolve_project(&conn, p))
.transpose()?;
// Resolve bare filenames (e.g. "operators.ts" -> "src/utils/operators.ts")
let pq = build_path_query(&conn, &normalized, project_id)?;
let path = if pq.is_prefix {
// Directory prefix — trace is file-oriented, pass the raw path.
// Don't use pq.value which contains LIKE-escaped metacharacters.
normalized.trim_end_matches('/').to_string()
} else {
pq.value
};
let result = run_trace(
&conn,
project_id,

View File

@@ -136,6 +136,7 @@ mod tests {
#[test]
fn test_knn_k_never_exceeds_sqlite_vec_limit() {
// Brute-force: no combination of valid inputs should exceed 4096.
for limit in [1, 10, 50, 100, 500, 1000, 1500, 2000, 5000] {
for max_chunks in [1, 2, 5, 10, 50, 100, 200, 500, 1000] {
let k = compute_knn_k(limit, max_chunks);
@@ -149,33 +150,37 @@ mod tests {
#[test]
fn test_knn_k_reproduces_original_bug_scenario() {
// The original bug: limit=1500 (RECALL_CAP) with multiplier >= 8
// produced k=10_000 which exceeded sqlite-vec's 4096 cap.
let k = compute_knn_k(1500, 1);
assert!(
k <= SQLITE_VEC_KNN_MAX,
"k={k} exceeded 4096 at RECALL_CAP with 1 chunk"
);
assert!(k <= SQLITE_VEC_KNN_MAX, "k={k} exceeded 4096 at RECALL_CAP with 1 chunk");
}
#[test]
fn test_knn_k_small_limit_uses_minimum_multiplier() {
// With 1 chunk, multiplier clamps to minimum of 8.
let k = compute_knn_k(10, 1);
assert_eq!(k, 80);
}
#[test]
fn test_knn_k_high_chunks_caps_multiplier() {
// With 200 chunks, multiplier = (200*3/2 + 1) = 301 → clamped to 200.
// limit=10 * 200 = 2000 < 4096.
let k = compute_knn_k(10, 200);
assert_eq!(k, 2000);
}
#[test]
fn test_knn_k_zero_max_chunks_treated_as_one() {
// max_chunks_per_doc=0 → unsigned_abs().max(1) = 1
let k = compute_knn_k(10, 0);
assert_eq!(k, 80);
assert_eq!(k, 80); // multiplier clamp(8, 200) → 8
}
#[test]
fn test_knn_k_negative_max_chunks_uses_absolute() {
// Defensive: negative values use unsigned_abs
let k = compute_knn_k(10, -5);
assert_eq!(k, compute_knn_k(10, 5));
}