- Add get_mut() and clear() methods to EntityCache<V> - Add CachedIssuePayload / CachedMrPayload types to state - Wire cache check in navigate_to for instant cache hits - Populate cache on IssueDetailLoaded / MrDetailLoaded - Update cache on DiscussionsLoaded - Add 6 new entity_cache tests (get_mut, clear)
513 lines
16 KiB
Rust
513 lines
16 KiB
Rust
#![allow(dead_code)] // Phase 3: consumed by view/mod.rs screen dispatch
|
|
|
|
//! Search screen view — query bar, mode indicator, and results list.
|
|
//!
|
|
//! Layout:
|
|
//! ```text
|
|
//! +--[ FTS ]--- Search ──────────────────────+
|
|
//! | > query text here_ |
|
|
//! +───────────────────────────────────────────+
|
|
//! | #42 Fix login bug group/proj |
|
|
//! | !99 Add retry logic group/proj |
|
|
//! | #10 Update docs other/repo |
|
|
//! +───────────────────────────────────────────+
|
|
//! | Tab: mode /: focus j/k: nav Enter: go |
|
|
//! +───────────────────────────────────────────+
|
|
//! ```
|
|
|
|
use ftui::core::geometry::Rect;
|
|
use ftui::render::cell::Cell;
|
|
use ftui::render::drawing::Draw;
|
|
use ftui::render::frame::Frame;
|
|
|
|
use crate::layout::{classify_width, search_show_project};
|
|
use crate::message::EntityKind;
|
|
use crate::state::search::SearchState;
|
|
use crate::text_width::cursor_cell_offset;
|
|
|
|
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// render_search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Render the search screen.
|
|
///
|
|
/// Composes: mode indicator + query bar (row 0), separator (row 1),
|
|
/// results list (fill), and a hint bar at the bottom.
|
|
pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
|
|
if area.height < 4 || area.width < 20 {
|
|
return;
|
|
}
|
|
|
|
let bp = classify_width(area.width);
|
|
let show_project = search_show_project(bp);
|
|
let mut y = area.y;
|
|
let max_x = area.right();
|
|
|
|
// -- Mode indicator + query bar ------------------------------------------
|
|
y = render_query_bar(frame, state, area.x, y, area.width, max_x);
|
|
|
|
// -- Separator -----------------------------------------------------------
|
|
if y >= area.bottom() {
|
|
return;
|
|
}
|
|
let sep_cell = Cell {
|
|
fg: BORDER,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
let sep_line = "─".repeat(area.width as usize);
|
|
frame.print_text_clipped(area.x, y, &sep_line, sep_cell, max_x);
|
|
y += 1;
|
|
|
|
// -- No-index warning ----------------------------------------------------
|
|
if !state.capabilities.has_any_index() {
|
|
if y >= area.bottom() {
|
|
return;
|
|
}
|
|
let warn_cell = Cell {
|
|
fg: ACCENT,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(area.x + 1, y, "No search indexes found.", warn_cell, max_x);
|
|
y += 1;
|
|
if y < area.bottom() {
|
|
let hint_cell = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(
|
|
area.x + 1,
|
|
y,
|
|
"Run: lore generate-docs && lore embed",
|
|
hint_cell,
|
|
max_x,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// -- Results list --------------------------------------------------------
|
|
let bottom_hint_row = area.bottom().saturating_sub(1);
|
|
let list_bottom = bottom_hint_row;
|
|
let list_height = list_bottom.saturating_sub(y) as usize;
|
|
|
|
if list_height == 0 {
|
|
return;
|
|
}
|
|
|
|
if state.results.is_empty() {
|
|
render_empty_state(frame, state, area.x + 1, y, max_x);
|
|
} else {
|
|
render_result_list(
|
|
frame,
|
|
state,
|
|
area.x,
|
|
y,
|
|
area.width,
|
|
list_height,
|
|
show_project,
|
|
);
|
|
}
|
|
|
|
// -- Bottom hint bar -----------------------------------------------------
|
|
if bottom_hint_row < area.bottom() {
|
|
render_hint_bar(frame, state, area.x, bottom_hint_row, max_x);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Query bar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Render the mode badge and query input. Returns the next y position.
|
|
fn render_query_bar(
|
|
frame: &mut Frame<'_>,
|
|
state: &SearchState,
|
|
x: u16,
|
|
y: u16,
|
|
width: u16,
|
|
max_x: u16,
|
|
) -> u16 {
|
|
// Mode badge: [ FTS ] or [ Hybrid ] or [ Vec ]
|
|
let mode_label = format!("[ {} ]", state.mode.label());
|
|
let mode_cell = Cell {
|
|
fg: ACCENT,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
let after_mode = frame.print_text_clipped(x, y, &mode_label, mode_cell, max_x);
|
|
|
|
// Space separator.
|
|
let after_sep = frame.print_text_clipped(
|
|
after_mode,
|
|
y,
|
|
" ",
|
|
Cell {
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
},
|
|
max_x,
|
|
);
|
|
|
|
// Prompt.
|
|
let prompt = "> ";
|
|
let prompt_cell = Cell {
|
|
fg: ACCENT,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
let after_prompt = frame.print_text_clipped(after_sep, y, prompt, prompt_cell, max_x);
|
|
|
|
// Query text (or placeholder).
|
|
let (display_text, text_fg) = if state.query.is_empty() {
|
|
("Type to search...", TEXT_MUTED)
|
|
} else {
|
|
(state.query.as_str(), TEXT)
|
|
};
|
|
let text_cell = Cell {
|
|
fg: text_fg,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x);
|
|
|
|
// Cursor (only when focused and has query text).
|
|
if state.query_focused && !state.query.is_empty() {
|
|
let cursor_x = after_prompt + cursor_cell_offset(&state.query, state.cursor);
|
|
if cursor_x < max_x {
|
|
let cursor_cell = Cell {
|
|
fg: BG_SURFACE,
|
|
bg: TEXT,
|
|
..Cell::default()
|
|
};
|
|
let cursor_char = state
|
|
.query
|
|
.get(state.cursor..)
|
|
.and_then(|s| s.chars().next())
|
|
.unwrap_or(' ');
|
|
frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x);
|
|
}
|
|
}
|
|
|
|
// Loading indicator (right-aligned).
|
|
if state.loading {
|
|
let loading_text = " searching... ";
|
|
let loading_x = (x + width).saturating_sub(loading_text.len() as u16);
|
|
let loading_cell = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(loading_x, y, loading_text, loading_cell, max_x);
|
|
}
|
|
|
|
y + 1
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Empty state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Show a message when there are no results.
|
|
fn render_empty_state(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) {
|
|
let msg = if state.query.is_empty() {
|
|
"Enter a search query above"
|
|
} else {
|
|
"No results found"
|
|
};
|
|
let cell = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(x, y, msg, cell, max_x);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Result list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Render the scrollable list of search results.
|
|
fn render_result_list(
|
|
frame: &mut Frame<'_>,
|
|
state: &SearchState,
|
|
x: u16,
|
|
start_y: u16,
|
|
width: u16,
|
|
list_height: usize,
|
|
show_project: bool,
|
|
) {
|
|
let max_x = x + width;
|
|
|
|
// Scroll so selected item is always visible.
|
|
let scroll_offset = if state.selected_index >= list_height {
|
|
state.selected_index - list_height + 1
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let normal = Cell {
|
|
fg: TEXT,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
let selected = Cell {
|
|
fg: BG_SURFACE,
|
|
bg: ACCENT,
|
|
..Cell::default()
|
|
};
|
|
let muted = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
let muted_selected = Cell {
|
|
fg: BG_SURFACE,
|
|
bg: ACCENT,
|
|
..Cell::default()
|
|
};
|
|
|
|
for (i, result) in state
|
|
.results
|
|
.iter()
|
|
.skip(scroll_offset)
|
|
.enumerate()
|
|
.take(list_height)
|
|
{
|
|
let y = start_y + i as u16;
|
|
let is_selected = i + scroll_offset == state.selected_index;
|
|
|
|
let (label_style, detail_style) = if is_selected {
|
|
(selected, muted_selected)
|
|
} else {
|
|
(normal, muted)
|
|
};
|
|
|
|
// Fill row background for selected item.
|
|
if is_selected {
|
|
for col in x..max_x {
|
|
frame.buffer.set(col, y, selected);
|
|
}
|
|
}
|
|
|
|
// Entity prefix: # for issues, ! for MRs.
|
|
let prefix = match result.key.kind {
|
|
EntityKind::Issue => "#",
|
|
EntityKind::MergeRequest => "!",
|
|
};
|
|
let iid_str = format!("{}{}", prefix, result.key.iid);
|
|
let after_iid = frame.print_text_clipped(x + 1, y, &iid_str, label_style, max_x);
|
|
|
|
// Title.
|
|
let after_title =
|
|
frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x);
|
|
|
|
// Project path (right-aligned, hidden on narrow terminals).
|
|
if show_project {
|
|
let path_width = result.project_path.len() as u16 + 2;
|
|
let path_x = max_x.saturating_sub(path_width);
|
|
if path_x > after_title + 1 {
|
|
frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scroll indicator (overlaid on last visible row when results overflow).
|
|
if state.results.len() > list_height && list_height > 0 {
|
|
let indicator = format!(
|
|
" {}/{} ",
|
|
(scroll_offset + list_height).min(state.results.len()),
|
|
state.results.len()
|
|
);
|
|
let ind_x = max_x.saturating_sub(indicator.len() as u16);
|
|
let ind_y = start_y + list_height.saturating_sub(1) as u16;
|
|
let ind_cell = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
frame.print_text_clipped(ind_x, ind_y, &indicator, ind_cell, max_x);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hint bar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Render keybinding hints at the bottom of the search screen.
|
|
fn render_hint_bar(frame: &mut Frame<'_>, state: &SearchState, x: u16, y: u16, max_x: u16) {
|
|
let hint_cell = Cell {
|
|
fg: TEXT_MUTED,
|
|
bg: BG_SURFACE,
|
|
..Cell::default()
|
|
};
|
|
|
|
let hints = if state.query_focused {
|
|
"Tab: mode Esc: blur Enter: search"
|
|
} else {
|
|
"Tab: mode /: focus j/k: nav Enter: open"
|
|
};
|
|
|
|
frame.print_text_clipped(x + 1, y, hints, hint_cell, max_x);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::message::{EntityKey, SearchResult};
|
|
use crate::state::search::{SearchCapabilities, SearchState};
|
|
use ftui::render::grapheme_pool::GraphemePool;
|
|
|
|
macro_rules! with_frame {
|
|
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
|
let mut pool = GraphemePool::new();
|
|
let mut $frame = Frame::new($width, $height, &mut pool);
|
|
$body
|
|
}};
|
|
}
|
|
|
|
fn fts_caps() -> SearchCapabilities {
|
|
SearchCapabilities {
|
|
has_fts: true,
|
|
has_embeddings: false,
|
|
embedding_coverage_pct: 0.0,
|
|
}
|
|
}
|
|
|
|
fn sample_results(count: usize) -> Vec<SearchResult> {
|
|
(0..count)
|
|
.map(|i| SearchResult {
|
|
key: EntityKey::issue(1, (i + 1) as i64),
|
|
title: format!("Result {}", i + 1),
|
|
score: 1.0 - (i as f64 * 0.1),
|
|
snippet: "matched text".into(),
|
|
project_path: "group/project".into(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_empty_no_panic() {
|
|
with_frame!(80, 24, |frame| {
|
|
let state = SearchState::default();
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_with_capabilities_no_panic() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_with_results_no_panic() {
|
|
with_frame!(100, 30, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.results = sample_results(5);
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 100, 30));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_with_query_no_panic() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.insert_char('h');
|
|
state.insert_char('e');
|
|
state.insert_char('l');
|
|
state.insert_char('l');
|
|
state.insert_char('o');
|
|
state.results = sample_results(3);
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_with_selection_no_panic() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.results = sample_results(10);
|
|
state.select_next();
|
|
state.select_next();
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_tiny_terminal_noop() {
|
|
with_frame!(15, 3, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 15, 3));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_no_indexes_warning() {
|
|
with_frame!(80, 24, |frame| {
|
|
let state = SearchState::default();
|
|
// capabilities are default (no indexes)
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
|
// Should show "No search indexes found" without panicking.
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_loading_indicator() {
|
|
with_frame!(80, 24, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.loading = true;
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 24));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_scrollable_results() {
|
|
with_frame!(80, 10, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.results = sample_results(20);
|
|
// Select item near the bottom to trigger scrolling.
|
|
for _ in 0..15 {
|
|
state.select_next();
|
|
}
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 80, 10));
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_search_responsive_breakpoints() {
|
|
// Narrow (Xs=50): project path hidden.
|
|
with_frame!(50, 24, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.results = sample_results(3);
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 50, 24));
|
|
});
|
|
|
|
// Standard (Md=100): project path shown.
|
|
with_frame!(100, 24, |frame| {
|
|
let mut state = SearchState::default();
|
|
state.enter(fts_caps());
|
|
state.results = sample_results(3);
|
|
render_search(&mut frame, &state, Rect::new(0, 0, 100, 24));
|
|
});
|
|
}
|
|
}
|