feat(kebab-tui): P9-2 Search pane — input + dense hits + preview + editor jump
Library 의 / 키가 활성화. App.search slot 이 lazy 채워지고 (run loop 가 SwitchPane(Search) 받을 때),
debounce 200 ms 후 kebab-app::search 호출, 선택된 hit 의 chunk 를 preview pane 에 표시.
g 키로 $EDITOR (vim/nvim/code/cursor 자동 감지) 에서 citation 위치 열림.
핵심:
- SearchState 본체 (`app.rs` 의 forward decl 채움) — input / mode / hits /
selected_hit / input_dirty_at / last_query / searching / preview.
- `src/search.rs` (신규):
- `render_search(f, area, state)` — 3-pane layout (input bar / 결과 리스트 / preview).
각 hit 는 §1.5 dense 4-line format (rank.score URI / heading / snippet).
- `handle_key_search`: typing → input + dirty mark. Tab → mode 순환. Enter →
immediate refresh. j/k → 선택 이동 + preview invalidate. g → editor jump
(RAII raw-mode suspend). Esc → Library 복귀.
- `build_jump_command(citation, editor_env, workspace_root)` 가 vim 류
`+<line> path` / VS Code `code -g path:line` / cursor `cursor -g`
자동 분기. unit test 로 잠금.
- `jump_to_citation` 가 raw-mode + AltScreen 을 RAII 로 suspend/restore
(panic 안전).
- run-loop hook 4 함수: `debounce_due` / `fire_search` /
`refresh_preview` (private to crate).
- run.rs:
- Pane::Search arm 이 `handle_key_search` 로 dispatch + `render_search`.
- SwitchPane(Search) 시 `app.search = Some(SearchState::default())` lazy init.
- Idle tick 마다 debounce_due → fire_search, preview None → refresh_preview.
- 테스트 13개 (`tests/search.rs`) — Esc/typing/backspace/Tab cycle/Enter
refresh/j-k 이동/jump cmd vim+code+args/render w/hits/empty render/no slot.
Spec deviation (HOTFIXES `2026-05-02 P9-2`):
- `render_search<B: Backend>` generic 제거 (P9-1 와 동일 사유 — ratatui 0.28
Frame backend-agnostic).
- `jump_to_citation` 가 `workspace_root: &Path` 인자 추가. Citation.path 가
workspace 상대 라 editor 호출 시 절대 경로 필요. spec literal 의 시그니처
는 unimplementable.
Docs (sync rule):
- README: TUI 행 \"Library + Search 패널, ask/inspect 진행 중\" + Quick start
의 `kebab tui` 코멘트 갱신.
- HANDOFF: 한 줄 요약 + Phase status (P9 1/5 → 2/5) + deviation 한 줄 추가.
- HOTFIXES: P9-2 entry 추가.
- tasks/p9/p9-2 status: completed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
262
crates/kebab-tui/tests/search.rs
Normal file
262
crates/kebab-tui/tests/search.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
//! Unit + snapshot tests for the Search pane (P9-2).
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use kebab_config::Config;
|
||||
use kebab_core::{
|
||||
Citation, ChunkId, ChunkerVersion, DocumentId, EmbeddingModelId, IndexVersion,
|
||||
RetrievalDetail, SearchHit, SearchMode, WorkspacePath,
|
||||
};
|
||||
use kebab_tui::{
|
||||
App, KeyOutcome, Pane, SearchState, build_jump_command, handle_key_search, render_search,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use std::path::Path;
|
||||
|
||||
fn fresh_app() -> App {
|
||||
let mut config = Config::defaults();
|
||||
config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".to_string();
|
||||
config.workspace.root = "/tmp/kebab-tui-search-tests-noop/workspace".to_string();
|
||||
let mut app = App::new(config).expect("App::new");
|
||||
app.focus = Pane::Search;
|
||||
app.search = Some(SearchState::default());
|
||||
app
|
||||
}
|
||||
|
||||
fn make_hit(rank: u32, path: &str, snippet: &str, citation: Citation) -> SearchHit {
|
||||
SearchHit {
|
||||
rank,
|
||||
chunk_id: ChunkId(format!("{:0<32}", rank)),
|
||||
doc_id: DocumentId(format!("{:0<32}", rank * 2)),
|
||||
doc_path: WorkspacePath::new(path.into()).unwrap(),
|
||||
heading_path: vec!["Section".into(), "Sub".into()],
|
||||
section_label: Some("Sub".into()),
|
||||
snippet: snippet.into(),
|
||||
citation,
|
||||
retrieval: RetrievalDetail {
|
||||
method: SearchMode::Hybrid,
|
||||
fusion_score: 0.9,
|
||||
lexical_score: Some(0.8),
|
||||
vector_score: Some(0.95),
|
||||
lexical_rank: Some(rank),
|
||||
vector_rank: Some(rank),
|
||||
},
|
||||
index_version: IndexVersion("v1".into()),
|
||||
embedding_model: Some(EmbeddingModelId("multilingual-e5-small".into())),
|
||||
chunker_version: ChunkerVersion("md-heading-v1".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_citation(path: &str, line: u32) -> Citation {
|
||||
Citation::Line {
|
||||
path: WorkspacePath::new(path.into()).unwrap(),
|
||||
start: line,
|
||||
end: line,
|
||||
section: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_returns_to_library() {
|
||||
let mut app = fresh_app();
|
||||
let outcome = handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_appends_to_input_and_marks_dirty() {
|
||||
let mut app = fresh_app();
|
||||
for ch in "hello".chars() {
|
||||
handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
let s = app.search.as_ref().unwrap();
|
||||
assert_eq!(s.input, "hello");
|
||||
assert!(s.input_dirty_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_removes_last_char() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.search.as_mut().unwrap();
|
||||
s.input = "abc".into();
|
||||
}
|
||||
handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(app.search.as_ref().unwrap().input, "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_mode_lex_vec_hybrid() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.search.as_mut().unwrap();
|
||||
s.mode = SearchMode::Lexical;
|
||||
}
|
||||
let press_tab = |app: &mut App| {
|
||||
handle_key_search(app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
};
|
||||
press_tab(&mut app);
|
||||
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Vector);
|
||||
press_tab(&mut app);
|
||||
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Hybrid);
|
||||
press_tab(&mut app);
|
||||
assert_eq!(app.search.as_ref().unwrap().mode, SearchMode::Lexical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_query_emits_refresh() {
|
||||
let mut app = fresh_app();
|
||||
app.search.as_mut().unwrap().input = "rust".into();
|
||||
let outcome = handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Refresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_empty_query_is_continue() {
|
||||
let mut app = fresh_app();
|
||||
let outcome = handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::Continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_k_move_selection_within_bounds() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.search.as_mut().unwrap();
|
||||
s.hits = vec![
|
||||
make_hit(1, "a.md", "snip a\nline2", line_citation("a.md", 1)),
|
||||
make_hit(2, "b.md", "snip b\nline2", line_citation("b.md", 5)),
|
||||
make_hit(3, "c.md", "snip c\nline2", line_citation("c.md", 7)),
|
||||
];
|
||||
s.selected_hit = 0;
|
||||
}
|
||||
handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(app.search.as_ref().unwrap().selected_hit, 1);
|
||||
handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(app.search.as_ref().unwrap().selected_hit, 2);
|
||||
// Bounds clamp.
|
||||
handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(app.search.as_ref().unwrap().selected_hit, 2);
|
||||
handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(app.search.as_ref().unwrap().selected_hit, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_jump_command_line_uses_plus_n_for_vim() {
|
||||
let citation = line_citation("notes/foo.md", 42);
|
||||
let (program, args) =
|
||||
build_jump_command(&citation, "vim", Path::new("/tmp/workspace"));
|
||||
assert_eq!(program, "vim");
|
||||
assert_eq!(args, vec!["+42".to_string(), "/tmp/workspace/notes/foo.md".into()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_jump_command_line_uses_g_flag_for_code() {
|
||||
let citation = line_citation("notes/foo.md", 42);
|
||||
let (program, args) =
|
||||
build_jump_command(&citation, "code", Path::new("/tmp/workspace"));
|
||||
assert_eq!(program, "code");
|
||||
assert_eq!(args, vec!["-g".to_string(), "/tmp/workspace/notes/foo.md:42".into()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_jump_command_passes_through_editor_args() {
|
||||
let citation = line_citation("a.md", 7);
|
||||
let (program, args) = build_jump_command(&citation, "nvim -p", Path::new("/ws"));
|
||||
assert_eq!(program, "nvim");
|
||||
// Leading `-p` from $EDITOR env preserved before the +N path arg.
|
||||
assert!(args[0] == "-p", "leading editor arg preserved: {args:?}");
|
||||
assert!(args.contains(&"+7".to_string()));
|
||||
assert!(args.contains(&"/ws/a.md".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_search_with_hits_shows_input_and_path() {
|
||||
let mut app = fresh_app();
|
||||
{
|
||||
let s = app.search.as_mut().unwrap();
|
||||
s.input = "rust traits".into();
|
||||
s.mode = SearchMode::Hybrid;
|
||||
s.hits = vec![
|
||||
make_hit(1, "notes/rust.md", "trait dispatch\nis dynamic", line_citation("notes/rust.md", 12)),
|
||||
make_hit(2, "notes/dyn.md", "dynamic dispatch\nvtable", line_citation("notes/dyn.md", 3)),
|
||||
];
|
||||
s.selected_hit = 0;
|
||||
}
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
render_search(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
let rendered: String = (0..buffer.area.height)
|
||||
.map(|y| {
|
||||
(0..buffer.area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("hybrid"), "mode badge rendered: {rendered}");
|
||||
assert!(rendered.contains("rust traits"), "input text rendered");
|
||||
assert!(rendered.contains("notes/rust.md"), "first hit path rendered");
|
||||
assert!(rendered.contains("notes/dyn.md"), "second hit path rendered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_renders_without_panic() {
|
||||
let app = fresh_app();
|
||||
let backend = TestBackend::new(80, 20);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, 80, 20);
|
||||
render_search(f, area, &app);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_search_state_returns_to_library() {
|
||||
let mut config = Config::defaults();
|
||||
config.storage.data_dir = "/tmp/kebab-tui-search-tests-noop".into();
|
||||
let mut app = App::new(config).unwrap();
|
||||
app.focus = Pane::Search;
|
||||
// search slot intentionally None.
|
||||
let outcome = handle_key_search(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
|
||||
);
|
||||
assert_eq!(outcome, KeyOutcome::SwitchPane(Pane::Library));
|
||||
}
|
||||
Reference in New Issue
Block a user