1. **Citation::Page 분기 fix** — `args.push(format!(\"# page {page}\"))` 가
vim/code/cursor 에 \"두 번째 파일\" 로 해석돼 의도 외 동작 (split / new
buffer). 마지막 push 제거, path 만 열고 `tracing::debug!` 한 줄.
PDF 페이지 jump 는 사용자 PDF reader 책임 — `KEBAB_EDITOR_JUMP_FORMAT`
env hook 은 P+ enhancement.
2. **j/k/g 의 SHIFT modifier 차단** — `is_typing_mod` 가 SHIFT 를 typing
으로 취급하던 부분이 J/K/G 를 selection 키로 흡수해 \"JSON\" / \"PostgreSQL\"
/ \"Go\" 같은 대문자 검색어 깨짐. arrow 키 (Down/Up) 는 modifier 무관 유지,
문자 키 (j/k/g) 는 `KeyModifiers::NONE` 만. SHIFT-J / SHIFT-G 회귀 테스트
2건 추가.
3. **`format_hit_lines` 의 unused `_width` 인자 제거** — ratatui 자동
truncate 신뢰 (Library 의 한국어 column 정렬은 별도 path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
482 lines
17 KiB
Rust
482 lines
17 KiB
Rust
//! Search pane (P9-2).
|
|
//!
|
|
//! `App.search` slot is filled lazily by the run loop on first
|
|
//! `Pane::Search` switch. `handle_key_search` mutates only
|
|
//! `app.search` (parallel-safety contract from p9-1) — never touches
|
|
//! Library / Ask / Inspect state.
|
|
//!
|
|
//! Spec deviation (HOTFIXES `2026-05-02 P9-2`):
|
|
//! - `render_search<B: Backend>` generic dropped (ratatui 0.28 Frame
|
|
//! is backend-agnostic, same as P9-1).
|
|
//! - `jump_to_citation` gained a `workspace_root: &Path` argument
|
|
//! missing from spec literal — citations carry workspace-relative
|
|
//! paths and the editor needs an absolute path to open.
|
|
//!
|
|
//! Per design §1.5 / §1.6 (search output dense format), §3.7
|
|
//! (`SearchHit`), §0 Q3 (citation URI fragments).
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use kebab_core::{Citation, SearchHit, SearchMode, SearchQuery};
|
|
use ratatui::Frame;
|
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
|
|
use crate::app::{App, KeyOutcome, Pane, SearchState};
|
|
use crate::error_popup::ErrorOverlay;
|
|
|
|
/// Debounce window after the last keystroke before re-searching.
|
|
/// Matches the spec's 200 ms.
|
|
pub const SEARCH_DEBOUNCE: Duration = Duration::from_millis(200);
|
|
|
|
/// Maximum hits to fetch per query — matches `config.search.default_k`
|
|
/// in production but the trait does not expose `Config`, so we cap
|
|
/// here. Users running deep recall should `kebab search --json` for
|
|
/// large `k`.
|
|
const SEARCH_K: usize = 10;
|
|
|
|
/// Render the Search pane: input bar (top), result list (middle),
|
|
/// preview (bottom). Each result row uses §1.5's 4-line dense format.
|
|
pub fn render_search(f: &mut Frame, area: Rect, state: &App) {
|
|
let Some(s) = state.search.as_ref() else {
|
|
// Pane has no state yet — should not happen because the run
|
|
// loop lazy-inits before render. Defensive empty block.
|
|
f.render_widget(
|
|
Block::default().title("Search").borders(Borders::ALL),
|
|
area,
|
|
);
|
|
return;
|
|
};
|
|
|
|
let layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3),
|
|
Constraint::Min(3),
|
|
Constraint::Length(7),
|
|
])
|
|
.split(area);
|
|
|
|
render_input_bar(f, layout[0], s);
|
|
render_result_list(f, layout[1], s);
|
|
render_preview(f, layout[2], s);
|
|
}
|
|
|
|
fn render_input_bar(f: &mut Frame, area: Rect, s: &SearchState) {
|
|
let mode_label = mode_label(s.mode);
|
|
let searching_hint = if s.searching { " searching…" } else { "" };
|
|
let line = Line::from(vec![
|
|
Span::styled(format!("[{mode_label}] "), Style::default().fg(Color::Cyan)),
|
|
Span::raw(s.input.as_str()),
|
|
Span::styled(searching_hint, Style::default().add_modifier(Modifier::DIM)),
|
|
]);
|
|
let block = Block::default()
|
|
.title("query (Tab=mode Enter=search Esc=back)")
|
|
.borders(Borders::ALL);
|
|
f.render_widget(Paragraph::new(line).block(block), area);
|
|
}
|
|
|
|
fn mode_label(m: SearchMode) -> &'static str {
|
|
match m {
|
|
SearchMode::Lexical => "lexical",
|
|
SearchMode::Vector => "vector",
|
|
SearchMode::Hybrid => "hybrid",
|
|
}
|
|
}
|
|
|
|
fn render_result_list(f: &mut Frame, area: Rect, s: &SearchState) {
|
|
let block = Block::default()
|
|
.title(format!("results ({})", s.hits.len()))
|
|
.borders(Borders::ALL);
|
|
|
|
if s.hits.is_empty() {
|
|
f.render_widget(block, area);
|
|
return;
|
|
}
|
|
|
|
let items: Vec<ListItem> = s
|
|
.hits
|
|
.iter()
|
|
.map(|h| ListItem::new(format_hit_lines(h)))
|
|
.collect();
|
|
let list = List::new(items)
|
|
.block(block)
|
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
|
|
.highlight_symbol("> ");
|
|
let mut list_state = ListState::default();
|
|
list_state.select(Some(s.selected_hit.min(s.hits.len().saturating_sub(1))));
|
|
f.render_stateful_widget(list, area, &mut list_state);
|
|
}
|
|
|
|
/// §1.5 dense format — 4 lines per hit:
|
|
/// 1. `<rank>. <fusion_score> <path#frag>`
|
|
/// 2. `<heading_path joined by " / "> | section_label?`
|
|
/// 3. snippet line 1
|
|
/// 4. snippet line 2 (or trailing blank for layout symmetry)
|
|
fn format_hit_lines(h: &SearchHit) -> Vec<Line<'static>> {
|
|
let header = format!(
|
|
"{}. {:.4} {}",
|
|
h.rank,
|
|
h.retrieval.fusion_score,
|
|
h.citation.to_uri(),
|
|
);
|
|
let path_line = {
|
|
let hp = if h.heading_path.is_empty() {
|
|
String::from("-")
|
|
} else {
|
|
h.heading_path.join(" / ")
|
|
};
|
|
match h.section_label.as_deref() {
|
|
Some(s) if !s.is_empty() => format!(" {hp} | {s}"),
|
|
_ => format!(" {hp}"),
|
|
}
|
|
};
|
|
let mut snippet_lines = h.snippet.lines();
|
|
let s1 = snippet_lines.next().unwrap_or("").to_string();
|
|
let s2 = snippet_lines.next().unwrap_or("").to_string();
|
|
vec![
|
|
Line::from(Span::styled(
|
|
header,
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
)),
|
|
Line::from(Span::styled(path_line, Style::default().fg(Color::DarkGray))),
|
|
Line::from(format!(" {s1}")),
|
|
Line::from(format!(" {s2}")),
|
|
]
|
|
}
|
|
|
|
fn render_preview(f: &mut Frame, area: Rect, s: &SearchState) {
|
|
let block = Block::default()
|
|
.title("preview (g=open in $EDITOR)")
|
|
.borders(Borders::ALL);
|
|
let body = match (&s.preview, s.hits.is_empty()) {
|
|
(_, true) => Paragraph::new(""),
|
|
(Some(text), _) => Paragraph::new(text.as_str()).wrap(Wrap { trim: false }),
|
|
(None, _) => Paragraph::new(Span::styled(
|
|
"(loading preview… select a hit to fetch its chunk text)",
|
|
Style::default().add_modifier(Modifier::DIM),
|
|
)),
|
|
};
|
|
f.render_widget(body.block(block), area);
|
|
}
|
|
|
|
/// Search pane key dispatch. Returns `KeyOutcome::Refresh` when the
|
|
/// run loop should re-fire `kebab-app::search`. Pure mutation on
|
|
/// `app.search` — never touches another pane's state.
|
|
pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome {
|
|
if state.error_overlay.is_some() {
|
|
state.error_overlay = None;
|
|
return KeyOutcome::Continue;
|
|
}
|
|
if state.search.is_none() {
|
|
// No search state — bail back to Library.
|
|
return KeyOutcome::SwitchPane(Pane::Library);
|
|
}
|
|
|
|
// `g` (editor jump) requires re-borrowing `state` for
|
|
// workspace_root after dropping the `&mut state.search` borrow.
|
|
// Handle it as a pre-pass so the rest of the function can use
|
|
// `state.search.as_mut()` without scope juggling.
|
|
// `g` only fires the editor jump on plain (no-modifier) press —
|
|
// SHIFT-G in vim land is "go to bottom" (not implemented here),
|
|
// and CTRL/ALT chords stay reserved.
|
|
if matches!(
|
|
(key.code, key.modifiers),
|
|
(KeyCode::Char('g'), KeyModifiers::NONE)
|
|
) {
|
|
let (citation, has_hits) = {
|
|
let s = state.search.as_ref().unwrap();
|
|
if s.hits.is_empty() {
|
|
(None, false)
|
|
} else {
|
|
(Some(s.hits[s.selected_hit].citation.clone()), true)
|
|
}
|
|
};
|
|
if has_hits {
|
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
|
|
let workspace_root = std::path::PathBuf::from(&state.config.workspace.root);
|
|
if let Err(e) = jump_to_citation(&citation.unwrap(), &editor, &workspace_root) {
|
|
state.error_overlay = Some(ErrorOverlay::from_anyhow(&e));
|
|
}
|
|
}
|
|
return KeyOutcome::Continue;
|
|
}
|
|
|
|
let s = state.search.as_mut().unwrap();
|
|
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Esc, _) => KeyOutcome::SwitchPane(Pane::Library),
|
|
(KeyCode::Tab, _) => {
|
|
s.mode = cycle_mode(s.mode);
|
|
// Force re-search at the new mode if there's a query.
|
|
if !s.input.trim().is_empty() {
|
|
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
|
|
}
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Enter, _) => {
|
|
// Skip debounce; refresh now if there's anything to query.
|
|
if s.input.trim().is_empty() {
|
|
KeyOutcome::Continue
|
|
} else {
|
|
s.input_dirty_at = None;
|
|
s.last_query = None;
|
|
KeyOutcome::Refresh
|
|
}
|
|
}
|
|
// `j` / `k` only fire as selection movers when *no* modifier is
|
|
// held. SHIFT-bearing keypresses (`J`, `K`) are typed input —
|
|
// letting them through here would corrupt every \"JSON\" /
|
|
// \"PostgreSQL\" search query. Down / Up arrows still accept
|
|
// any modifier (no typing collision).
|
|
(KeyCode::Char('j'), KeyModifiers::NONE) => {
|
|
move_selection(s, 1);
|
|
s.preview = None;
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Down, m) if !is_typing_mod(m) => {
|
|
move_selection(s, 1);
|
|
s.preview = None;
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char('k'), KeyModifiers::NONE) => {
|
|
move_selection(s, -1);
|
|
s.preview = None;
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Up, m) if !is_typing_mod(m) => {
|
|
move_selection(s, -1);
|
|
s.preview = None;
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Backspace, _) => {
|
|
if !s.input.is_empty() {
|
|
s.input.pop();
|
|
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
|
|
}
|
|
KeyOutcome::Continue
|
|
}
|
|
(KeyCode::Char(c), _) => {
|
|
// Treat 'g' separately above; here 'g' would reach this
|
|
// branch only when `is_typing_mod` triggered — i.e. SHIFT
|
|
// 'G'. Fold into typing.
|
|
s.input.push(c);
|
|
s.input_dirty_at = Some(time::OffsetDateTime::now_utc());
|
|
KeyOutcome::Continue
|
|
}
|
|
_ => KeyOutcome::Continue,
|
|
}
|
|
}
|
|
|
|
fn cycle_mode(m: SearchMode) -> SearchMode {
|
|
match m {
|
|
SearchMode::Lexical => SearchMode::Vector,
|
|
SearchMode::Vector => SearchMode::Hybrid,
|
|
SearchMode::Hybrid => SearchMode::Lexical,
|
|
}
|
|
}
|
|
|
|
fn is_typing_mod(m: KeyModifiers) -> bool {
|
|
// SHIFT alone is fine for typing capital letters, but CTRL/ALT
|
|
// means a chord — don't swallow as input.
|
|
m.contains(KeyModifiers::CONTROL) || m.contains(KeyModifiers::ALT)
|
|
}
|
|
|
|
fn move_selection(s: &mut SearchState, delta: i32) {
|
|
if s.hits.is_empty() {
|
|
return;
|
|
}
|
|
let current = s.selected_hit as i32;
|
|
let last = (s.hits.len() as i32) - 1;
|
|
let next = (current + delta).clamp(0, last);
|
|
s.selected_hit = next as usize;
|
|
}
|
|
|
|
/// Build the editor command for a citation. Splits out from
|
|
/// `jump_to_citation` so unit tests can assert command shape without
|
|
/// spawning a process.
|
|
///
|
|
/// Returns `(program, args)` where `program` is the `$EDITOR` value
|
|
/// (or `vi` fallback) and `args` opens the file at the cited line /
|
|
/// page / region (best-effort for non-text citations).
|
|
pub fn build_jump_command(
|
|
citation: &Citation,
|
|
editor_env: &str,
|
|
workspace_root: &Path,
|
|
) -> (String, Vec<String>) {
|
|
let (program, leading_args) = parse_editor_env(editor_env);
|
|
let path = workspace_root.join(&citation.path().0);
|
|
let path_str = path.to_string_lossy().into_owned();
|
|
let mut args = leading_args;
|
|
|
|
let editor_basename = std::path::Path::new(&program)
|
|
.file_name()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| program.clone());
|
|
|
|
match citation {
|
|
Citation::Line { start, .. } => {
|
|
if editor_basename.contains("code") || editor_basename.contains("cursor") {
|
|
// VS Code / Cursor: `code -g <path>:<line>`
|
|
args.push("-g".into());
|
|
args.push(format!("{path_str}:{start}"));
|
|
} else {
|
|
// vim / nvim / vi / emacs / hx all accept `+<N>`.
|
|
args.push(format!("+{start}"));
|
|
args.push(path_str);
|
|
}
|
|
}
|
|
Citation::Page { page, .. } => {
|
|
// No standard editor jump for PDFs across vim / VS Code /
|
|
// emacs. Earlier versions of this branch tried to push a
|
|
// `# page N` string as a final arg, but every common
|
|
// editor treats it as a *second file to open* — opening
|
|
// a stray buffer or splitting the window. Path-only is
|
|
// the honest best-effort: the user's PDF reader (or the
|
|
// editor's PDF plugin) handles in-document navigation.
|
|
// A `KEBAB_EDITOR_JUMP_FORMAT="pdf=evince -p {page} {path}"`
|
|
// env hook stays a P+ enhancement (per spec § Risks).
|
|
tracing::debug!(
|
|
target: "kebab-tui",
|
|
page,
|
|
"PDF citation — opening file only; editor page-jump unsupported"
|
|
);
|
|
args.push(path_str);
|
|
}
|
|
_ => {
|
|
args.push(path_str);
|
|
}
|
|
}
|
|
(program, args)
|
|
}
|
|
|
|
/// Suspend the TUI raw mode, spawn `$EDITOR`, restore raw mode on
|
|
/// return. Errors propagate; raw-mode restore happens via a guard so
|
|
/// a panic during the editor child does not strand the user in a
|
|
/// corrupt terminal.
|
|
pub fn jump_to_citation(
|
|
citation: &Citation,
|
|
editor_env: &str,
|
|
workspace_root: &Path,
|
|
) -> anyhow::Result<()> {
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{
|
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
|
};
|
|
|
|
let (program, args) = build_jump_command(citation, editor_env, workspace_root);
|
|
|
|
// Suspend.
|
|
let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
|
|
let _ = disable_raw_mode();
|
|
|
|
// RAII guard re-enters even on panic.
|
|
struct RawModeRestore;
|
|
impl Drop for RawModeRestore {
|
|
fn drop(&mut self) {
|
|
let _ = enable_raw_mode();
|
|
let _ = execute!(std::io::stdout(), EnterAlternateScreen);
|
|
}
|
|
}
|
|
let _restore = RawModeRestore;
|
|
|
|
let status = Command::new(&program)
|
|
.args(&args)
|
|
.status()
|
|
.map_err(|e| anyhow::anyhow!("spawn {program} failed: {e}"))?;
|
|
if !status.success() {
|
|
anyhow::bail!("{program} exited with {status:?}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_editor_env(env: &str) -> (String, Vec<String>) {
|
|
// `$EDITOR` may carry args, e.g. `vim -p`. Split on whitespace.
|
|
let mut parts = env.split_whitespace();
|
|
let program = parts.next().unwrap_or("vi").to_string();
|
|
let leading: Vec<String> = parts.map(str::to_string).collect();
|
|
(program, leading)
|
|
}
|
|
|
|
/// Run-loop hook: tick called every poll cycle. Returns `true` if a
|
|
/// search should fire this tick (debounce expired and query
|
|
/// changed).
|
|
pub(crate) fn debounce_due(s: &SearchState) -> bool {
|
|
let Some(at) = s.input_dirty_at else { return false };
|
|
let elapsed = (time::OffsetDateTime::now_utc() - at)
|
|
.try_into()
|
|
.unwrap_or(Duration::ZERO);
|
|
if elapsed < SEARCH_DEBOUNCE {
|
|
return false;
|
|
}
|
|
let q = s.input.trim();
|
|
if q.is_empty() {
|
|
return false;
|
|
}
|
|
!matches!(
|
|
&s.last_query,
|
|
Some((prev_input, prev_mode))
|
|
if prev_input == &s.input && *prev_mode == s.mode
|
|
)
|
|
}
|
|
|
|
/// Run-loop hook: actually perform the search, populate `hits`. The
|
|
/// state's `input_dirty_at` is cleared, `last_query` snapshots, and
|
|
/// `searching` flag toggles around the call.
|
|
pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> {
|
|
let cfg = state.config.clone();
|
|
let (q_text, mode) = {
|
|
let s = state.search.as_mut().expect("Search slot must exist");
|
|
s.searching = true;
|
|
s.input_dirty_at = None;
|
|
s.last_query = Some((s.input.clone(), s.mode));
|
|
(s.input.clone(), s.mode)
|
|
};
|
|
let query = SearchQuery {
|
|
text: q_text,
|
|
mode,
|
|
k: SEARCH_K,
|
|
filters: kebab_core::SearchFilters::default(),
|
|
};
|
|
let result = kebab_app::search_with_config(cfg, query);
|
|
let s = state.search.as_mut().expect("Search slot must exist");
|
|
s.searching = false;
|
|
match result {
|
|
Ok(hits) => {
|
|
s.hits = hits;
|
|
s.selected_hit = 0;
|
|
s.preview = None;
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
s.hits.clear();
|
|
s.selected_hit = 0;
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run-loop hook: lazy-fetch preview text for the selected hit.
|
|
pub(crate) fn refresh_preview(state: &mut App) -> anyhow::Result<()> {
|
|
let cfg = state.config.clone();
|
|
let chunk_id = {
|
|
let s = state.search.as_ref().expect("Search slot must exist");
|
|
if s.preview.is_some() || s.hits.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let Some(hit) = s.hits.get(s.selected_hit) else {
|
|
return Ok(());
|
|
};
|
|
hit.chunk_id.clone()
|
|
};
|
|
let chunk = kebab_app::inspect_chunk_with_config(cfg, &chunk_id)?;
|
|
let s = state.search.as_mut().expect("Search slot must exist");
|
|
s.preview = Some(chunk.text);
|
|
Ok(())
|
|
}
|
|
|