Files
kebab/crates/kebab-tui/src/library.rs
altair823 685007789a style: cargo fmt --all (round 4 ingest log feature follow-up)
Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이
test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all
적용. 모든 import alphabetical reorder + line wrapping 정합.

추가 untracked artifact 동시 commit:
- docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT)
- docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT)

workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:18:40 +00:00

594 lines
21 KiB
Rust

//! Library pane — list + filter + key dispatch.
//!
//! State / render / key handler are kept in one module so the slot
//! pattern (p9-2/3/4 in their own modules) has a clear template to
//! follow. The renderer is `Frame`-typed — ratatui 0.28 dropped the
//! `B: Backend` generic from `Frame` (it's bound at `Terminal` init),
//! so the spec's `render_library<B: Backend>` literal is collapsed
//! here. Logged in HOTFIXES.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use kebab_core::{DocFilter, DocSummary, Lang};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::app::{App, KeyOutcome, Pane};
use crate::input::{display_width, truncate_to_display_width};
/// Width (in display columns) of the `tags` column in the doc-list
/// row. Used twice — truncate input + pad calculation — so a const
/// keeps them in sync.
const TAGS_COL_W: usize = 12;
/// Internal state owned by `LibraryState`. Public-by-crate so
/// `handle_key_library` can mutate it without crossing the
/// `pub`-visibility boundary `LibraryState` exposes.
pub(crate) struct LibraryStateInner {
pub docs: Vec<DocSummary>,
pub list_state: ListState,
pub filter: DocFilter,
/// Edit overlay for the filter (toggled by `f`). `Some` while
/// the user is editing tags / lang fields.
pub filter_edit: Option<FilterEdit>,
/// True after `App::new` and again after every filter refresh,
/// flipped to false once the run loop services the refresh.
pub needs_refresh: bool,
/// True while the run loop is awaiting `kebab-app::list_docs_with_config`
/// — drives the "loading…" header span. Synchronous in v1
/// (acceptable hang per spec).
pub loading: bool,
/// `g` waiting for the second `g` (vim-style `gg` → top).
pub pending_g: bool,
}
impl Default for LibraryStateInner {
fn default() -> Self {
let mut list_state = ListState::default();
list_state.select(None);
Self {
docs: Vec::new(),
list_state,
filter: DocFilter::default(),
filter_edit: None,
needs_refresh: true,
loading: false,
pending_g: false,
}
}
}
/// Filter edit overlay state. `f` toggles in/out of edit mode;
/// while editing, `tab` cycles between fields and `Enter` commits.
pub(crate) struct FilterEdit {
pub field: FilterField,
pub tags_buf: crate::input::InputBuffer,
pub lang_buf: crate::input::InputBuffer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FilterField {
Tags,
Lang,
}
impl FilterEdit {
/// Borrow the buffer for the currently-focused field. Centralizes
/// the `match edit.field` pick so the key-handler arms (Backspace
/// / arrows / Delete / typed Char) don't each re-spell the same
/// 2-arm dispatch.
fn active_buf_mut(&mut self) -> &mut crate::input::InputBuffer {
match self.field {
FilterField::Tags => &mut self.tags_buf,
FilterField::Lang => &mut self.lang_buf,
}
}
pub fn from_filter(filter: &DocFilter) -> Self {
let mut tags_buf = crate::input::InputBuffer::new();
tags_buf.push_str(&filter.tags_any.join(","));
let mut lang_buf = crate::input::InputBuffer::new();
if let Some(lang) = filter.lang.as_ref() {
lang_buf.push_str(&lang.0);
}
Self {
field: FilterField::Tags,
tags_buf,
lang_buf,
}
}
pub fn commit_into(&self, filter: &mut DocFilter) {
filter.tags_any = self
.tags_buf
.as_str()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let trimmed = self.lang_buf.as_str().trim();
filter.lang = if trimmed.is_empty() {
None
} else {
Some(Lang(trimmed.to_string()))
};
}
}
/// Render the Library pane. `area` is the full body region;
/// header / footer are owned by the run loop.
pub fn render_library(f: &mut Frame, area: Rect, state: &App) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(filter_overlay_height(state)),
Constraint::Min(1),
])
.split(area);
if let Some(edit) = &state.library.inner.filter_edit {
render_filter_overlay(f, layout[0], edit, &state.theme);
}
render_doc_list(f, layout[1], state);
}
fn filter_overlay_height(state: &App) -> u16 {
if state.library.inner.filter_edit.is_some() {
4
} else {
0
}
}
/// Single source of truth for the filter overlay row labels — used
/// both by `line_with_focus` (display) and the cursor-placement
/// `display_width(...)` math below. Editing one without the other
/// would silently miscolumn the caret.
const LABEL_TAGS: &str = "tags_any (csv): ";
const LABEL_LANG: &str = "lang: ";
fn render_filter_overlay(
f: &mut Frame,
area: Rect,
edit: &FilterEdit,
theme: &crate::theme::Theme,
) {
let block = Block::default()
.title("Filter (Tab=cycle field, Enter=apply, Esc=cancel)")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let lines = vec![
line_with_focus(
LABEL_TAGS,
edit.tags_buf.as_str(),
edit.field == FilterField::Tags,
theme,
),
line_with_focus(
LABEL_LANG,
edit.lang_buf.as_str(),
edit.field == FilterField::Lang,
theme,
),
];
let para = Paragraph::new(lines);
f.render_widget(para, inner);
// p9-fb-10: ratatui calls show_cursor + MoveTo whenever
// cursor_position is Some (our case here). When a render fn
// omits set_cursor_position (Library/Inspect main view), ratatui
// calls hide_cursor instead. So this single call positions the
// caret on the focused field of the filter overlay.
// place_cursor_x sums in usize (avoiding u16 wrap) and clamps to
// the right edge of the inner area.
let (label, focused_buf, row_offset) = match edit.field {
FilterField::Tags => (LABEL_TAGS, &edit.tags_buf, 0u16),
FilterField::Lang => (LABEL_LANG, &edit.lang_buf, 1u16),
};
let label_w = display_width(label);
let cursor_x =
crate::input::place_cursor_x(inner.x, inner.width, label_w, focused_buf.cursor_col());
f.set_cursor_position((cursor_x, inner.y + row_offset));
}
fn line_with_focus<'a>(
label: &'a str,
value: &'a str,
focused: bool,
theme: &crate::theme::Theme,
) -> Line<'a> {
let style = if focused {
theme.style(crate::theme::Role::Selected)
} else {
theme.style(crate::theme::Role::Body)
};
Line::from(vec![Span::raw(label), Span::styled(value, style)])
}
fn render_doc_list(f: &mut Frame, area: Rect, state: &App) {
let inner = &state.library.inner;
let header_text = if inner.loading {
"Library — loading…"
} else if inner.docs.is_empty() {
"Library — no docs (run `kebab ingest` first, then press F5 or re-open)"
} else {
"Library"
};
let block = Block::default().title(header_text).borders(Borders::ALL);
let block_inner = block.inner(area);
f.render_widget(block, area);
if inner.docs.is_empty() {
return;
}
// p9-fb-24: split the inner area into a 1-row column header on top
// and the doc list below. Header reuses the same width math as
// `format_doc_row` so labels line up with their data columns.
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(block_inner);
let header_area = layout[0];
let list_area = layout[1];
let title_w = (list_area.width as usize).saturating_sub(40).max(20);
let header_para = Paragraph::new(format_doc_header(title_w))
.style(state.theme.style(crate::theme::Role::Heading));
f.render_widget(header_para, header_area);
let items: Vec<ListItem> = inner
.docs
.iter()
.map(|d| ListItem::new(format_doc_row(d, title_w)))
.collect();
let list = List::new(items)
.highlight_style(state.theme.style(crate::theme::Role::Selected))
.highlight_symbol("> ");
let mut list_state = inner.list_state.clone();
f.render_stateful_widget(list, list_area, &mut list_state);
}
/// p9-fb-24: render the column-label row that sits directly above
/// the doc list. Uses the same width math as `format_doc_row` so
/// the labels line up with their data columns regardless of Hangul
/// / CJK width drift.
///
/// Layout: `TITLE<title_pad> TAGS<tags_pad> UPDATED CHUNKS`.
/// The title column width matches `area.width.saturating_sub(40).max(20)`
/// — the same calculation `render_doc_list` uses for `title_w`.
pub(crate) fn format_doc_header(title_w: usize) -> Line<'static> {
let title_label = "TITLE";
let tags_label = "TAGS";
let title_pad = title_w.saturating_sub(display_width(title_label));
let tags_pad = TAGS_COL_W.saturating_sub(display_width(tags_label));
let text = format!(
"{title_label}{:title_pad$} {tags_label}{:tags_pad$} {updated:<10} {chunks}",
"",
"",
title_label = title_label,
tags_label = tags_label,
updated = "UPDATED",
chunks = "CHUNKS",
title_pad = title_pad,
tags_pad = tags_pad,
);
Line::from(text)
}
/// Format a `DocSummary` row using display-width-aware truncation
/// and padding. Korean / wide chars contribute 2 columns each.
pub(crate) fn format_doc_row(d: &DocSummary, title_w: usize) -> String {
let title = truncate_to_display_width(&d.title, title_w);
let tags = if d.tags.is_empty() {
"-".to_string()
} else {
d.tags.join(",")
};
let tags = truncate_to_display_width(&tags, TAGS_COL_W);
let updated = d
.updated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "?".to_string());
let updated_short = updated.split('T').next().unwrap_or("?");
// std::fmt's `<width$>` form pads by **char count**, which
// overshoots when the value contains wide chars (each Hangul
// adds 2 cols but counts as 1 char → padding is half-short and
// downstream columns drift). Compute the pad spaces ourselves
// from `display_width`, then concatenate — the truncate above
// already guarantees `display_width(title) <= title_w`.
let title_pad = title_w.saturating_sub(display_width(&title));
let tags_pad = TAGS_COL_W.saturating_sub(display_width(&tags));
format!(
"{title}{:title_pad$} {tags}{:tags_pad$} {updated_short:<10} {chunk_count}",
"",
"",
title = title,
tags = tags,
updated_short = updated_short,
chunk_count = d.chunk_count,
title_pad = title_pad,
tags_pad = tags_pad,
)
}
/// Library pane key dispatch. Mutates `App.library.inner`; never
/// touches another pane's state (parallel-safety contract).
pub fn handle_key_library(state: &mut App, key: KeyEvent) -> KeyOutcome {
if state.error_overlay.is_some() {
// Any key dismisses the popup.
state.error_overlay = None;
return KeyOutcome::Continue;
}
if state.library.inner.filter_edit.is_some() {
return handle_filter_edit_key(state, key);
}
// p9-fb-04: Esc / Ctrl-C while ingest is in flight flips the
// worker's cancel token (instead of triggering the quit path).
// Done BEFORE the `inner` borrow so we can re-borrow `state`.
let is_cancel_chord = match (key.code, key.modifiers) {
(KeyCode::Esc, _) => true,
(KeyCode::Char('c'), m) => m.contains(KeyModifiers::CONTROL),
_ => false,
};
if is_cancel_chord && crate::ingest_progress::cancel_running_ingest(state) {
return KeyOutcome::Continue;
}
let inner = &mut state.library.inner;
let pending_g = std::mem::take(&mut inner.pending_g);
match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => {
state.should_quit = true;
KeyOutcome::Quit
}
(KeyCode::Char('j') | KeyCode::Down, _) => {
move_selection(inner, 1);
KeyOutcome::Continue
}
(KeyCode::Char('k') | KeyCode::Up, _) => {
move_selection(inner, -1);
KeyOutcome::Continue
}
(KeyCode::Char('g'), m) if !m.contains(KeyModifiers::SHIFT) => {
if pending_g {
set_selection(inner, 0);
KeyOutcome::Continue
} else {
inner.pending_g = true;
KeyOutcome::Continue
}
}
(KeyCode::Char('G'), _) => {
let last = inner.docs.len().saturating_sub(1);
set_selection(inner, last);
KeyOutcome::Continue
}
(KeyCode::Char('f'), _) => {
inner.filter_edit = Some(FilterEdit::from_filter(&inner.filter));
KeyOutcome::Continue
}
(KeyCode::Char('r'), _) => {
// p9-fb-03: trigger background ingest. The `inner` mutable
// borrow above is not used in this arm, so NLL releases it
// before we re-borrow `state` for `start_ingest`. Errors
// (e.g. "ingest already running") surface via the error
// overlay.
if let Err(e) = crate::ingest_progress::start_ingest(state) {
state.error_overlay = Some(crate::ErrorOverlay::from_anyhow(&e));
}
KeyOutcome::Continue
}
(KeyCode::Char('/'), _) => KeyOutcome::SwitchPane(Pane::Search),
(KeyCode::Char('?'), _) => KeyOutcome::SwitchPane(Pane::Ask),
(KeyCode::Enter, _) => {
if inner.docs.is_empty() {
KeyOutcome::Continue
} else {
let idx = inner.list_state.selected().unwrap_or(0);
// Capture doc_id and exit the `inner` borrow scope
// before re-borrowing `state` for `enter_inspect`.
let doc_id = inner.docs[idx].doc_id.clone();
// NLL releases the `inner` borrow at last use above;
// we can re-borrow `state` mutably for the inspect-side
// mutation below.
let target = crate::app::InspectTarget::Doc(doc_id);
crate::inspect::enter_inspect(state, target, Pane::Library);
KeyOutcome::SwitchPane(Pane::Inspect)
}
}
_ => KeyOutcome::Continue,
}
}
fn handle_filter_edit_key(state: &mut App, key: KeyEvent) -> KeyOutcome {
let Some(edit) = state.library.inner.filter_edit.as_mut() else {
return KeyOutcome::Continue;
};
match key.code {
KeyCode::Esc => {
state.library.inner.filter_edit = None;
KeyOutcome::Continue
}
KeyCode::Tab => {
edit.field = match edit.field {
FilterField::Tags => FilterField::Lang,
FilterField::Lang => FilterField::Tags,
};
KeyOutcome::Continue
}
KeyCode::Enter => {
let edit = state.library.inner.filter_edit.take().unwrap();
edit.commit_into(&mut state.library.inner.filter);
state.library.inner.needs_refresh = true;
KeyOutcome::Refresh
}
KeyCode::Backspace => {
edit.active_buf_mut().pop_char();
KeyOutcome::Continue
}
// p9-fb-22: cursor navigation + Delete inside the active filter
// field. Tab still cycles between Tags / Lang fields; arrows
// only move within the focused buffer.
KeyCode::Left => {
edit.active_buf_mut().move_left();
KeyOutcome::Continue
}
KeyCode::Right => {
edit.active_buf_mut().move_right();
KeyOutcome::Continue
}
KeyCode::Home => {
edit.active_buf_mut().move_home();
KeyOutcome::Continue
}
KeyCode::End => {
edit.active_buf_mut().move_end();
KeyOutcome::Continue
}
KeyCode::Delete => {
edit.active_buf_mut().delete_after();
KeyOutcome::Continue
}
KeyCode::Char(c) => {
edit.active_buf_mut().push_char(c);
KeyOutcome::Continue
}
_ => KeyOutcome::Continue,
}
}
fn move_selection(inner: &mut LibraryStateInner, delta: i32) {
if inner.docs.is_empty() {
return;
}
let current = inner.list_state.selected().unwrap_or(0) as i32;
let last = (inner.docs.len() as i32) - 1;
let next = (current + delta).clamp(0, last);
inner.list_state.select(Some(next as usize));
}
fn set_selection(inner: &mut LibraryStateInner, idx: usize) {
if inner.docs.is_empty() {
inner.list_state.select(None);
} else {
let clamped = idx.min(inner.docs.len() - 1);
inner.list_state.select(Some(clamped));
}
}
/// Run-loop hook: refresh `docs` from the facade. Public-by-crate
/// because the run loop owns the call site.
pub(crate) fn refresh_docs(state: &mut App) -> anyhow::Result<()> {
state.library.inner.loading = true;
let result =
kebab_app::list_docs_with_config(state.config.clone(), state.library.inner.filter.clone());
state.library.inner.loading = false;
match result {
Ok(docs) => {
let prior = state.library.inner.list_state.selected();
state.library.inner.docs = docs;
// Clamp selection.
let len = state.library.inner.docs.len();
if len == 0 {
state.library.inner.list_state.select(None);
} else {
let next = prior.map_or(0, |p| p.min(len - 1));
state.library.inner.list_state.select(Some(next));
}
state.library.inner.needs_refresh = false;
Ok(())
}
Err(e) => {
state.library.inner.needs_refresh = false;
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use kebab_core::{
ChunkerVersion, DocSummary, DocumentId, Lang, ParserVersion, SourceType, TrustLevel,
WorkspacePath,
};
use time::OffsetDateTime;
fn doc(title: &str, tags: &[&str]) -> DocSummary {
DocSummary {
doc_id: DocumentId("a".repeat(32)),
doc_path: WorkspacePath::new("x.md".into()).unwrap(),
title: title.into(),
lang: Lang("en".into()),
tags: tags.iter().map(|s| (*s).into()).collect(),
trust_level: TrustLevel::Primary,
source_type: SourceType::Note,
byte_len: 1,
chunk_count: 1,
created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
parser_version: ParserVersion("p".into()),
chunker_version: ChunkerVersion("c".into()),
}
}
/// p9-fb-10: format_doc_row pads by display width (not char
/// count) so wide-char titles don't shift downstream columns.
/// Regression pin — `<title_w$>` (std::fmt char-count form)
/// would fail this for any Hangul title.
#[test]
fn format_doc_row_pads_by_display_width_for_hangul_title() {
let row = format_doc_row(&doc("러스트로 만드는 KB", &["rust"]), 30);
// Expected layout (display cols):
// title 30 + " "(2) + tags 12 + " "(2) + date 10 + " "(2) + chunk
// chunk = "1" → 1 col. Total = 30+2+12+2+10+2+1 = 59.
assert_eq!(
display_width(&row),
59,
"row must align to display columns, not char count: {row:?}"
);
}
/// p9-fb-10: Hangul tag also pads by display width.
#[test]
fn format_doc_row_pads_by_display_width_for_hangul_tag() {
let row = format_doc_row(&doc("ascii", &["한글"]), 20);
// title 20 + " " + tags 12 + " " + date 10 + " " + "1" = 49
assert_eq!(display_width(&row), 49, "row: {row:?}");
}
/// p9-fb-24: column header row uses the same width math as
/// `format_doc_row` so labels line up with their data columns.
/// The TITLE label sits in the title column, TAGS sits in the
/// 12-col TAGS column, UPDATED in the 10-col date column, and
/// CHUNKS at the trailing position.
#[test]
fn format_doc_header_aligns_with_format_doc_row() {
let title_w = 30;
let header = format_doc_header(title_w);
let header_text: String = header.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(header_text.contains("TITLE"), "header has TITLE label");
assert!(header_text.contains("TAGS"), "header has TAGS label");
assert!(header_text.contains("UPDATED"), "header has UPDATED label");
assert!(header_text.contains("CHUNKS"), "header has CHUNKS label");
let row = format_doc_row(&doc("ascii-title", &["rust"]), title_w);
let tags_start_in_row = row.find("rust").expect("row has tags");
let tags_start_in_header = header_text.find("TAGS").expect("header has TAGS");
assert!(
tags_start_in_header <= tags_start_in_row,
"TAGS header drifted past row tags: header={tags_start_in_header} row={tags_start_in_row}"
);
}
}