generated from eblume/project-template
feat(tui): heph-tui T3 — full-text search overlay (§8.1)
`/` opens a search prompt; submitting runs the FTS `search` RPC and overlays the results on the center pane (title + [kind]). j/k move, Enter opens the hit (a task hit opens its canonical-context doc via context_of; docs/journals open themselves) in nvim, Esc exits search. Backend gained `search` + `context_of`. Tests: fake-backend flow (results populate; task hit resolves to its context, doc hit to itself; clear) + a real-daemon integration test (seed a doc, search, assert the hit + that the Search pane renders). 183 workspace tests; clippy/fmt clean. Move-to-project is the last Todoist-parity gap; it needs a new task.set_project RPC (no link-remove RPC yet) and is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3099034d43
commit
dc8e06ecaa
6 changed files with 299 additions and 11 deletions
|
|
@ -5,7 +5,7 @@
|
|||
use anyhow::Result;
|
||||
use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS};
|
||||
|
||||
use crate::backend::{Backend, Project};
|
||||
use crate::backend::{Backend, Project, SearchHit};
|
||||
|
||||
/// The interaction mode: normal navigation, or collecting a line of text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -31,6 +31,17 @@ enum InputKind {
|
|||
Reschedule {
|
||||
task_id: String,
|
||||
},
|
||||
/// Full-text search query.
|
||||
Search,
|
||||
}
|
||||
|
||||
/// An active full-text search: the query, its hits, and the highlighted row.
|
||||
/// While `Some` on the App, the center pane shows results instead of tasks.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchView {
|
||||
pub query: String,
|
||||
pub results: Vec<SearchHit>,
|
||||
pub cursor: usize,
|
||||
}
|
||||
|
||||
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
|
||||
|
|
@ -93,6 +104,8 @@ pub struct App<B: Backend> {
|
|||
pub preview: Preview,
|
||||
pub focus: Focus,
|
||||
pub mode: Mode,
|
||||
/// When `Some`, a full-text search overlays the task list.
|
||||
pub search: Option<SearchView>,
|
||||
pub status: String,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
|
@ -129,6 +142,7 @@ impl<B: Backend> App<B> {
|
|||
preview: Preview::default(),
|
||||
focus: Focus::Sidebar,
|
||||
mode: Mode::Normal,
|
||||
search: None,
|
||||
status: String::new(),
|
||||
should_quit: false,
|
||||
};
|
||||
|
|
@ -378,6 +392,48 @@ impl<B: Backend> App<B> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Open the full-text search prompt.
|
||||
pub fn begin_search(&mut self) {
|
||||
self.mode = Mode::Input(InputState {
|
||||
prompt: "Search".into(),
|
||||
buffer: String::new(),
|
||||
kind: InputKind::Search,
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the search overlay, returning to the selected view's task list.
|
||||
pub fn clear_search(&mut self) {
|
||||
self.search = None;
|
||||
}
|
||||
|
||||
/// Move the search-results cursor by `delta` (clamped).
|
||||
pub fn search_move(&mut self, delta: isize) {
|
||||
if let Some(s) = &mut self.search {
|
||||
if s.results.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = s.results.len() as isize - 1;
|
||||
s.cursor = (s.cursor as isize + delta).clamp(0, max) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
/// The node id to open for the highlighted search hit — a task's
|
||||
/// canonical-context doc, or the node itself. `None` if no hit is selected.
|
||||
pub fn search_open_target(&mut self) -> Option<String> {
|
||||
let hit = self
|
||||
.search
|
||||
.as_ref()?
|
||||
.results
|
||||
.get(self.search.as_ref()?.cursor)?;
|
||||
let (id, is_task) = (hit.id.clone(), hit.kind == "task");
|
||||
if is_task {
|
||||
if let Ok(Some(ctx)) = self.backend.context_of(&id) {
|
||||
return Some(ctx);
|
||||
}
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Append a typed character to the active input.
|
||||
pub fn input_push(&mut self, c: char) {
|
||||
if let Mode::Input(state) = &mut self.mode {
|
||||
|
|
@ -443,6 +499,22 @@ impl<B: Backend> App<B> {
|
|||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
InputKind::Search => {
|
||||
if buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
match self.backend.search(&buf) {
|
||||
Ok(results) => {
|
||||
self.status = format!("{} result(s) for {buf:?}", results.len());
|
||||
self.search = Some(SearchView {
|
||||
query: buf,
|
||||
results,
|
||||
cursor: 0,
|
||||
});
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
InputKind::Reschedule { task_id } => {
|
||||
let patch = if buf.is_empty() {
|
||||
SchedulePatch {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ pub struct Project {
|
|||
pub title: String,
|
||||
}
|
||||
|
||||
/// A full-text search result row.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchHit {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Everything the agenda surface asks of the daemon.
|
||||
pub trait Backend {
|
||||
/// All project nodes (for the sidebar), title-sorted.
|
||||
|
|
@ -30,6 +38,11 @@ pub trait Backend {
|
|||
fn node_body(&mut self, id: &str) -> Result<String>;
|
||||
/// The last `n` log lines for a task (the resumption breadcrumb).
|
||||
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>;
|
||||
/// Full-text search over titles + bodies (FTS5), best-match first.
|
||||
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>>;
|
||||
/// A task's canonical-context doc id (where its description/checklist live),
|
||||
/// for opening a task search-hit at the useful node. `None` if it has none.
|
||||
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
|
||||
|
||||
// --- triage mutations (T2) ---
|
||||
|
||||
|
|
@ -108,6 +121,28 @@ impl Backend for ClientBackend {
|
|||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
|
||||
let v = self.call("search", json!({ "query": query }))?;
|
||||
let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|n| SearchHit {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
kind: n.kind.as_str().to_string(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn context_of(&mut self, task_id: &str) -> Result<Option<String>> {
|
||||
let v = self.call("links.outgoing", json!({ "id": task_id }))?;
|
||||
let links: Vec<heph_core::Link> = serde_json::from_value(v)?;
|
||||
Ok(links
|
||||
.into_iter()
|
||||
.find(|l| l.link_type == heph_core::LinkType::CanonicalContext)
|
||||
.map(|l| l.dst_id))
|
||||
}
|
||||
|
||||
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
||||
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ use std::path::PathBuf;
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use heph_tui::{app::App, backend::ClientBackend, editor, ui, Focus};
|
||||
use heph_tui::{
|
||||
app::{App, Mode},
|
||||
backend::ClientBackend,
|
||||
editor, ui, Focus,
|
||||
};
|
||||
use hephd::Client;
|
||||
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
|
|
@ -89,14 +93,40 @@ fn perform<B: heph_tui::Backend>(
|
|||
/// Translate a key press into an [`App`] mutation and/or an [`Action`] for the
|
||||
/// event loop to perform.
|
||||
fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<Action> {
|
||||
// Any keypress clears a stale status message.
|
||||
app.status.clear();
|
||||
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
app.should_quit = true;
|
||||
return None;
|
||||
}
|
||||
|
||||
// While collecting input, all keys go to the prompt.
|
||||
if matches!(app.mode, Mode::Input(_)) {
|
||||
match key.code {
|
||||
KeyCode::Esc => app.input_cancel(),
|
||||
KeyCode::Enter => app.input_submit(),
|
||||
KeyCode::Backspace => app.input_backspace(),
|
||||
KeyCode::Char(c) => app.input_push(c),
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// While search results are shown, the center pane navigates them.
|
||||
if app.search.is_some() {
|
||||
app.status.clear();
|
||||
match key.code {
|
||||
KeyCode::Esc => app.clear_search(),
|
||||
KeyCode::Char('j') | KeyCode::Down => app.search_move(1),
|
||||
KeyCode::Char('k') | KeyCode::Up => app.search_move(-1),
|
||||
KeyCode::Enter => return app.search_open_target().map(Action::EditContext),
|
||||
KeyCode::Char('q') => app.should_quit = true,
|
||||
_ => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Any other keypress clears a stale status message.
|
||||
app.status.clear();
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
|
||||
KeyCode::Char('r') => app.reload(),
|
||||
|
|
@ -105,6 +135,10 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
|
|||
KeyCode::Char('k') | KeyCode::Up => move_up(app),
|
||||
KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(),
|
||||
KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(),
|
||||
// capture + reschedule + search (open an input prompt)
|
||||
KeyCode::Char('a') => app.begin_add(),
|
||||
KeyCode::Char('e') => app.begin_reschedule(),
|
||||
KeyCode::Char('/') => app.begin_search(),
|
||||
// triage mutations (act on the highlighted task)
|
||||
KeyCode::Char('x') => app.complete_selected(),
|
||||
KeyCode::Char('d') => app.drop_selected(),
|
||||
|
|
|
|||
|
|
@ -6,16 +6,18 @@ use ratatui::{
|
|||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::app::{App, Focus, SidebarEntry};
|
||||
use crate::app::{App, Focus, InputState, Mode, SidebarEntry};
|
||||
use crate::backend::Backend;
|
||||
use crate::fmt::{fmt_date, today_local};
|
||||
|
||||
const HINTS: &str =
|
||||
" j/k move Tab pane x done d drop s skip A attn b→blue o edit r refresh q quit";
|
||||
" j/k move Tab pane a add x done e date A attn b→blue o edit / search q quit";
|
||||
|
||||
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";
|
||||
|
||||
/// Draw the whole UI for the current frame.
|
||||
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
||||
|
|
@ -34,9 +36,42 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
|||
.split(outer[0]);
|
||||
|
||||
render_sidebar(frame, app, panes[0]);
|
||||
render_tasks(frame, app, panes[1]);
|
||||
if app.search.is_some() {
|
||||
render_search(frame, app, panes[1]);
|
||||
} else {
|
||||
render_tasks(frame, app, panes[1]);
|
||||
}
|
||||
render_preview(frame, app, panes[2]);
|
||||
render_status(frame, app, outer[1]);
|
||||
|
||||
if let Mode::Input(state) = &app.mode {
|
||||
render_input(frame, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// A centered single-line input popup (guided add / reschedule).
|
||||
fn render_input(frame: &mut Frame, state: &InputState) {
|
||||
let area = frame.area();
|
||||
let width = area.width.saturating_sub(8).clamp(20, 70);
|
||||
let popup = Rect {
|
||||
x: area.x + (area.width.saturating_sub(width)) / 2,
|
||||
y: area.y + area.height / 3,
|
||||
width,
|
||||
height: 3,
|
||||
};
|
||||
frame.render_widget(Clear, popup);
|
||||
let line = Line::from(vec![
|
||||
Span::styled("> ", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(&state.buffer),
|
||||
Span::styled("▏", Style::default().fg(Color::Cyan)), // cursor
|
||||
]);
|
||||
let para = Paragraph::new(line).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(format!(" {} ", state.prompt)),
|
||||
);
|
||||
frame.render_widget(para, popup);
|
||||
}
|
||||
|
||||
fn pane_border(focused: bool) -> Style {
|
||||
|
|
@ -167,6 +202,46 @@ fn render_tasks<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let Some(s) = &app.search else { return };
|
||||
let items: Vec<ListItem> = if s.results.is_empty() {
|
||||
vec![ListItem::new(Line::from(Span::styled(
|
||||
" (no matches — Esc to exit search)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))]
|
||||
} else {
|
||||
s.results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, hit)| {
|
||||
let selected = i == s.cursor;
|
||||
let title_style = if selected {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let cursor = if selected { "▌" } else { " " };
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(cursor, Style::default().fg(Color::Cyan)),
|
||||
Span::styled(
|
||||
format!("[{}] ", hit.kind),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(hit.title.clone(), title_style),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let title = format!(" Search: {} ({}) ", s.query, s.results.len());
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(title),
|
||||
);
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
|
||||
fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if !app.preview.title.is_empty() {
|
||||
|
|
@ -203,8 +278,13 @@ fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
}
|
||||
|
||||
fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let hints = if app.search.is_some() {
|
||||
SEARCH_HINTS
|
||||
} else {
|
||||
HINTS
|
||||
};
|
||||
let text = if app.status.is_empty() {
|
||||
HINTS.to_string()
|
||||
hints.to_string()
|
||||
} else {
|
||||
format!(" {}", app.status)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -177,6 +177,29 @@ fn quick_add_captures_a_task_that_appears_in_the_view() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_a_matching_node_and_overlays_results() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
c.call(
|
||||
"node.create",
|
||||
json!({ "kind": "doc", "title": "Plumbing notes", "body": "shutoff valve is in the garage" }),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||
app.begin_search();
|
||||
type_and_submit(&mut app, "plumbing");
|
||||
|
||||
let s = app.search.as_ref().expect("search active");
|
||||
assert!(
|
||||
s.results.iter().any(|h| h.title == "Plumbing notes"),
|
||||
"search missed the doc: {:?}",
|
||||
s.results
|
||||
);
|
||||
assert!(screen(&app).contains("Search:"), "search pane not rendered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_sets_a_do_date_on_the_task() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use anyhow::Result;
|
|||
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState};
|
||||
use heph_tui::{
|
||||
app::{App, Focus},
|
||||
backend::{Backend, Project},
|
||||
backend::{Backend, Project, SearchHit},
|
||||
};
|
||||
|
||||
/// A recorded `create_task`: (title, attention, do_date, recurrence, project_id).
|
||||
|
|
@ -50,6 +50,8 @@ struct Fake {
|
|||
projects: Vec<Project>,
|
||||
by_project: HashMap<String, Vec<RankedTask>>,
|
||||
bodies: HashMap<String, String>,
|
||||
search_hits: Vec<SearchHit>,
|
||||
contexts: HashMap<String, String>,
|
||||
rec: Rc<RefCell<Recorder>>,
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +72,17 @@ impl Backend for Fake {
|
|||
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
|
||||
Ok(self
|
||||
.search_hits
|
||||
.iter()
|
||||
.filter(|h| h.title.to_lowercase().contains(&query.to_lowercase()))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
fn context_of(&mut self, task_id: &str) -> Result<Option<String>> {
|
||||
Ok(self.contexts.get(task_id).cloned())
|
||||
}
|
||||
fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -219,6 +232,37 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() {
|
|||
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_populates_results_and_task_hits_open_their_context() {
|
||||
let mut fake = fixture();
|
||||
fake.search_hits = vec![
|
||||
SearchHit {
|
||||
id: "doc1".into(),
|
||||
title: "Roof notes".into(),
|
||||
kind: "doc".into(),
|
||||
},
|
||||
SearchHit {
|
||||
id: "task1".into(),
|
||||
title: "Roof repair".into(),
|
||||
kind: "task".into(),
|
||||
},
|
||||
];
|
||||
fake.contexts.insert("task1".into(), "ctx1".into());
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
app.begin_search();
|
||||
type_and_submit(&mut app, "roof");
|
||||
assert_eq!(app.search.as_ref().unwrap().results.len(), 2);
|
||||
|
||||
// A doc hit opens itself; a task hit opens its canonical-context doc.
|
||||
assert_eq!(app.search_open_target().as_deref(), Some("doc1"));
|
||||
app.search_move(1);
|
||||
assert_eq!(app.search_open_target().as_deref(), Some("ctx1"));
|
||||
|
||||
app.clear_search();
|
||||
assert!(app.search.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_add_passes_inline_recurrence_and_project_through() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue