feat(tui): Phase 3 power features — Who, Search, Timeline, Trace, File History screens
Complete TUI Phase 3 implementation with all 5 power feature screens: - Who screen: 5 modes (expert/workload/reviews/active/overlap) with mode tabs, input bar, result rendering, and hint bar - Search screen: full-text search with result list and scoring display - Timeline screen: chronological event feed with time-relative display - Trace screen: file provenance chains with expand/collapse, rename tracking, and linked issues/discussions - File History screen: per-file MR timeline with rename chain display and discussion snippets Also includes: - Command palette overlay (fuzzy search) - Bootstrap screen (initial sync flow) - Action layer split from monolithic action.rs to per-screen modules - Entity and render cache infrastructure - Shared who_types module in core crate - All screens wired into view/mod.rs dispatch - 597 tests passing, clippy clean (pedantic + nursery), fmt clean
This commit is contained in:
160
crates/lore-tui/src/state/bootstrap.rs
Normal file
160
crates/lore-tui/src/state/bootstrap.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
#![allow(dead_code)] // Phase 2.5: consumed by Bootstrap screen
|
||||
|
||||
//! Bootstrap screen state.
|
||||
//!
|
||||
//! Handles first-launch and empty-database scenarios. The schema
|
||||
//! preflight runs before the TUI event loop; the bootstrap screen
|
||||
//! guides users to sync when no data is available.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataReadiness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of checking whether the database has enough data to show the TUI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DataReadiness {
|
||||
/// Database has at least one issue.
|
||||
pub has_issues: bool,
|
||||
/// Database has at least one merge request.
|
||||
pub has_mrs: bool,
|
||||
/// Database has at least one search document.
|
||||
pub has_documents: bool,
|
||||
/// Current schema version from the schema_version table.
|
||||
pub schema_version: i32,
|
||||
}
|
||||
|
||||
impl DataReadiness {
|
||||
/// Whether the database has any entity data at all.
|
||||
#[must_use]
|
||||
pub fn has_any_data(&self) -> bool {
|
||||
self.has_issues || self.has_mrs
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaCheck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of schema version validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SchemaCheck {
|
||||
/// Schema is at or above the minimum required version.
|
||||
Compatible { version: i32 },
|
||||
/// No database or no schema_version table found.
|
||||
NoDB,
|
||||
/// Schema exists but is too old for this TUI version.
|
||||
Incompatible { found: i32, minimum: i32 },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BootstrapState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the Bootstrap screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BootstrapState {
|
||||
/// Whether a data readiness check has completed.
|
||||
pub readiness: Option<DataReadiness>,
|
||||
/// Whether the user has initiated a sync from the bootstrap screen.
|
||||
pub sync_started: bool,
|
||||
}
|
||||
|
||||
impl BootstrapState {
|
||||
/// Apply a data readiness result.
|
||||
pub fn apply_readiness(&mut self, readiness: DataReadiness) {
|
||||
self.readiness = Some(readiness);
|
||||
}
|
||||
|
||||
/// Whether we have data (and should auto-transition to Dashboard).
|
||||
#[must_use]
|
||||
pub fn should_transition_to_dashboard(&self) -> bool {
|
||||
self.readiness
|
||||
.as_ref()
|
||||
.is_some_and(DataReadiness::has_any_data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_data_readiness_has_any_data() {
|
||||
let empty = DataReadiness {
|
||||
has_issues: false,
|
||||
has_mrs: false,
|
||||
has_documents: false,
|
||||
schema_version: 26,
|
||||
};
|
||||
assert!(!empty.has_any_data());
|
||||
|
||||
let with_issues = DataReadiness {
|
||||
has_issues: true,
|
||||
..empty.clone()
|
||||
};
|
||||
assert!(with_issues.has_any_data());
|
||||
|
||||
let with_mrs = DataReadiness {
|
||||
has_mrs: true,
|
||||
..empty
|
||||
};
|
||||
assert!(with_mrs.has_any_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_check_variants() {
|
||||
let compat = SchemaCheck::Compatible { version: 26 };
|
||||
assert!(matches!(compat, SchemaCheck::Compatible { version: 26 }));
|
||||
|
||||
let no_db = SchemaCheck::NoDB;
|
||||
assert!(matches!(no_db, SchemaCheck::NoDB));
|
||||
|
||||
let incompat = SchemaCheck::Incompatible {
|
||||
found: 10,
|
||||
minimum: 20,
|
||||
};
|
||||
assert!(matches!(
|
||||
incompat,
|
||||
SchemaCheck::Incompatible {
|
||||
found: 10,
|
||||
minimum: 20
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_state_default() {
|
||||
let state = BootstrapState::default();
|
||||
assert!(state.readiness.is_none());
|
||||
assert!(!state.sync_started);
|
||||
assert!(!state.should_transition_to_dashboard());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_state_apply_readiness_empty() {
|
||||
let mut state = BootstrapState::default();
|
||||
state.apply_readiness(DataReadiness {
|
||||
has_issues: false,
|
||||
has_mrs: false,
|
||||
has_documents: false,
|
||||
schema_version: 26,
|
||||
});
|
||||
assert!(!state.should_transition_to_dashboard());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_state_apply_readiness_with_data() {
|
||||
let mut state = BootstrapState::default();
|
||||
state.apply_readiness(DataReadiness {
|
||||
has_issues: true,
|
||||
has_mrs: false,
|
||||
has_documents: false,
|
||||
schema_version: 26,
|
||||
});
|
||||
assert!(state.should_transition_to_dashboard());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,304 @@
|
||||
#![allow(dead_code)]
|
||||
//! Command palette state and fuzzy matching.
|
||||
//!
|
||||
//! The command palette is a modal overlay (Ctrl+P) that provides fuzzy-match
|
||||
//! access to all commands. Populated from [`CommandRegistry::palette_entries`].
|
||||
|
||||
//! Command palette state.
|
||||
use crate::commands::{CommandId, CommandRegistry};
|
||||
use crate::message::Screen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PaletteEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single entry in the filtered palette list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaletteEntry {
|
||||
/// Command ID for execution.
|
||||
pub id: CommandId,
|
||||
/// Human-readable label.
|
||||
pub label: &'static str,
|
||||
/// Keybinding display string (e.g., "g i").
|
||||
pub keybinding: Option<String>,
|
||||
/// Help text / description.
|
||||
pub help_text: &'static str,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandPaletteState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the command palette overlay.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CommandPaletteState {
|
||||
/// Current query text.
|
||||
pub query: String,
|
||||
/// Whether the query input is focused.
|
||||
pub query_focused: bool,
|
||||
/// Cursor position within the query string (byte offset).
|
||||
pub cursor: usize,
|
||||
/// Index of the currently selected entry in `filtered`.
|
||||
pub selected_index: usize,
|
||||
/// Filtered and scored palette entries.
|
||||
pub filtered: Vec<PaletteEntry>,
|
||||
}
|
||||
|
||||
impl CommandPaletteState {
|
||||
/// Open the palette: reset query, focus input, populate with all commands.
|
||||
pub fn open(&mut self, registry: &CommandRegistry, screen: &Screen) {
|
||||
self.query.clear();
|
||||
self.cursor = 0;
|
||||
self.query_focused = true;
|
||||
self.selected_index = 0;
|
||||
self.refilter(registry, screen);
|
||||
}
|
||||
|
||||
/// Close the palette: unfocus and clear state.
|
||||
pub fn close(&mut self) {
|
||||
self.query_focused = false;
|
||||
self.query.clear();
|
||||
self.cursor = 0;
|
||||
self.selected_index = 0;
|
||||
self.filtered.clear();
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert_char(&mut self, c: char, registry: &CommandRegistry, screen: &Screen) {
|
||||
self.query.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
self.selected_index = 0;
|
||||
self.refilter(registry, screen);
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor.
|
||||
pub fn delete_back(&mut self, registry: &CommandRegistry, screen: &Screen) {
|
||||
if self.cursor > 0 {
|
||||
// Find the previous character boundary.
|
||||
let prev = self.query[..self.cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map_or(0, |(i, _)| i);
|
||||
self.query.drain(prev..self.cursor);
|
||||
self.cursor = prev;
|
||||
self.selected_index = 0;
|
||||
self.refilter(registry, screen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection up by one.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected_index = self.selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Move selection down by one.
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.filtered.is_empty() {
|
||||
self.selected_index = (self.selected_index + 1).min(self.filtered.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently selected entry's command ID.
|
||||
#[must_use]
|
||||
pub fn selected_command_id(&self) -> Option<CommandId> {
|
||||
self.filtered.get(self.selected_index).map(|e| e.id)
|
||||
}
|
||||
|
||||
/// Whether the palette is visible/active.
|
||||
#[must_use]
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.query_focused
|
||||
}
|
||||
|
||||
/// Recompute the filtered list from the registry.
|
||||
fn refilter(&mut self, registry: &CommandRegistry, screen: &Screen) {
|
||||
let entries = registry.palette_entries(screen);
|
||||
let query_lower = self.query.to_lowercase();
|
||||
|
||||
self.filtered = entries
|
||||
.into_iter()
|
||||
.filter(|cmd| {
|
||||
if query_lower.is_empty() {
|
||||
return true;
|
||||
}
|
||||
fuzzy_match(&query_lower, cmd.label) || fuzzy_match(&query_lower, cmd.help_text)
|
||||
})
|
||||
.map(|cmd| PaletteEntry {
|
||||
id: cmd.id,
|
||||
label: cmd.label,
|
||||
keybinding: cmd.keybinding.as_ref().map(|kb| kb.display()),
|
||||
help_text: cmd.help_text,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Clamp selection.
|
||||
if !self.filtered.is_empty() {
|
||||
self.selected_index = self.selected_index.min(self.filtered.len() - 1);
|
||||
} else {
|
||||
self.selected_index = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fuzzy matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Subsequence fuzzy match: every character in `query` must appear in `text`
|
||||
/// in order, case-insensitive.
|
||||
fn fuzzy_match(query: &str, text: &str) -> bool {
|
||||
let text_lower = text.to_lowercase();
|
||||
let mut text_chars = text_lower.chars();
|
||||
for qc in query.chars() {
|
||||
if !text_chars.any(|tc| tc == qc) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::build_registry;
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_exact() {
|
||||
assert!(fuzzy_match("quit", "Quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_subsequence() {
|
||||
assert!(fuzzy_match("gi", "Go to Issues"));
|
||||
assert!(fuzzy_match("iss", "Go to Issues"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_case_insensitive() {
|
||||
assert!(fuzzy_match("help", "Show keybinding help overlay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_no_match() {
|
||||
assert!(!fuzzy_match("xyz", "Quit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_empty_query() {
|
||||
assert!(fuzzy_match("", "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_open_populates_all() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
assert!(state.query_focused);
|
||||
assert!(state.query.is_empty());
|
||||
assert!(!state.filtered.is_empty());
|
||||
// All palette-eligible commands for Dashboard should be present.
|
||||
let palette_count = registry.palette_entries(&Screen::Dashboard).len();
|
||||
assert_eq!(state.filtered.len(), palette_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_filter_narrows_results() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
let all_count = state.filtered.len();
|
||||
state.insert_char('i', ®istry, &Screen::Dashboard);
|
||||
state.insert_char('s', ®istry, &Screen::Dashboard);
|
||||
state.insert_char('s', ®istry, &Screen::Dashboard);
|
||||
|
||||
// "iss" should match "Go to Issues" but not most other commands.
|
||||
assert!(state.filtered.len() < all_count);
|
||||
assert!(state.filtered.iter().any(|e| e.label == "Go to Issues"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_delete_back_widens_results() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
state.insert_char('q', ®istry, &Screen::Dashboard);
|
||||
let narrow_count = state.filtered.len();
|
||||
state.delete_back(®istry, &Screen::Dashboard);
|
||||
// After deleting, query is empty — should show all commands again.
|
||||
assert!(state.filtered.len() > narrow_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_select_navigation() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 2);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_prev(); // Should not go below 0.
|
||||
assert_eq!(state.selected_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_selected_command_id() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
assert!(state.selected_command_id().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_close_resets() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
state.insert_char('q', ®istry, &Screen::Dashboard);
|
||||
state.select_next();
|
||||
|
||||
state.close();
|
||||
assert!(!state.query_focused);
|
||||
assert!(state.query.is_empty());
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(state.filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_empty_query_no_match_returns_empty() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
// Type something that matches nothing.
|
||||
for c in "zzzzzz".chars() {
|
||||
state.insert_char(c, ®istry, &Screen::Dashboard);
|
||||
}
|
||||
assert!(state.filtered.is_empty());
|
||||
assert!(state.selected_command_id().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_keybinding_display() {
|
||||
let registry = build_registry();
|
||||
let mut state = CommandPaletteState::default();
|
||||
state.open(®istry, &Screen::Dashboard);
|
||||
|
||||
// "Quit" should have keybinding "q".
|
||||
let quit_entry = state.filtered.iter().find(|e| e.id == "quit");
|
||||
assert!(quit_entry.is_some());
|
||||
assert_eq!(quit_entry.unwrap().keybinding.as_deref(), Some("q"));
|
||||
}
|
||||
}
|
||||
|
||||
364
crates/lore-tui/src/state/file_history.rs
Normal file
364
crates/lore-tui/src/state/file_history.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
//! File History screen state — per-file MR timeline with rename tracking.
|
||||
//!
|
||||
//! Shows which MRs touched a file over time, resolving renames via BFS.
|
||||
//! Users enter a file path, toggle options (follow renames, merged only,
|
||||
//! show discussions), and browse a chronological MR list.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileHistoryState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the File History screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FileHistoryState {
|
||||
/// User-entered file path.
|
||||
pub path_input: String,
|
||||
/// Cursor position within `path_input` (byte offset).
|
||||
pub path_cursor: usize,
|
||||
/// Whether the path input field has keyboard focus.
|
||||
pub path_focused: bool,
|
||||
|
||||
/// The most recent result (None until first query).
|
||||
pub result: Option<FileHistoryResult>,
|
||||
|
||||
/// Index of the currently selected MR in the result list.
|
||||
pub selected_mr_index: usize,
|
||||
/// Vertical scroll offset for the MR list.
|
||||
pub scroll_offset: u16,
|
||||
|
||||
/// Whether to follow rename chains (default true).
|
||||
pub follow_renames: bool,
|
||||
/// Whether to show only merged MRs (default false).
|
||||
pub merged_only: bool,
|
||||
/// Whether to show inline discussion snippets (default false).
|
||||
pub show_discussions: bool,
|
||||
|
||||
/// Cached list of known file paths for autocomplete.
|
||||
pub known_paths: Vec<String>,
|
||||
/// Filtered autocomplete matches for current input.
|
||||
pub autocomplete_matches: Vec<String>,
|
||||
/// Currently highlighted autocomplete suggestion index.
|
||||
pub autocomplete_index: usize,
|
||||
|
||||
/// Monotonic generation counter for stale-response detection.
|
||||
pub generation: u64,
|
||||
/// Whether a query is currently in-flight.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types (local to TUI — avoids coupling to CLI command structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full result of a file-history query.
|
||||
#[derive(Debug)]
|
||||
pub struct FileHistoryResult {
|
||||
/// The queried file path.
|
||||
pub path: String,
|
||||
/// Resolved rename chain (may be just the original path).
|
||||
pub rename_chain: Vec<String>,
|
||||
/// Whether renames were actually followed.
|
||||
pub renames_followed: bool,
|
||||
/// MRs that touched any path in the rename chain.
|
||||
pub merge_requests: Vec<FileHistoryMr>,
|
||||
/// DiffNote discussion snippets on the file (when requested).
|
||||
pub discussions: Vec<FileDiscussion>,
|
||||
/// Total MR count (may exceed displayed count if limited).
|
||||
pub total_mrs: usize,
|
||||
/// Number of distinct file paths searched.
|
||||
pub paths_searched: usize,
|
||||
}
|
||||
|
||||
/// A single MR that touched the file.
|
||||
#[derive(Debug)]
|
||||
pub struct FileHistoryMr {
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
/// "merged", "opened", or "closed".
|
||||
pub state: String,
|
||||
pub author_username: String,
|
||||
/// "added", "modified", "deleted", or "renamed".
|
||||
pub change_type: String,
|
||||
pub merged_at_ms: Option<i64>,
|
||||
pub updated_at_ms: i64,
|
||||
pub merge_commit_sha: Option<String>,
|
||||
}
|
||||
|
||||
/// A DiffNote discussion snippet on the file.
|
||||
#[derive(Debug)]
|
||||
pub struct FileDiscussion {
|
||||
pub discussion_id: String,
|
||||
pub author_username: String,
|
||||
pub body_snippet: String,
|
||||
pub path: String,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl FileHistoryState {
|
||||
/// Enter the screen: focus the path input.
|
||||
pub fn enter(&mut self) {
|
||||
self.path_focused = true;
|
||||
self.path_cursor = self.path_input.len();
|
||||
}
|
||||
|
||||
/// Leave the screen: blur all inputs.
|
||||
pub fn leave(&mut self) {
|
||||
self.path_focused = false;
|
||||
}
|
||||
|
||||
/// Whether any text input has focus.
|
||||
#[must_use]
|
||||
pub fn has_text_focus(&self) -> bool {
|
||||
self.path_focused
|
||||
}
|
||||
|
||||
/// Blur all inputs.
|
||||
pub fn blur(&mut self) {
|
||||
self.path_focused = false;
|
||||
}
|
||||
|
||||
/// Submit the current path (trigger a query).
|
||||
/// Returns the generation for stale detection.
|
||||
pub fn submit(&mut self) -> u64 {
|
||||
self.loading = true;
|
||||
self.bump_generation()
|
||||
}
|
||||
|
||||
/// Apply query results if generation matches.
|
||||
pub fn apply_results(&mut self, generation: u64, result: FileHistoryResult) {
|
||||
if generation != self.generation {
|
||||
return; // Stale response — discard.
|
||||
}
|
||||
self.result = Some(result);
|
||||
self.loading = false;
|
||||
self.selected_mr_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Toggle follow_renames. Returns new generation for re-query.
|
||||
pub fn toggle_follow_renames(&mut self) -> u64 {
|
||||
self.follow_renames = !self.follow_renames;
|
||||
self.bump_generation()
|
||||
}
|
||||
|
||||
/// Toggle merged_only. Returns new generation for re-query.
|
||||
pub fn toggle_merged_only(&mut self) -> u64 {
|
||||
self.merged_only = !self.merged_only;
|
||||
self.bump_generation()
|
||||
}
|
||||
|
||||
/// Toggle show_discussions. Returns new generation for re-query.
|
||||
pub fn toggle_show_discussions(&mut self) -> u64 {
|
||||
self.show_discussions = !self.show_discussions;
|
||||
self.bump_generation()
|
||||
}
|
||||
|
||||
// --- Input field operations ---
|
||||
|
||||
/// Insert a char at cursor.
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
if self.path_focused {
|
||||
self.path_input.insert(self.path_cursor, c);
|
||||
self.path_cursor += c.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the char before cursor.
|
||||
pub fn delete_char_before_cursor(&mut self) {
|
||||
if self.path_focused && self.path_cursor > 0 {
|
||||
let prev = prev_char_boundary(&self.path_input, self.path_cursor);
|
||||
self.path_input.drain(prev..self.path_cursor);
|
||||
self.path_cursor = prev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor left.
|
||||
pub fn cursor_left(&mut self) {
|
||||
if self.path_focused && self.path_cursor > 0 {
|
||||
self.path_cursor = prev_char_boundary(&self.path_input, self.path_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right.
|
||||
pub fn cursor_right(&mut self) {
|
||||
if self.path_focused && self.path_cursor < self.path_input.len() {
|
||||
self.path_cursor = next_char_boundary(&self.path_input, self.path_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Selection navigation ---
|
||||
|
||||
/// Move selection up.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected_mr_index = self.selected_mr_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Move selection down (bounded by result count).
|
||||
pub fn select_next(&mut self, result_count: usize) {
|
||||
if result_count > 0 {
|
||||
self.selected_mr_index = (self.selected_mr_index + 1).min(result_count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the selected row is visible within the viewport.
|
||||
pub fn ensure_visible(&mut self, viewport_height: usize) {
|
||||
if viewport_height == 0 {
|
||||
return;
|
||||
}
|
||||
let offset = self.scroll_offset as usize;
|
||||
if self.selected_mr_index < offset {
|
||||
self.scroll_offset = self.selected_mr_index as u16;
|
||||
} else if self.selected_mr_index >= offset + viewport_height {
|
||||
self.scroll_offset = (self.selected_mr_index - viewport_height + 1) as u16;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
|
||||
fn bump_generation(&mut self) -> u64 {
|
||||
self.generation += 1;
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the byte offset of the previous char boundary.
|
||||
fn prev_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos.saturating_sub(1);
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the byte offset of the next char boundary.
|
||||
fn next_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos + 1;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_state() {
|
||||
let state = FileHistoryState::default();
|
||||
assert!(state.path_input.is_empty());
|
||||
assert!(!state.path_focused);
|
||||
assert!(state.result.is_none());
|
||||
assert!(!state.follow_renames); // Default false, toggled on by user
|
||||
assert!(!state.merged_only);
|
||||
assert!(!state.show_discussions);
|
||||
assert_eq!(state.generation, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enter_focuses_path() {
|
||||
let mut state = FileHistoryState {
|
||||
path_input: "src/lib.rs".into(),
|
||||
..FileHistoryState::default()
|
||||
};
|
||||
state.enter();
|
||||
assert!(state.path_focused);
|
||||
assert_eq!(state.path_cursor, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_bumps_generation() {
|
||||
let mut state = FileHistoryState::default();
|
||||
let generation = state.submit();
|
||||
assert_eq!(generation, 1);
|
||||
assert!(state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_response_discarded() {
|
||||
let mut state = FileHistoryState::default();
|
||||
let stale_gen = state.submit();
|
||||
// Bump again (user toggled an option).
|
||||
let _new_gen = state.toggle_merged_only();
|
||||
// Stale result arrives.
|
||||
state.apply_results(
|
||||
stale_gen,
|
||||
FileHistoryResult {
|
||||
path: "src/lib.rs".into(),
|
||||
rename_chain: vec!["src/lib.rs".into()],
|
||||
renames_followed: false,
|
||||
merge_requests: vec![],
|
||||
discussions: vec![],
|
||||
total_mrs: 0,
|
||||
paths_searched: 1,
|
||||
},
|
||||
);
|
||||
assert!(state.result.is_none()); // Discarded.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_options_bump_generation() {
|
||||
let mut state = FileHistoryState::default();
|
||||
let g1 = state.toggle_follow_renames();
|
||||
assert_eq!(g1, 1);
|
||||
assert!(state.follow_renames);
|
||||
|
||||
let g2 = state.toggle_merged_only();
|
||||
assert_eq!(g2, 2);
|
||||
assert!(state.merged_only);
|
||||
|
||||
let g3 = state.toggle_show_discussions();
|
||||
assert_eq!(g3, 3);
|
||||
assert!(state.show_discussions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_delete_char() {
|
||||
let mut state = FileHistoryState {
|
||||
path_focused: true,
|
||||
..FileHistoryState::default()
|
||||
};
|
||||
state.insert_char('s');
|
||||
state.insert_char('r');
|
||||
state.insert_char('c');
|
||||
assert_eq!(state.path_input, "src");
|
||||
assert_eq!(state.path_cursor, 3);
|
||||
|
||||
state.delete_char_before_cursor();
|
||||
assert_eq!(state.path_input, "sr");
|
||||
assert_eq!(state.path_cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_next() {
|
||||
let mut state = FileHistoryState::default();
|
||||
state.select_next(5);
|
||||
assert_eq!(state.selected_mr_index, 1);
|
||||
state.select_next(5);
|
||||
assert_eq!(state.selected_mr_index, 2);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_mr_index, 1);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_mr_index, 0);
|
||||
state.select_prev(); // Should not underflow.
|
||||
assert_eq!(state.selected_mr_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_visible() {
|
||||
let mut state = FileHistoryState {
|
||||
selected_mr_index: 15,
|
||||
..FileHistoryState::default()
|
||||
};
|
||||
state.ensure_visible(5);
|
||||
assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@
|
||||
//! [`LoreApp`](crate::app::LoreApp) which dispatches through the
|
||||
//! [`TaskSupervisor`](crate::task_supervisor::TaskSupervisor).
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod command_palette;
|
||||
pub mod dashboard;
|
||||
pub mod file_history;
|
||||
pub mod issue_detail;
|
||||
pub mod issue_list;
|
||||
pub mod mr_detail;
|
||||
@@ -22,6 +24,7 @@ pub mod mr_list;
|
||||
pub mod search;
|
||||
pub mod sync;
|
||||
pub mod timeline;
|
||||
pub mod trace;
|
||||
pub mod who;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -29,8 +32,10 @@ use std::collections::{HashMap, HashSet};
|
||||
use crate::message::Screen;
|
||||
|
||||
// Re-export screen states for convenience.
|
||||
pub use bootstrap::BootstrapState;
|
||||
pub use command_palette::CommandPaletteState;
|
||||
pub use dashboard::DashboardState;
|
||||
pub use file_history::FileHistoryState;
|
||||
pub use issue_detail::IssueDetailState;
|
||||
pub use issue_list::IssueListState;
|
||||
pub use mr_detail::MrDetailState;
|
||||
@@ -38,6 +43,7 @@ pub use mr_list::MrListState;
|
||||
pub use search::SearchState;
|
||||
pub use sync::SyncState;
|
||||
pub use timeline::TimelineState;
|
||||
pub use trace::TraceState;
|
||||
pub use who::WhoState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -163,6 +169,7 @@ pub struct ScopeContext {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AppState {
|
||||
// Per-screen states.
|
||||
pub bootstrap: BootstrapState,
|
||||
pub dashboard: DashboardState,
|
||||
pub issue_list: IssueListState,
|
||||
pub issue_detail: IssueDetailState,
|
||||
@@ -171,6 +178,8 @@ pub struct AppState {
|
||||
pub search: SearchState,
|
||||
pub timeline: TimelineState,
|
||||
pub who: WhoState,
|
||||
pub trace: TraceState,
|
||||
pub file_history: FileHistoryState,
|
||||
pub sync: SyncState,
|
||||
pub command_palette: CommandPaletteState,
|
||||
|
||||
@@ -205,6 +214,9 @@ impl AppState {
|
||||
|| self.mr_list.filter_focused
|
||||
|| self.search.query_focused
|
||||
|| self.command_palette.query_focused
|
||||
|| self.who.has_text_focus()
|
||||
|| self.trace.has_text_focus()
|
||||
|| self.file_history.has_text_focus()
|
||||
}
|
||||
|
||||
/// Remove focus from all text inputs.
|
||||
@@ -213,6 +225,9 @@ impl AppState {
|
||||
self.mr_list.filter_focused = false;
|
||||
self.search.query_focused = false;
|
||||
self.command_palette.query_focused = false;
|
||||
self.who.blur();
|
||||
self.trace.blur();
|
||||
self.file_history.blur();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,569 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Search screen state.
|
||||
//! Search screen state — query input, mode selection, capability detection.
|
||||
//!
|
||||
//! The search screen supports three modes ([`SearchMode`]): Lexical (FTS5),
|
||||
//! Hybrid (FTS+vector RRF), and Semantic (vector-only). Available modes are
|
||||
//! gated by [`SearchCapabilities`], which probes the database on screen entry.
|
||||
|
||||
use crate::message::SearchResult;
|
||||
use crate::message::{SearchMode, SearchResult};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearchCapabilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What search indexes are available in the local database.
|
||||
///
|
||||
/// Detected once on screen entry by probing FTS and embedding tables.
|
||||
/// Used to gate which [`SearchMode`] values are selectable.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SearchCapabilities {
|
||||
/// FTS5 `documents_fts` table has rows.
|
||||
pub has_fts: bool,
|
||||
/// `embedding_metadata` table has rows.
|
||||
pub has_embeddings: bool,
|
||||
/// Percentage of documents that have embeddings (0.0–100.0).
|
||||
pub embedding_coverage_pct: f32,
|
||||
}
|
||||
|
||||
impl Default for SearchCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
has_fts: false,
|
||||
has_embeddings: false,
|
||||
embedding_coverage_pct: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchCapabilities {
|
||||
/// Whether the given mode is usable with these capabilities.
|
||||
#[must_use]
|
||||
pub fn supports_mode(&self, mode: SearchMode) -> bool {
|
||||
match mode {
|
||||
SearchMode::Lexical => self.has_fts,
|
||||
SearchMode::Hybrid => self.has_fts && self.has_embeddings,
|
||||
SearchMode::Semantic => self.has_embeddings,
|
||||
}
|
||||
}
|
||||
|
||||
/// The best default mode given current capabilities.
|
||||
#[must_use]
|
||||
pub fn best_default_mode(&self) -> SearchMode {
|
||||
if self.has_fts && self.has_embeddings {
|
||||
SearchMode::Hybrid
|
||||
} else if self.has_fts {
|
||||
SearchMode::Lexical
|
||||
} else if self.has_embeddings {
|
||||
SearchMode::Semantic
|
||||
} else {
|
||||
SearchMode::Lexical // Fallback; UI will show "no indexes" message
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any search index is available at all.
|
||||
#[must_use]
|
||||
pub fn has_any_index(&self) -> bool {
|
||||
self.has_fts || self.has_embeddings
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearchState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the search screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchState {
|
||||
/// Current query text.
|
||||
pub query: String,
|
||||
/// Whether the query input has keyboard focus.
|
||||
pub query_focused: bool,
|
||||
/// Cursor position within the query string (byte offset).
|
||||
pub cursor: usize,
|
||||
/// Active search mode.
|
||||
pub mode: SearchMode,
|
||||
/// Available search capabilities (detected on screen entry).
|
||||
pub capabilities: SearchCapabilities,
|
||||
/// Current result set.
|
||||
pub results: Vec<SearchResult>,
|
||||
/// Index of the selected result in the list.
|
||||
pub selected_index: usize,
|
||||
/// Monotonic generation counter for stale-response detection.
|
||||
pub generation: u64,
|
||||
/// Whether a search request is in-flight.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
/// Enter the search screen: focus query, detect capabilities.
|
||||
pub fn enter(&mut self, capabilities: SearchCapabilities) {
|
||||
self.query_focused = true;
|
||||
self.cursor = self.query.len();
|
||||
self.capabilities = capabilities;
|
||||
// Pick the best mode for detected capabilities.
|
||||
if !self.capabilities.supports_mode(self.mode) {
|
||||
self.mode = self.capabilities.best_default_mode();
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the search screen: blur focus.
|
||||
pub fn leave(&mut self) {
|
||||
self.query_focused = false;
|
||||
}
|
||||
|
||||
/// Focus the query input.
|
||||
pub fn focus_query(&mut self) {
|
||||
self.query_focused = true;
|
||||
self.cursor = self.query.len();
|
||||
}
|
||||
|
||||
/// Blur the query input.
|
||||
pub fn blur_query(&mut self) {
|
||||
self.query_focused = false;
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
///
|
||||
/// Returns the new generation (caller should arm debounce timer).
|
||||
pub fn insert_char(&mut self, c: char) -> u64 {
|
||||
self.query.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
self.generation += 1;
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor (backspace).
|
||||
///
|
||||
/// Returns the new generation if changed, or `None` if cursor was at start.
|
||||
pub fn delete_back(&mut self) -> Option<u64> {
|
||||
if self.cursor == 0 {
|
||||
return None;
|
||||
}
|
||||
let prev = self.query[..self.cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map_or(0, |(i, _)| i);
|
||||
self.query.drain(prev..self.cursor);
|
||||
self.cursor = prev;
|
||||
self.generation += 1;
|
||||
Some(self.generation)
|
||||
}
|
||||
|
||||
/// Move cursor left by one character.
|
||||
pub fn cursor_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor = self.query[..self.cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map_or(0, |(i, _)| i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right by one character.
|
||||
pub fn cursor_right(&mut self) {
|
||||
if self.cursor < self.query.len() {
|
||||
self.cursor = self.query[self.cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(self.query.len(), |ch| self.cursor + ch.len_utf8());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor to the start of the query.
|
||||
pub fn cursor_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// Move cursor to the end of the query.
|
||||
pub fn cursor_end(&mut self) {
|
||||
self.cursor = self.query.len();
|
||||
}
|
||||
|
||||
/// Cycle to the next available search mode (skip unsupported modes).
|
||||
pub fn cycle_mode(&mut self) {
|
||||
let start = self.mode;
|
||||
let mut candidate = start.next();
|
||||
// Cycle through at most 3 modes to find a supported one.
|
||||
for _ in 0..3 {
|
||||
if self.capabilities.supports_mode(candidate) {
|
||||
self.mode = candidate;
|
||||
return;
|
||||
}
|
||||
candidate = candidate.next();
|
||||
}
|
||||
// No supported mode found (shouldn't happen if has_any_index is true).
|
||||
}
|
||||
|
||||
/// Apply search results from an async response.
|
||||
///
|
||||
/// Only applies if the generation matches (stale guard).
|
||||
pub fn apply_results(&mut self, generation: u64, results: Vec<SearchResult>) {
|
||||
if generation != self.generation {
|
||||
return; // Stale response — discard.
|
||||
}
|
||||
self.results = results;
|
||||
self.selected_index = 0;
|
||||
self.loading = false;
|
||||
}
|
||||
|
||||
/// Move selection up in the results list.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected_index = self.selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Move selection down in the results list.
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.results.is_empty() {
|
||||
self.selected_index = (self.selected_index + 1).min(self.results.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently selected result, if any.
|
||||
#[must_use]
|
||||
pub fn selected_result(&self) -> Option<&SearchResult> {
|
||||
self.results.get(self.selected_index)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::{EntityKey, SearchMode};
|
||||
|
||||
fn fts_only() -> SearchCapabilities {
|
||||
SearchCapabilities {
|
||||
has_fts: true,
|
||||
has_embeddings: false,
|
||||
embedding_coverage_pct: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn full_caps() -> SearchCapabilities {
|
||||
SearchCapabilities {
|
||||
has_fts: true,
|
||||
has_embeddings: true,
|
||||
embedding_coverage_pct: 85.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn embeddings_only() -> SearchCapabilities {
|
||||
SearchCapabilities {
|
||||
has_fts: false,
|
||||
has_embeddings: true,
|
||||
embedding_coverage_pct: 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn no_indexes() -> SearchCapabilities {
|
||||
SearchCapabilities::default()
|
||||
}
|
||||
|
||||
fn sample_result(iid: i64) -> SearchResult {
|
||||
SearchResult {
|
||||
key: EntityKey::issue(1, iid),
|
||||
title: format!("Issue #{iid}"),
|
||||
score: 0.95,
|
||||
snippet: "matched text here".into(),
|
||||
project_path: "group/project".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- SearchCapabilities tests --
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_supports_mode_fts_only() {
|
||||
let caps = fts_only();
|
||||
assert!(caps.supports_mode(SearchMode::Lexical));
|
||||
assert!(!caps.supports_mode(SearchMode::Hybrid));
|
||||
assert!(!caps.supports_mode(SearchMode::Semantic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_supports_mode_full() {
|
||||
let caps = full_caps();
|
||||
assert!(caps.supports_mode(SearchMode::Lexical));
|
||||
assert!(caps.supports_mode(SearchMode::Hybrid));
|
||||
assert!(caps.supports_mode(SearchMode::Semantic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_supports_mode_embeddings_only() {
|
||||
let caps = embeddings_only();
|
||||
assert!(!caps.supports_mode(SearchMode::Lexical));
|
||||
assert!(!caps.supports_mode(SearchMode::Hybrid));
|
||||
assert!(caps.supports_mode(SearchMode::Semantic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_best_default_hybrid_when_both() {
|
||||
assert_eq!(full_caps().best_default_mode(), SearchMode::Hybrid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_best_default_lexical_when_fts_only() {
|
||||
assert_eq!(fts_only().best_default_mode(), SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_best_default_semantic_when_embeddings_only() {
|
||||
assert_eq!(embeddings_only().best_default_mode(), SearchMode::Semantic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_best_default_lexical_when_none() {
|
||||
assert_eq!(no_indexes().best_default_mode(), SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capabilities_has_any_index() {
|
||||
assert!(fts_only().has_any_index());
|
||||
assert!(full_caps().has_any_index());
|
||||
assert!(embeddings_only().has_any_index());
|
||||
assert!(!no_indexes().has_any_index());
|
||||
}
|
||||
|
||||
// -- SearchState tests --
|
||||
|
||||
#[test]
|
||||
fn test_enter_focuses_and_preserves_supported_mode() {
|
||||
let mut state = SearchState::default();
|
||||
// Default mode is Lexical, which full_caps supports — preserved.
|
||||
state.enter(full_caps());
|
||||
assert!(state.query_focused);
|
||||
assert_eq!(state.mode, SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enter_preserves_mode_if_supported() {
|
||||
let mut state = SearchState {
|
||||
mode: SearchMode::Lexical,
|
||||
..SearchState::default()
|
||||
};
|
||||
state.enter(full_caps());
|
||||
// Lexical is supported by full_caps, so it stays.
|
||||
assert_eq!(state.mode, SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enter_overrides_unsupported_mode() {
|
||||
let mut state = SearchState {
|
||||
mode: SearchMode::Hybrid,
|
||||
..SearchState::default()
|
||||
};
|
||||
state.enter(fts_only());
|
||||
// Hybrid requires embeddings, so fallback to Lexical.
|
||||
assert_eq!(state.mode, SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_char_and_cursor() {
|
||||
let mut state = SearchState::default();
|
||||
let generation1 = state.insert_char('h');
|
||||
let generation2 = state.insert_char('i');
|
||||
assert_eq!(state.query, "hi");
|
||||
assert_eq!(state.cursor, 2);
|
||||
assert_eq!(generation1, 1);
|
||||
assert_eq!(generation2, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_back() {
|
||||
let mut state = SearchState::default();
|
||||
state.insert_char('a');
|
||||
state.insert_char('b');
|
||||
state.insert_char('c');
|
||||
|
||||
let generation = state.delete_back();
|
||||
assert!(generation.is_some());
|
||||
assert_eq!(state.query, "ab");
|
||||
assert_eq!(state.cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_back_at_start_returns_none() {
|
||||
let mut state = SearchState::default();
|
||||
state.insert_char('a');
|
||||
state.cursor = 0;
|
||||
assert!(state.delete_back().is_none());
|
||||
assert_eq!(state.query, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_movement() {
|
||||
let mut state = SearchState::default();
|
||||
state.insert_char('a');
|
||||
state.insert_char('b');
|
||||
state.insert_char('c');
|
||||
assert_eq!(state.cursor, 3);
|
||||
|
||||
state.cursor_left();
|
||||
assert_eq!(state.cursor, 2);
|
||||
state.cursor_left();
|
||||
assert_eq!(state.cursor, 1);
|
||||
state.cursor_right();
|
||||
assert_eq!(state.cursor, 2);
|
||||
state.cursor_home();
|
||||
assert_eq!(state.cursor, 0);
|
||||
state.cursor_end();
|
||||
assert_eq!(state.cursor, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_left_at_start_is_noop() {
|
||||
let mut state = SearchState::default();
|
||||
state.cursor_left();
|
||||
assert_eq!(state.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_right_at_end_is_noop() {
|
||||
let mut state = SearchState::default();
|
||||
state.insert_char('x');
|
||||
state.cursor_right();
|
||||
assert_eq!(state.cursor, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cycle_mode_full_caps() {
|
||||
let mut state = SearchState {
|
||||
capabilities: full_caps(),
|
||||
mode: SearchMode::Lexical,
|
||||
..SearchState::default()
|
||||
};
|
||||
|
||||
state.cycle_mode();
|
||||
assert_eq!(state.mode, SearchMode::Hybrid);
|
||||
state.cycle_mode();
|
||||
assert_eq!(state.mode, SearchMode::Semantic);
|
||||
state.cycle_mode();
|
||||
assert_eq!(state.mode, SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cycle_mode_fts_only_stays_lexical() {
|
||||
let mut state = SearchState {
|
||||
capabilities: fts_only(),
|
||||
mode: SearchMode::Lexical,
|
||||
..SearchState::default()
|
||||
};
|
||||
|
||||
state.cycle_mode();
|
||||
// Hybrid and Semantic unsupported, wraps back to Lexical.
|
||||
assert_eq!(state.mode, SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cycle_mode_embeddings_only() {
|
||||
let mut state = SearchState {
|
||||
capabilities: embeddings_only(),
|
||||
mode: SearchMode::Semantic,
|
||||
..SearchState::default()
|
||||
};
|
||||
|
||||
state.cycle_mode();
|
||||
// Lexical and Hybrid unsupported, wraps back to Semantic.
|
||||
assert_eq!(state.mode, SearchMode::Semantic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_results_matching_generation() {
|
||||
let mut state = SearchState::default();
|
||||
let generation = state.insert_char('q');
|
||||
|
||||
let results = vec![sample_result(1), sample_result(2)];
|
||||
state.apply_results(generation, results);
|
||||
|
||||
assert_eq!(state.results.len(), 2);
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(!state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_results_stale_generation_discarded() {
|
||||
let mut state = SearchState::default();
|
||||
state.insert_char('q'); // gen=1
|
||||
state.insert_char('u'); // gen=2
|
||||
|
||||
let stale_results = vec![sample_result(99)];
|
||||
state.apply_results(1, stale_results); // gen 1 is stale
|
||||
|
||||
assert!(state.results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_next() {
|
||||
let mut state = SearchState {
|
||||
results: vec![sample_result(1), sample_result(2), sample_result(3)],
|
||||
..SearchState::default()
|
||||
};
|
||||
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 2);
|
||||
state.select_next(); // Clamps at end.
|
||||
assert_eq!(state.selected_index, 2);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_prev(); // Clamps at start.
|
||||
assert_eq!(state.selected_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_result() {
|
||||
let mut state = SearchState::default();
|
||||
assert!(state.selected_result().is_none());
|
||||
|
||||
state.results = vec![sample_result(42)];
|
||||
let result = state.selected_result().unwrap();
|
||||
assert_eq!(result.key.iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leave_blurs_focus() {
|
||||
let mut state = SearchState::default();
|
||||
state.enter(fts_only());
|
||||
assert!(state.query_focused);
|
||||
state.leave();
|
||||
assert!(!state.query_focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus_query_moves_cursor_to_end() {
|
||||
let mut state = SearchState {
|
||||
query: "hello".into(),
|
||||
cursor: 0,
|
||||
..SearchState::default()
|
||||
};
|
||||
state.focus_query();
|
||||
assert!(state.query_focused);
|
||||
assert_eq!(state.cursor, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unicode_cursor_handling() {
|
||||
let mut state = SearchState::default();
|
||||
// Insert a multi-byte character.
|
||||
state.insert_char('田');
|
||||
assert_eq!(state.cursor, 3); // 田 is 3 bytes in UTF-8
|
||||
state.insert_char('中');
|
||||
assert_eq!(state.cursor, 6);
|
||||
|
||||
state.cursor_left();
|
||||
assert_eq!(state.cursor, 3);
|
||||
state.cursor_right();
|
||||
assert_eq!(state.cursor, 6);
|
||||
|
||||
state.delete_back();
|
||||
assert_eq!(state.query, "田");
|
||||
assert_eq!(state.cursor, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,271 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Timeline screen state.
|
||||
//! Timeline screen state — event stream, scope filtering, navigation.
|
||||
//!
|
||||
//! The timeline displays a chronological event stream from resource event
|
||||
//! tables. Events can be scoped to a specific entity, author, or shown
|
||||
//! globally. [`TimelineScope`] gates the query; [`TimelineState`] manages
|
||||
//! the scroll position, selected event, and generation counter for
|
||||
//! stale-response detection.
|
||||
|
||||
use crate::message::TimelineEvent;
|
||||
use crate::message::{EntityKey, TimelineEvent};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimelineScope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Scope filter for the timeline event query.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum TimelineScope {
|
||||
/// All events across all entities.
|
||||
#[default]
|
||||
All,
|
||||
/// Events for a specific entity (issue or MR).
|
||||
Entity(EntityKey),
|
||||
/// Events by a specific actor.
|
||||
Author(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TimelineState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the timeline screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TimelineState {
|
||||
/// Loaded timeline events (sorted by timestamp, most recent first).
|
||||
pub events: Vec<TimelineEvent>,
|
||||
pub scroll_offset: u16,
|
||||
/// Active scope filter.
|
||||
pub scope: TimelineScope,
|
||||
/// Index of the selected event in the list.
|
||||
pub selected_index: usize,
|
||||
/// Scroll offset for the visible window.
|
||||
pub scroll_offset: usize,
|
||||
/// Monotonic generation counter for stale-response detection.
|
||||
pub generation: u64,
|
||||
/// Whether a fetch is in-flight.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
impl TimelineState {
|
||||
/// Enter the timeline screen. Bumps generation for fresh data.
|
||||
pub fn enter(&mut self) -> u64 {
|
||||
self.generation += 1;
|
||||
self.loading = true;
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Set the scope filter and bump generation.
|
||||
///
|
||||
/// Returns the new generation (caller should trigger a re-fetch).
|
||||
pub fn set_scope(&mut self, scope: TimelineScope) -> u64 {
|
||||
self.scope = scope;
|
||||
self.generation += 1;
|
||||
self.loading = true;
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Apply timeline events from an async response.
|
||||
///
|
||||
/// Only applies if the generation matches (stale guard).
|
||||
pub fn apply_results(&mut self, generation: u64, events: Vec<TimelineEvent>) {
|
||||
if generation != self.generation {
|
||||
return; // Stale response — discard.
|
||||
}
|
||||
self.events = events;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.loading = false;
|
||||
}
|
||||
|
||||
/// Move selection up in the event list.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected_index = self.selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Move selection down in the event list.
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.events.is_empty() {
|
||||
self.selected_index = (self.selected_index + 1).min(self.events.len() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently selected event, if any.
|
||||
#[must_use]
|
||||
pub fn selected_event(&self) -> Option<&TimelineEvent> {
|
||||
self.events.get(self.selected_index)
|
||||
}
|
||||
|
||||
/// Ensure the selected index is visible given the viewport height.
|
||||
///
|
||||
/// Adjusts `scroll_offset` so the selected item is within the
|
||||
/// visible window.
|
||||
pub fn ensure_visible(&mut self, viewport_height: usize) {
|
||||
if viewport_height == 0 {
|
||||
return;
|
||||
}
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
} else if self.selected_index >= self.scroll_offset + viewport_height {
|
||||
self.scroll_offset = self.selected_index - viewport_height + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::TimelineEventKind;
|
||||
|
||||
fn sample_event(timestamp_ms: i64, iid: i64) -> TimelineEvent {
|
||||
TimelineEvent {
|
||||
timestamp_ms,
|
||||
entity_key: EntityKey::issue(1, iid),
|
||||
event_kind: TimelineEventKind::Created,
|
||||
summary: format!("Issue #{iid} created"),
|
||||
detail: None,
|
||||
actor: Some("alice".into()),
|
||||
project_path: "group/project".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_scope_default_is_all() {
|
||||
assert_eq!(TimelineScope::default(), TimelineScope::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enter_bumps_generation() {
|
||||
let mut state = TimelineState::default();
|
||||
let generation = state.enter();
|
||||
assert_eq!(generation, 1);
|
||||
assert!(state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_scope_bumps_generation() {
|
||||
let mut state = TimelineState::default();
|
||||
let gen1 = state.set_scope(TimelineScope::Author("bob".into()));
|
||||
assert_eq!(gen1, 1);
|
||||
assert_eq!(state.scope, TimelineScope::Author("bob".into()));
|
||||
|
||||
let gen2 = state.set_scope(TimelineScope::All);
|
||||
assert_eq!(gen2, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_results_matching_generation() {
|
||||
let mut state = TimelineState::default();
|
||||
let generation = state.enter();
|
||||
|
||||
let events = vec![sample_event(3000, 1), sample_event(2000, 2)];
|
||||
state.apply_results(generation, events);
|
||||
|
||||
assert_eq!(state.events.len(), 2);
|
||||
assert_eq!(state.selected_index, 0);
|
||||
assert!(!state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_results_stale_generation_discarded() {
|
||||
let mut state = TimelineState::default();
|
||||
state.enter(); // gen=1
|
||||
let _gen2 = state.enter(); // gen=2
|
||||
|
||||
let stale_events = vec![sample_event(1000, 99)];
|
||||
state.apply_results(1, stale_events); // gen 1 is stale
|
||||
|
||||
assert!(state.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_next() {
|
||||
let mut state = TimelineState {
|
||||
events: vec![
|
||||
sample_event(3000, 1),
|
||||
sample_event(2000, 2),
|
||||
sample_event(1000, 3),
|
||||
],
|
||||
..TimelineState::default()
|
||||
};
|
||||
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 2);
|
||||
state.select_next(); // Clamps at end.
|
||||
assert_eq!(state.selected_index, 2);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_prev(); // Clamps at start.
|
||||
assert_eq!(state.selected_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_event() {
|
||||
let mut state = TimelineState::default();
|
||||
assert!(state.selected_event().is_none());
|
||||
|
||||
state.events = vec![sample_event(3000, 42)];
|
||||
let event = state.selected_event().unwrap();
|
||||
assert_eq!(event.entity_key.iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_visible_scrolls_down() {
|
||||
let mut state = TimelineState {
|
||||
events: vec![
|
||||
sample_event(5000, 1),
|
||||
sample_event(4000, 2),
|
||||
sample_event(3000, 3),
|
||||
sample_event(2000, 4),
|
||||
sample_event(1000, 5),
|
||||
],
|
||||
selected_index: 4,
|
||||
scroll_offset: 0,
|
||||
..TimelineState::default()
|
||||
};
|
||||
state.ensure_visible(3);
|
||||
assert_eq!(state.scroll_offset, 2); // 4 - 3 + 1 = 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_visible_scrolls_up() {
|
||||
let mut state = TimelineState {
|
||||
events: vec![
|
||||
sample_event(5000, 1),
|
||||
sample_event(4000, 2),
|
||||
sample_event(3000, 3),
|
||||
],
|
||||
selected_index: 0,
|
||||
scroll_offset: 2,
|
||||
..TimelineState::default()
|
||||
};
|
||||
state.ensure_visible(3);
|
||||
assert_eq!(state.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_visible_zero_viewport() {
|
||||
let mut state = TimelineState {
|
||||
scroll_offset: 5,
|
||||
..TimelineState::default()
|
||||
};
|
||||
state.ensure_visible(0);
|
||||
assert_eq!(state.scroll_offset, 5); // Unchanged.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_next_on_empty_is_noop() {
|
||||
let mut state = TimelineState::default();
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_index, 0);
|
||||
}
|
||||
}
|
||||
|
||||
556
crates/lore-tui/src/state/trace.rs
Normal file
556
crates/lore-tui/src/state/trace.rs
Normal file
@@ -0,0 +1,556 @@
|
||||
//! Trace screen state — file → MR → issue chain drill-down.
|
||||
//!
|
||||
//! Users enter a file path, and the trace query resolves rename chains,
|
||||
//! finds MRs that touched the file, links issues via entity_references,
|
||||
//! and extracts DiffNote discussions. Each result chain can be
|
||||
//! expanded/collapsed independently.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use lore::core::trace::TraceResult;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TraceState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the Trace screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TraceState {
|
||||
/// User-entered file path (with optional :line suffix).
|
||||
pub path_input: String,
|
||||
/// Cursor position within `path_input`.
|
||||
pub path_cursor: usize,
|
||||
/// Whether the path input field has keyboard focus.
|
||||
pub path_focused: bool,
|
||||
|
||||
/// Parsed line filter from `:N` suffix (stored but not yet used for highlighting).
|
||||
pub line_filter: Option<u32>,
|
||||
|
||||
/// The most recent trace result (None until first query).
|
||||
pub result: Option<TraceResult>,
|
||||
|
||||
/// Index of the currently selected chain in the trace result.
|
||||
pub selected_chain_index: usize,
|
||||
/// Set of chain indices that are currently expanded.
|
||||
pub expanded_chains: HashSet<usize>,
|
||||
|
||||
/// Whether to follow rename chains in the query (default true).
|
||||
pub follow_renames: bool,
|
||||
/// Whether to include DiffNote discussions (default true).
|
||||
pub include_discussions: bool,
|
||||
|
||||
/// Vertical scroll offset for the chain list.
|
||||
pub scroll_offset: u16,
|
||||
|
||||
/// Cached list of known file paths for autocomplete.
|
||||
pub known_paths: Vec<String>,
|
||||
/// Filtered autocomplete matches for current input.
|
||||
pub autocomplete_matches: Vec<String>,
|
||||
/// Currently highlighted autocomplete suggestion index.
|
||||
pub autocomplete_index: usize,
|
||||
|
||||
/// Generation counter for stale response guard.
|
||||
pub generation: u64,
|
||||
/// Whether a query is in flight.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
impl TraceState {
|
||||
/// Initialize defaults for a fresh Trace screen entry.
|
||||
pub fn enter(&mut self) {
|
||||
self.path_focused = true;
|
||||
self.follow_renames = true;
|
||||
self.include_discussions = true;
|
||||
}
|
||||
|
||||
/// Clean up when leaving the Trace screen.
|
||||
pub fn leave(&mut self) {
|
||||
self.path_focused = false;
|
||||
}
|
||||
|
||||
/// Submit the current path input as a trace query.
|
||||
///
|
||||
/// Bumps generation, parses the :line suffix, and returns the
|
||||
/// new generation if the path is non-empty.
|
||||
pub fn submit(&mut self) -> Option<u64> {
|
||||
let trimmed = self.path_input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (path, line) = lore::cli::commands::trace::parse_trace_path(trimmed);
|
||||
self.path_input = path;
|
||||
self.path_cursor = self.path_input.len();
|
||||
self.line_filter = line;
|
||||
|
||||
self.generation += 1;
|
||||
self.loading = true;
|
||||
self.selected_chain_index = 0;
|
||||
self.expanded_chains.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.path_focused = false;
|
||||
self.autocomplete_matches.clear();
|
||||
|
||||
Some(self.generation)
|
||||
}
|
||||
|
||||
/// Apply a trace result, guarded by generation counter.
|
||||
pub fn apply_result(&mut self, generation: u64, result: TraceResult) {
|
||||
if generation != self.generation {
|
||||
return; // Stale response — discard.
|
||||
}
|
||||
self.result = Some(result);
|
||||
self.loading = false;
|
||||
}
|
||||
|
||||
/// Toggle the expand/collapse state of the selected chain.
|
||||
pub fn toggle_expand(&mut self) {
|
||||
if self.expanded_chains.contains(&self.selected_chain_index) {
|
||||
self.expanded_chains.remove(&self.selected_chain_index);
|
||||
} else {
|
||||
self.expanded_chains.insert(self.selected_chain_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle follow_renames and bump generation (triggers re-fetch).
|
||||
pub fn toggle_follow_renames(&mut self) -> Option<u64> {
|
||||
self.follow_renames = !self.follow_renames;
|
||||
self.requery()
|
||||
}
|
||||
|
||||
/// Toggle include_discussions and bump generation (triggers re-fetch).
|
||||
pub fn toggle_include_discussions(&mut self) -> Option<u64> {
|
||||
self.include_discussions = !self.include_discussions;
|
||||
self.requery()
|
||||
}
|
||||
|
||||
/// Re-query with current settings if path is non-empty.
|
||||
fn requery(&mut self) -> Option<u64> {
|
||||
if self.path_input.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.generation += 1;
|
||||
self.loading = true;
|
||||
self.selected_chain_index = 0;
|
||||
self.expanded_chains.clear();
|
||||
self.scroll_offset = 0;
|
||||
Some(self.generation)
|
||||
}
|
||||
|
||||
/// Select the previous chain.
|
||||
pub fn select_prev(&mut self) {
|
||||
if self.selected_chain_index > 0 {
|
||||
self.selected_chain_index -= 1;
|
||||
self.ensure_visible();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the next chain.
|
||||
pub fn select_next(&mut self) {
|
||||
let max = self.chain_count().saturating_sub(1);
|
||||
if self.selected_chain_index < max {
|
||||
self.selected_chain_index += 1;
|
||||
self.ensure_visible();
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of trace chains in the current result.
|
||||
fn chain_count(&self) -> usize {
|
||||
self.result.as_ref().map_or(0, |r| r.trace_chains.len())
|
||||
}
|
||||
|
||||
/// Ensure the selected chain is visible within the scroll viewport.
|
||||
fn ensure_visible(&mut self) {
|
||||
let idx = self.selected_chain_index as u16;
|
||||
if idx < self.scroll_offset {
|
||||
self.scroll_offset = idx;
|
||||
}
|
||||
// Rough viewport — exact height adjusted in render.
|
||||
}
|
||||
|
||||
/// Whether the text input has focus.
|
||||
#[must_use]
|
||||
pub fn has_text_focus(&self) -> bool {
|
||||
self.path_focused
|
||||
}
|
||||
|
||||
/// Remove focus from all text inputs.
|
||||
pub fn blur(&mut self) {
|
||||
self.path_focused = false;
|
||||
self.autocomplete_matches.clear();
|
||||
}
|
||||
|
||||
/// Focus the path input.
|
||||
pub fn focus_input(&mut self) {
|
||||
self.path_focused = true;
|
||||
self.update_autocomplete();
|
||||
}
|
||||
|
||||
// --- Text editing helpers ---
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert_char(&mut self, ch: char) {
|
||||
let byte_pos = self
|
||||
.path_input
|
||||
.char_indices()
|
||||
.nth(self.path_cursor)
|
||||
.map_or(self.path_input.len(), |(i, _)| i);
|
||||
self.path_input.insert(byte_pos, ch);
|
||||
self.path_cursor += 1;
|
||||
self.update_autocomplete();
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor.
|
||||
pub fn delete_char_before_cursor(&mut self) {
|
||||
if self.path_cursor == 0 {
|
||||
return;
|
||||
}
|
||||
self.path_cursor -= 1;
|
||||
let byte_pos = self
|
||||
.path_input
|
||||
.char_indices()
|
||||
.nth(self.path_cursor)
|
||||
.map_or(self.path_input.len(), |(i, _)| i);
|
||||
let end = self
|
||||
.path_input
|
||||
.char_indices()
|
||||
.nth(self.path_cursor + 1)
|
||||
.map_or(self.path_input.len(), |(i, _)| i);
|
||||
self.path_input.drain(byte_pos..end);
|
||||
self.update_autocomplete();
|
||||
}
|
||||
|
||||
/// Move cursor left.
|
||||
pub fn cursor_left(&mut self) {
|
||||
self.path_cursor = self.path_cursor.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Move cursor right.
|
||||
pub fn cursor_right(&mut self) {
|
||||
let max = self.path_input.chars().count();
|
||||
if self.path_cursor < max {
|
||||
self.path_cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Autocomplete ---
|
||||
|
||||
/// Update autocomplete matches based on current input.
|
||||
pub fn update_autocomplete(&mut self) {
|
||||
let input_lower = self.path_input.to_lowercase();
|
||||
if input_lower.is_empty() {
|
||||
self.autocomplete_matches.clear();
|
||||
self.autocomplete_index = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
self.autocomplete_matches = self
|
||||
.known_paths
|
||||
.iter()
|
||||
.filter(|p| p.to_lowercase().contains(&input_lower))
|
||||
.take(10) // Limit visible suggestions.
|
||||
.cloned()
|
||||
.collect();
|
||||
self.autocomplete_index = 0;
|
||||
}
|
||||
|
||||
/// Cycle to the next autocomplete suggestion.
|
||||
pub fn autocomplete_next(&mut self) {
|
||||
if self.autocomplete_matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.autocomplete_index = (self.autocomplete_index + 1) % self.autocomplete_matches.len();
|
||||
}
|
||||
|
||||
/// Accept the current autocomplete suggestion into the path input.
|
||||
pub fn accept_autocomplete(&mut self) {
|
||||
if let Some(match_) = self.autocomplete_matches.get(self.autocomplete_index) {
|
||||
self.path_input = match_.clone();
|
||||
self.path_cursor = self.path_input.chars().count();
|
||||
self.autocomplete_matches.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trace_state_default() {
|
||||
let state = TraceState::default();
|
||||
assert!(state.path_input.is_empty());
|
||||
assert!(!state.path_focused);
|
||||
assert!(!state.follow_renames); // Default false, enter() sets true.
|
||||
assert!(state.result.is_none());
|
||||
assert_eq!(state.generation, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trace_state_enter_sets_defaults() {
|
||||
let mut state = TraceState::default();
|
||||
state.enter();
|
||||
assert!(state.path_focused);
|
||||
assert!(state.follow_renames);
|
||||
assert!(state.include_discussions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_empty_returns_none() {
|
||||
let mut state = TraceState::default();
|
||||
assert!(state.submit().is_none());
|
||||
assert_eq!(state.generation, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_with_path_bumps_generation() {
|
||||
let mut state = TraceState {
|
||||
path_input: "src/main.rs".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
let generation = state.submit();
|
||||
assert_eq!(generation, Some(1));
|
||||
assert_eq!(state.generation, 1);
|
||||
assert!(state.loading);
|
||||
assert!(!state.path_focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_submit_parses_line_suffix() {
|
||||
let mut state = TraceState {
|
||||
path_input: "src/main.rs:42".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
state.submit();
|
||||
assert_eq!(state.path_input, "src/main.rs");
|
||||
assert_eq!(state.line_filter, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_result_matching_generation() {
|
||||
let mut state = TraceState {
|
||||
path_input: "src/lib.rs".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
state.submit(); // generation = 1
|
||||
|
||||
let result = TraceResult {
|
||||
path: "src/lib.rs".into(),
|
||||
resolved_paths: vec![],
|
||||
renames_followed: false,
|
||||
trace_chains: vec![],
|
||||
total_chains: 0,
|
||||
};
|
||||
|
||||
state.apply_result(1, result);
|
||||
assert!(state.result.is_some());
|
||||
assert!(!state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_result_stale_generation_discarded() {
|
||||
let mut state = TraceState {
|
||||
path_input: "src/lib.rs".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
state.submit(); // generation = 1
|
||||
state.path_input = "src/other.rs".into();
|
||||
state.submit(); // generation = 2
|
||||
|
||||
let stale_result = TraceResult {
|
||||
path: "src/lib.rs".into(),
|
||||
resolved_paths: vec![],
|
||||
renames_followed: false,
|
||||
trace_chains: vec![],
|
||||
total_chains: 0,
|
||||
};
|
||||
|
||||
state.apply_result(1, stale_result); // Stale — should be discarded.
|
||||
assert!(state.result.is_none());
|
||||
assert!(state.loading); // Still loading.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_expand() {
|
||||
let mut state = TraceState {
|
||||
selected_chain_index: 2,
|
||||
..TraceState::default()
|
||||
};
|
||||
|
||||
state.toggle_expand();
|
||||
assert!(state.expanded_chains.contains(&2));
|
||||
|
||||
state.toggle_expand();
|
||||
assert!(!state.expanded_chains.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_follow_renames_requeues() {
|
||||
let mut state = TraceState {
|
||||
path_input: "src/main.rs".into(),
|
||||
path_focused: true,
|
||||
follow_renames: true,
|
||||
include_discussions: true,
|
||||
..TraceState::default()
|
||||
};
|
||||
assert!(state.follow_renames);
|
||||
|
||||
let generation = state.toggle_follow_renames();
|
||||
assert!(!state.follow_renames);
|
||||
assert_eq!(generation, Some(1));
|
||||
assert!(state.loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_include_discussions_requeues() {
|
||||
let mut state = TraceState {
|
||||
path_input: "src/main.rs".into(),
|
||||
path_focused: true,
|
||||
follow_renames: true,
|
||||
include_discussions: true,
|
||||
..TraceState::default()
|
||||
};
|
||||
assert!(state.include_discussions);
|
||||
|
||||
let generation = state.toggle_include_discussions();
|
||||
assert!(!state.include_discussions);
|
||||
assert_eq!(generation, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_next() {
|
||||
let mut state = TraceState {
|
||||
result: Some(TraceResult {
|
||||
path: "x".into(),
|
||||
resolved_paths: vec![],
|
||||
renames_followed: false,
|
||||
trace_chains: vec![
|
||||
lore::core::trace::TraceChain {
|
||||
mr_iid: 1,
|
||||
mr_title: "a".into(),
|
||||
mr_state: "merged".into(),
|
||||
mr_author: "x".into(),
|
||||
change_type: "modified".into(),
|
||||
merged_at_iso: None,
|
||||
updated_at_iso: "2024-01-01".into(),
|
||||
web_url: None,
|
||||
issues: vec![],
|
||||
discussions: vec![],
|
||||
},
|
||||
lore::core::trace::TraceChain {
|
||||
mr_iid: 2,
|
||||
mr_title: "b".into(),
|
||||
mr_state: "merged".into(),
|
||||
mr_author: "y".into(),
|
||||
change_type: "added".into(),
|
||||
merged_at_iso: None,
|
||||
updated_at_iso: "2024-01-02".into(),
|
||||
web_url: None,
|
||||
issues: vec![],
|
||||
discussions: vec![],
|
||||
},
|
||||
],
|
||||
total_chains: 2,
|
||||
}),
|
||||
..TraceState::default()
|
||||
};
|
||||
|
||||
assert_eq!(state.selected_chain_index, 0);
|
||||
state.select_next();
|
||||
assert_eq!(state.selected_chain_index, 1);
|
||||
state.select_next(); // Clamped at max.
|
||||
assert_eq!(state.selected_chain_index, 1);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_chain_index, 0);
|
||||
state.select_prev(); // Clamped at 0.
|
||||
assert_eq!(state.selected_chain_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_char_and_delete() {
|
||||
let mut state = TraceState::default();
|
||||
state.insert_char('a');
|
||||
state.insert_char('b');
|
||||
state.insert_char('c');
|
||||
assert_eq!(state.path_input, "abc");
|
||||
assert_eq!(state.path_cursor, 3);
|
||||
|
||||
state.delete_char_before_cursor();
|
||||
assert_eq!(state.path_input, "ab");
|
||||
assert_eq!(state.path_cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autocomplete_filters() {
|
||||
let mut state = TraceState {
|
||||
known_paths: vec!["src/a.rs".into(), "src/b.rs".into(), "lib/c.rs".into()],
|
||||
path_input: "src/".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
state.update_autocomplete();
|
||||
assert_eq!(state.autocomplete_matches.len(), 2);
|
||||
assert!(state.autocomplete_matches.contains(&"src/a.rs".to_string()));
|
||||
assert!(state.autocomplete_matches.contains(&"src/b.rs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autocomplete_next_cycles() {
|
||||
let mut state = TraceState {
|
||||
known_paths: vec!["a.rs".into(), "ab.rs".into()],
|
||||
path_input: "a".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
state.update_autocomplete();
|
||||
assert_eq!(state.autocomplete_matches.len(), 2);
|
||||
assert_eq!(state.autocomplete_index, 0);
|
||||
|
||||
state.autocomplete_next();
|
||||
assert_eq!(state.autocomplete_index, 1);
|
||||
|
||||
state.autocomplete_next();
|
||||
assert_eq!(state.autocomplete_index, 0); // Wrapped.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accept_autocomplete() {
|
||||
let mut state = TraceState {
|
||||
known_paths: vec!["src/main.rs".into()],
|
||||
path_input: "src/".into(),
|
||||
..TraceState::default()
|
||||
};
|
||||
state.update_autocomplete();
|
||||
assert_eq!(state.autocomplete_matches.len(), 1);
|
||||
|
||||
state.accept_autocomplete();
|
||||
assert_eq!(state.path_input, "src/main.rs");
|
||||
assert!(state.autocomplete_matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_text_focus() {
|
||||
let state = TraceState::default();
|
||||
assert!(!state.has_text_focus());
|
||||
let state = TraceState {
|
||||
path_focused: true,
|
||||
..TraceState::default()
|
||||
};
|
||||
assert!(state.has_text_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blur_clears_focus_and_autocomplete() {
|
||||
let mut state = TraceState {
|
||||
path_focused: true,
|
||||
autocomplete_matches: vec!["a".into()],
|
||||
..TraceState::default()
|
||||
};
|
||||
|
||||
state.blur();
|
||||
assert!(!state.path_focused);
|
||||
assert!(state.autocomplete_matches.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,516 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Who (people intelligence) screen state.
|
||||
//!
|
||||
//! Manages 5 query modes (Expert, Workload, Reviews, Active, Overlap),
|
||||
//! input fields (path or username depending on mode), and result display.
|
||||
|
||||
use crate::message::WhoResult;
|
||||
use lore::core::who_types::WhoResult;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WhoMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The 5 query modes for the who screen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum WhoMode {
|
||||
/// File-path expertise scores.
|
||||
#[default]
|
||||
Expert,
|
||||
/// Issue/MR assignment workload for a username.
|
||||
Workload,
|
||||
/// Review activity breakdown for a username.
|
||||
Reviews,
|
||||
/// Recent unresolved discussions (no input needed).
|
||||
Active,
|
||||
/// Shared file knowledge between contributors.
|
||||
Overlap,
|
||||
}
|
||||
|
||||
impl WhoMode {
|
||||
/// Short label for mode tab rendering.
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Expert => "Expert",
|
||||
Self::Workload => "Workload",
|
||||
Self::Reviews => "Reviews",
|
||||
Self::Active => "Active",
|
||||
Self::Overlap => "Overlap",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this mode requires a path input.
|
||||
#[must_use]
|
||||
pub fn needs_path(self) -> bool {
|
||||
matches!(self, Self::Expert | Self::Overlap)
|
||||
}
|
||||
|
||||
/// Whether this mode requires a username input.
|
||||
#[must_use]
|
||||
pub fn needs_username(self) -> bool {
|
||||
matches!(self, Self::Workload | Self::Reviews)
|
||||
}
|
||||
|
||||
/// Whether include_closed affects this mode's query.
|
||||
#[must_use]
|
||||
pub fn affected_by_include_closed(self) -> bool {
|
||||
matches!(self, Self::Workload | Self::Active)
|
||||
}
|
||||
|
||||
/// Cycle to the next mode (wraps around).
|
||||
#[must_use]
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Expert => Self::Workload,
|
||||
Self::Workload => Self::Reviews,
|
||||
Self::Reviews => Self::Active,
|
||||
Self::Active => Self::Overlap,
|
||||
Self::Overlap => Self::Expert,
|
||||
}
|
||||
}
|
||||
|
||||
/// All modes in order.
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Expert,
|
||||
Self::Workload,
|
||||
Self::Reviews,
|
||||
Self::Active,
|
||||
Self::Overlap,
|
||||
];
|
||||
|
||||
/// Mode from 1-based number key (1=Expert, 2=Workload, ..., 5=Overlap).
|
||||
#[must_use]
|
||||
pub fn from_number(n: u8) -> Option<Self> {
|
||||
match n {
|
||||
1 => Some(Self::Expert),
|
||||
2 => Some(Self::Workload),
|
||||
3 => Some(Self::Reviews),
|
||||
4 => Some(Self::Active),
|
||||
5 => Some(Self::Overlap),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WhoState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the who/people screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WhoState {
|
||||
/// Active query mode.
|
||||
pub mode: WhoMode,
|
||||
/// Current result (if any).
|
||||
pub result: Option<WhoResult>,
|
||||
pub scroll_offset: u16,
|
||||
|
||||
// Input fields.
|
||||
/// Path input text (used by Expert and Overlap modes).
|
||||
pub path: String,
|
||||
/// Cursor position within path string (byte offset).
|
||||
pub path_cursor: usize,
|
||||
/// Whether the path input has focus.
|
||||
pub path_focused: bool,
|
||||
|
||||
/// Username input text (used by Workload and Reviews modes).
|
||||
pub username: String,
|
||||
/// Cursor position within username string (byte offset).
|
||||
pub username_cursor: usize,
|
||||
/// Whether the username input has focus.
|
||||
pub username_focused: bool,
|
||||
|
||||
/// Toggle: include closed entities in Workload/Active queries.
|
||||
pub include_closed: bool,
|
||||
|
||||
// Result navigation.
|
||||
/// Index of the selected row in the result list.
|
||||
pub selected_index: usize,
|
||||
/// Vertical scroll offset for the result area.
|
||||
pub scroll_offset: usize,
|
||||
|
||||
// Async coordination.
|
||||
/// Monotonic generation counter for stale-response detection.
|
||||
pub generation: u64,
|
||||
/// Whether a query is in-flight.
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
impl WhoState {
|
||||
/// Enter the who screen: focus the appropriate input.
|
||||
pub fn enter(&mut self) {
|
||||
self.focus_input_for_mode();
|
||||
}
|
||||
|
||||
/// Leave the who screen: blur all inputs.
|
||||
pub fn leave(&mut self) {
|
||||
self.path_focused = false;
|
||||
self.username_focused = false;
|
||||
}
|
||||
|
||||
/// Switch to a different mode. Clears result and resets selection.
|
||||
/// Returns the new generation for stale detection.
|
||||
pub fn set_mode(&mut self, mode: WhoMode) -> u64 {
|
||||
if self.mode == mode {
|
||||
return self.generation;
|
||||
}
|
||||
self.mode = mode;
|
||||
self.result = None;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
self.focus_input_for_mode();
|
||||
self.bump_generation()
|
||||
}
|
||||
|
||||
/// Toggle include_closed. Returns new generation if the mode is affected.
|
||||
pub fn toggle_include_closed(&mut self) -> Option<u64> {
|
||||
self.include_closed = !self.include_closed;
|
||||
if self.mode.affected_by_include_closed() {
|
||||
Some(self.bump_generation())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply query results if generation matches.
|
||||
pub fn apply_results(&mut self, generation: u64, result: WhoResult) {
|
||||
if generation != self.generation {
|
||||
return; // Stale response — discard.
|
||||
}
|
||||
self.result = Some(result);
|
||||
self.loading = false;
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Submit the current input (trigger a query).
|
||||
/// Returns the generation for the new query.
|
||||
pub fn submit(&mut self) -> u64 {
|
||||
self.loading = true;
|
||||
self.bump_generation()
|
||||
}
|
||||
|
||||
// --- Input field operations ---
|
||||
|
||||
/// Insert a char at cursor in the active input field.
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
if self.path_focused {
|
||||
self.path.insert(self.path_cursor, c);
|
||||
self.path_cursor += c.len_utf8();
|
||||
} else if self.username_focused {
|
||||
self.username.insert(self.username_cursor, c);
|
||||
self.username_cursor += c.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the char before cursor in the active input field.
|
||||
pub fn delete_char_before_cursor(&mut self) {
|
||||
if self.path_focused && self.path_cursor > 0 {
|
||||
let prev = prev_char_boundary(&self.path, self.path_cursor);
|
||||
self.path.drain(prev..self.path_cursor);
|
||||
self.path_cursor = prev;
|
||||
} else if self.username_focused && self.username_cursor > 0 {
|
||||
let prev = prev_char_boundary(&self.username, self.username_cursor);
|
||||
self.username.drain(prev..self.username_cursor);
|
||||
self.username_cursor = prev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor left in the active input.
|
||||
pub fn cursor_left(&mut self) {
|
||||
if self.path_focused && self.path_cursor > 0 {
|
||||
self.path_cursor = prev_char_boundary(&self.path, self.path_cursor);
|
||||
} else if self.username_focused && self.username_cursor > 0 {
|
||||
self.username_cursor = prev_char_boundary(&self.username, self.username_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right in the active input.
|
||||
pub fn cursor_right(&mut self) {
|
||||
if self.path_focused && self.path_cursor < self.path.len() {
|
||||
self.path_cursor = next_char_boundary(&self.path, self.path_cursor);
|
||||
} else if self.username_focused && self.username_cursor < self.username.len() {
|
||||
self.username_cursor = next_char_boundary(&self.username, self.username_cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any input field has focus.
|
||||
#[must_use]
|
||||
pub fn has_text_focus(&self) -> bool {
|
||||
self.path_focused || self.username_focused
|
||||
}
|
||||
|
||||
/// Blur all inputs.
|
||||
pub fn blur(&mut self) {
|
||||
self.path_focused = false;
|
||||
self.username_focused = false;
|
||||
}
|
||||
|
||||
/// Focus the appropriate input for the current mode.
|
||||
pub fn focus_input_for_mode(&mut self) {
|
||||
self.path_focused = self.mode.needs_path();
|
||||
self.username_focused = self.mode.needs_username();
|
||||
// Place cursor at end of text.
|
||||
if self.path_focused {
|
||||
self.path_cursor = self.path.len();
|
||||
}
|
||||
if self.username_focused {
|
||||
self.username_cursor = self.username.len();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Selection navigation ---
|
||||
|
||||
/// Move selection up.
|
||||
pub fn select_prev(&mut self) {
|
||||
self.selected_index = self.selected_index.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Move selection down (bounded by result count).
|
||||
pub fn select_next(&mut self, result_count: usize) {
|
||||
if result_count > 0 {
|
||||
self.selected_index = (self.selected_index + 1).min(result_count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the selected row is visible within the viewport.
|
||||
pub fn ensure_visible(&mut self, viewport_height: usize) {
|
||||
if viewport_height == 0 {
|
||||
return;
|
||||
}
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
} else if self.selected_index >= self.scroll_offset + viewport_height {
|
||||
self.scroll_offset = self.selected_index - viewport_height + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
|
||||
fn bump_generation(&mut self) -> u64 {
|
||||
self.generation += 1;
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the byte offset of the previous char boundary.
|
||||
fn prev_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos.saturating_sub(1);
|
||||
while i > 0 && !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the byte offset of the next char boundary.
|
||||
fn next_char_boundary(s: &str, pos: usize) -> usize {
|
||||
let mut i = pos + 1;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_who_mode_defaults_to_expert() {
|
||||
assert_eq!(WhoMode::default(), WhoMode::Expert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_mode_labels() {
|
||||
assert_eq!(WhoMode::Expert.label(), "Expert");
|
||||
assert_eq!(WhoMode::Active.label(), "Active");
|
||||
assert_eq!(WhoMode::Overlap.label(), "Overlap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_mode_needs_path() {
|
||||
assert!(WhoMode::Expert.needs_path());
|
||||
assert!(WhoMode::Overlap.needs_path());
|
||||
assert!(!WhoMode::Workload.needs_path());
|
||||
assert!(!WhoMode::Reviews.needs_path());
|
||||
assert!(!WhoMode::Active.needs_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_mode_needs_username() {
|
||||
assert!(WhoMode::Workload.needs_username());
|
||||
assert!(WhoMode::Reviews.needs_username());
|
||||
assert!(!WhoMode::Expert.needs_username());
|
||||
assert!(!WhoMode::Active.needs_username());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_mode_next_cycles() {
|
||||
let start = WhoMode::Expert;
|
||||
let m = start.next().next().next().next().next();
|
||||
assert_eq!(m, start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_mode_from_number() {
|
||||
assert_eq!(WhoMode::from_number(1), Some(WhoMode::Expert));
|
||||
assert_eq!(WhoMode::from_number(5), Some(WhoMode::Overlap));
|
||||
assert_eq!(WhoMode::from_number(0), None);
|
||||
assert_eq!(WhoMode::from_number(6), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_who_state_default() {
|
||||
let state = WhoState::default();
|
||||
assert_eq!(state.mode, WhoMode::Expert);
|
||||
assert!(state.result.is_none());
|
||||
assert!(!state.include_closed);
|
||||
assert_eq!(state.generation, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_mode_bumps_generation() {
|
||||
let mut state = WhoState::default();
|
||||
let generation = state.set_mode(WhoMode::Workload);
|
||||
assert_eq!(generation, 1);
|
||||
assert_eq!(state.mode, WhoMode::Workload);
|
||||
assert!(state.result.is_none());
|
||||
assert!(state.username_focused);
|
||||
assert!(!state.path_focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_mode_same_does_not_bump() {
|
||||
let mut state = WhoState::default();
|
||||
let generation = state.set_mode(WhoMode::Expert);
|
||||
assert_eq!(generation, 0); // No bump for same mode.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_include_closed_returns_gen_for_affected_modes() {
|
||||
let state = &mut WhoState {
|
||||
mode: WhoMode::Workload,
|
||||
..WhoState::default()
|
||||
};
|
||||
let generation = state.toggle_include_closed();
|
||||
assert!(generation.is_some());
|
||||
assert!(state.include_closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toggle_include_closed_returns_none_for_unaffected_modes() {
|
||||
let state = &mut WhoState {
|
||||
mode: WhoMode::Expert,
|
||||
..WhoState::default()
|
||||
};
|
||||
let generation = state.toggle_include_closed();
|
||||
assert!(generation.is_none());
|
||||
assert!(state.include_closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_response_guard() {
|
||||
let mut state = WhoState::default();
|
||||
let stale_gen = state.submit();
|
||||
// Bump generation again (simulating user changed mode).
|
||||
let _new_gen = state.set_mode(WhoMode::Active);
|
||||
// Old response arrives — should be discarded.
|
||||
state.apply_results(
|
||||
stale_gen,
|
||||
WhoResult::Active(lore::core::who_types::ActiveResult {
|
||||
discussions: vec![],
|
||||
total_unresolved_in_window: 0,
|
||||
truncated: false,
|
||||
}),
|
||||
);
|
||||
assert!(state.result.is_none()); // Stale, discarded.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_delete_char() {
|
||||
let mut state = WhoState {
|
||||
path_focused: true,
|
||||
..WhoState::default()
|
||||
};
|
||||
state.insert_char('s');
|
||||
state.insert_char('r');
|
||||
state.insert_char('c');
|
||||
assert_eq!(state.path, "src");
|
||||
assert_eq!(state.path_cursor, 3);
|
||||
|
||||
state.delete_char_before_cursor();
|
||||
assert_eq!(state.path, "sr");
|
||||
assert_eq!(state.path_cursor, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_movement() {
|
||||
let mut state = WhoState {
|
||||
username_focused: true,
|
||||
username: "alice".into(),
|
||||
username_cursor: 5,
|
||||
..WhoState::default()
|
||||
};
|
||||
|
||||
state.cursor_left();
|
||||
assert_eq!(state.username_cursor, 4);
|
||||
state.cursor_right();
|
||||
assert_eq!(state.username_cursor, 5);
|
||||
// Right at end is clamped.
|
||||
state.cursor_right();
|
||||
assert_eq!(state.username_cursor, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_prev_next() {
|
||||
let mut state = WhoState::default();
|
||||
state.select_next(5);
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_next(5);
|
||||
assert_eq!(state.selected_index, 2);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 1);
|
||||
state.select_prev();
|
||||
assert_eq!(state.selected_index, 0);
|
||||
state.select_prev(); // Should not underflow.
|
||||
assert_eq!(state.selected_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_visible() {
|
||||
let mut state = WhoState {
|
||||
selected_index: 15,
|
||||
..WhoState::default()
|
||||
};
|
||||
state.ensure_visible(5);
|
||||
assert_eq!(state.scroll_offset, 11); // 15 - 5 + 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_enter_focuses_correct_input() {
|
||||
let mut state = WhoState {
|
||||
mode: WhoMode::Expert,
|
||||
..WhoState::default()
|
||||
};
|
||||
state.enter();
|
||||
assert!(state.path_focused);
|
||||
assert!(!state.username_focused);
|
||||
|
||||
state.mode = WhoMode::Reviews;
|
||||
state.enter();
|
||||
assert!(!state.path_focused);
|
||||
// Reviews needs username.
|
||||
// focus_input_for_mode is called in enter().
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_affected_by_include_closed() {
|
||||
assert!(WhoMode::Workload.affected_by_include_closed());
|
||||
assert!(WhoMode::Active.affected_by_include_closed());
|
||||
assert!(!WhoMode::Expert.affected_by_include_closed());
|
||||
assert!(!WhoMode::Reviews.affected_by_include_closed());
|
||||
assert!(!WhoMode::Overlap.affected_by_include_closed());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user