Personal outliner built with Rust.
4

Configure Feed

Select the types of activity you want to include in your feed.

phase 6.7: full-text search UI

- Ctrl+F overlay: type to search, ranked results with <b>-highlighted snippets, up/down to select, enter/click to jump into the containing page
- SearchIndex kept fresh incrementally at each edit site (commit/split/merge), full rebuild on the rarer structural events (rollover, page materialization)
- render snippets by hand-parsing tantivy's <b> markup into bold/plain spans, unescaping the 5 entities htmlescape::encode_minimal emits (including the easy-to-miss hex &#x27; for apostrophes)

graham.systems (Jul 12, 2026, 2:52 PM -0700) 599a4a3b 4f9f75d2

+264 -5
+263 -4
crates/trawler/src/main.rs
··· 3 3 //! and outline keybindings. 4 4 //! 5 5 //! See openspec/changes/trawler-mvp specs/outline-editor/spec.md, 6 - //! journal/spec.md, graph-navigation/spec.md and tasks.md 5.1-6.5. 7 - //! Quick open, search UI, and similar-blocks (6.6-6.8) are not wired up 8 - //! yet. 6 + //! journal/spec.md, graph-navigation/spec.md, search/spec.md and 7 + //! tasks.md 5.1-6.7. Similar-blocks (6.8) is not wired up yet. 9 8 //! 10 9 //! The one-hot editor is `editor::BlockEditor`, built directly on GPUI's 11 10 //! own `EntityInputHandler`/`Element` primitives rather than ··· 46 45 use trawler_core::index::GraphIndex; 47 46 use trawler_core::outline::{Outline, Position}; 48 47 use trawler_core::refs::ReferenceKind; 48 + use trawler_core::search::{SearchHit, SearchIndex}; 49 49 use trawler_core::storage::GraphStorage; 50 50 51 51 actions!( ··· 56 56 ZoomIn, 57 57 ZoomOut, 58 58 QuickOpen, 59 - CreatePage 59 + CreatePage, 60 + SearchOpen 60 61 ] 61 62 ); 62 63 ··· 69 70 KeyBinding::new("ctrl-.", ZoomIn, Some(APP_CONTEXT)), 70 71 KeyBinding::new("ctrl-,", ZoomOut, Some(APP_CONTEXT)), 71 72 KeyBinding::new("ctrl-k", QuickOpen, Some(APP_CONTEXT)), 73 + KeyBinding::new("ctrl-f", SearchOpen, Some(APP_CONTEXT)), 72 74 // Deliberately not bound to plain "enter": the quick-open overlay 73 75 // handles "enter" itself via a raw `on_key_down` listener (see 74 76 // `quick_open_key_down`), and it isn't scoped to its own key ··· 126 128 struct QuickOpenState { 127 129 query: String, 128 130 results: Vec<(NodeId, String)>, 131 + selected: usize, 132 + } 133 + 134 + /// State for the Ctrl+F full-text search overlay (spec: "Search and jump"). 135 + struct SearchState { 136 + query: String, 137 + results: Vec<SearchHit>, 129 138 selected: usize, 130 139 } 131 140 ··· 167 176 /// the window with no focused element and every app-level shortcut 168 177 /// dead. 169 178 root_focus: FocusHandle, 179 + /// Full-text search index over block content (spec: search/"Search and 180 + /// jump"). Kept incrementally in sync with the graph at each edit site 181 + /// via `upsert_block`/`remove_block`; rebuilt wholesale only for the 182 + /// rare structural events (new page materialized, journal rollover) 183 + /// where precise tracking isn't worth the bookkeeping. 184 + search: SearchIndex, 185 + search_open: Option<SearchState>, 186 + /// Dedicated focus target for the search query field — same reasoning 187 + /// as `quick_open_focus`. 188 + search_focus: FocusHandle, 170 189 /// The local date as of the last journal refresh, checked on every 171 190 /// render so a rollover while the app stays open picks up a new 172 191 /// today's page without needing a restart or an edit to trigger it ··· 192 211 193 212 let today = chrono::Local::now().date_naive(); 194 213 let today_page = ensure_today_journal_page(&storage, today); 214 + 215 + // Built after seeding/journal setup above so a brand-new graph's 216 + // first-ever build (the `open_or_create` fast path when the search 217 + // directory doesn't exist yet) picks up that content directly, 218 + // instead of needing a separate indexing pass for it. 219 + let search = SearchIndex::open_or_create(graph_dir.join("search-index"), storage.doc()) 220 + .expect("open or create search index"); 195 221 196 222 let mut app = Self { 197 223 graph_dir, ··· 208 234 quick_open: None, 209 235 quick_open_focus: cx.focus_handle(), 210 236 root_focus: cx.focus_handle(), 237 + search, 238 + search_open: None, 239 + search_focus: cx.focus_handle(), 211 240 journal_today: today, 212 241 window_handle: window.window_handle(), 213 242 }; ··· 240 269 } 241 270 self.journal_today = today; 242 271 ensure_today_journal_page(&self.storage, today); 272 + // Rare event (once a day at most) — a full rebuild is simpler than 273 + // precisely tracking what changed and costs nothing noticeable. 274 + let _ = self.search.rebuild(self.storage.doc()); 243 275 if matches!(self.view, View::Journal) { 244 276 self.refresh_view_data(); 245 277 cx.notify(); ··· 510 542 cx.notify(); 511 543 } 512 544 545 + /// Ctrl+F: open (or close, if already open) full-text search. 546 + fn toggle_search(&mut self, _: &SearchOpen, window: &mut Window, cx: &mut Context<Self>) { 547 + if self.search_open.is_some() { 548 + self.search_open = None; 549 + cx.notify(); 550 + return; 551 + } 552 + self.search_open = Some(SearchState { 553 + query: String::new(), 554 + results: Vec::new(), 555 + selected: 0, 556 + }); 557 + // Same reasoning as `toggle_quick_open`: this blurs whatever 558 + // `BlockEditor` was focused, triggering its normal on-blur commit. 559 + window.focus(&self.search_focus); 560 + cx.notify(); 561 + } 562 + 563 + fn search_key_down( 564 + &mut self, 565 + event: &KeyDownEvent, 566 + window: &mut Window, 567 + cx: &mut Context<Self>, 568 + ) { 569 + let Some(state) = self.search_open.as_mut() else { 570 + return; 571 + }; 572 + match event.keystroke.key.as_str() { 573 + "escape" => { 574 + self.search_open = None; 575 + cx.notify(); 576 + return; 577 + } 578 + "enter" => { 579 + if let Some(hit) = state.results.get(state.selected).cloned() { 580 + self.search_open = None; 581 + self.navigate_to_search_hit(hit, window, cx); 582 + } 583 + return; 584 + } 585 + "up" => { 586 + if !state.results.is_empty() { 587 + state.selected = state 588 + .selected 589 + .checked_sub(1) 590 + .unwrap_or(state.results.len() - 1); 591 + } 592 + cx.notify(); 593 + return; 594 + } 595 + "down" => { 596 + if !state.results.is_empty() { 597 + state.selected = (state.selected + 1) % state.results.len(); 598 + } 599 + cx.notify(); 600 + return; 601 + } 602 + "backspace" => { 603 + state.query.pop(); 604 + } 605 + _ => { 606 + let modifiers = event.keystroke.modifiers; 607 + if modifiers.control || modifiers.alt || modifiers.platform { 608 + return; 609 + } 610 + let Some(text) = event.keystroke.key_char.clone() else { 611 + return; 612 + }; 613 + state.query.push_str(&text); 614 + } 615 + } 616 + let query = state.query.clone(); 617 + let results = if query.trim().is_empty() { 618 + Vec::new() 619 + } else { 620 + self.search.search(&query, 20).unwrap_or_default() 621 + }; 622 + let state = self.search_open.as_mut().unwrap(); 623 + state.results = results; 624 + state.selected = 0; 625 + cx.notify(); 626 + } 627 + 628 + /// Jump to a search hit: find its containing page (backlinks-style 629 + /// ancestor walk) and focus the hit block within it. 630 + fn navigate_to_search_hit( 631 + &mut self, 632 + hit: SearchHit, 633 + window: &mut Window, 634 + cx: &mut Context<Self>, 635 + ) { 636 + let outline = Outline::new(self.storage.doc()); 637 + let mut page = hit.block; 638 + while let Some(parent) = outline.parent(page) { 639 + page = parent; 640 + } 641 + self.navigate_to(View::Node(NodeId::tree(page)), Some(hit.block), window, cx); 642 + } 643 + 513 644 /// Resolve a date to the node it should actually navigate to: that 514 645 /// day's journal page if one already exists (real editable content), 515 646 /// falling back to the bare backlinks-only `NodeId::Date` otherwise. ··· 560 691 NodeId::Date(date) => ensure_today_journal_page(&self.storage, date), 561 692 NodeId::Tree(_) => return, 562 693 }; 694 + // Rare event — a full rebuild is simpler than precisely tracking 695 + // what changed and costs nothing noticeable. 696 + let _ = self.search.rebuild(self.storage.doc()); 563 697 self.navigate_to(View::Node(NodeId::tree(page)), None, window, cx); 564 698 } 565 699 ··· 661 795 self.storage 662 796 .persist_update() 663 797 .expect("persist committed edit"); 798 + let _ = self.search.upsert_block(editor.block, &content); 664 799 self.refresh_view_data(); 665 800 } 666 801 ··· 765 900 // The old editor's content is now stale (superseded by the split 766 901 // above); drop it without re-committing through `commit_editor`. 767 902 self.editor = None; 903 + // Re-read both sides post-split rather than reusing `full_text`: 904 + // for the `is_page` branch nothing was actually split, and for the 905 + // ordinary branch `block` now holds only the head of what was set 906 + // above. 907 + let outline = Outline::new(self.storage.doc()); 908 + if let Ok(content) = outline.content(block) { 909 + let _ = self.search.upsert_block(block, &content); 910 + } 911 + if let Ok(content) = outline.content(new_block) { 912 + let _ = self.search.upsert_block(new_block, &content); 913 + } 768 914 self.refresh_view_data(); 769 915 770 916 // Focusing the new sibling needs a `&mut Window`, which this ··· 825 971 .merge_block(block, prev) 826 972 .expect("merge with previous sibling"); 827 973 self.storage.persist_update().expect("persist merge"); 974 + let _ = self.search.remove_block(block); 975 + if let Ok(content) = outline.content(prev) { 976 + let _ = self.search.upsert_block(prev, &content); 977 + } 828 978 self.editor = None; 829 979 self.refresh_view_data(); 830 980 ··· 1158 1308 /// collapsing to nothing. 1159 1309 const ROW_MIN_HEIGHT: f32 = 22.0; 1160 1310 1311 + /// Render a tantivy snippet (plain text with `<b>...</b>` marking matched 1312 + /// terms — see `SearchHit::snippet_html`) as bold/plain inline runs. This 1313 + /// is the one place raw HTML from `trawler-core` needs turning into 1314 + /// GPUI elements, so it's a hand-rolled split rather than a real parser: 1315 + /// tantivy's snippet generator only ever emits plain text plus `<b>`/`</b>`, 1316 + /// nothing else. 1317 + fn render_snippet(html: &str) -> gpui::AnyElement { 1318 + let mut spans = Vec::new(); 1319 + let mut bold = false; 1320 + for chunk in html.split("<b>") { 1321 + if bold { 1322 + if let Some((matched, rest)) = chunk.split_once("</b>") { 1323 + spans.push((matched, true)); 1324 + if !rest.is_empty() { 1325 + spans.push((rest, false)); 1326 + } 1327 + continue; 1328 + } 1329 + } 1330 + if !chunk.is_empty() { 1331 + spans.push((chunk, false)); 1332 + } 1333 + bold = true; 1334 + } 1335 + div() 1336 + .flex() 1337 + .flex_row() 1338 + .flex_wrap() 1339 + .children(spans.into_iter().map(|(text, is_bold)| { 1340 + let mut el = div().child(unescape_html_entities(text)); 1341 + if is_bold { 1342 + el = el.font_weight(FontWeight::BOLD); 1343 + } 1344 + el 1345 + })) 1346 + .into_any_element() 1347 + } 1348 + 1349 + /// Tantivy's snippet HTML escapes surrounding text via `htmlescape:: 1350 + /// encode_minimal`, which emits exactly these five entities — notably 1351 + /// `&#x27;` (hex) for an apostrophe, not the decimal `&#39;` most encoders 1352 + /// use, which is easy to get wrong. Undo them so plain content ("don't", 1353 + /// code containing `<`/`>`/`&`, etc.) round-trips. 1354 + fn unescape_html_entities(text: &str) -> String { 1355 + text.replace("&lt;", "<") 1356 + .replace("&gt;", ">") 1357 + .replace("&quot;", "\"") 1358 + .replace("&#x27;", "'") 1359 + .replace("&amp;", "&") 1360 + } 1361 + 1161 1362 /// Renders a single inline run. References/links get a click handler that 1162 1363 /// navigates via `TrawlerApp::navigate_to_reference` — `key` must be 1163 1364 /// unique among all inline elements rendered in the same frame (callers ··· 1448 1649 .into_any_element() 1449 1650 }); 1450 1651 1652 + let search_overlay = self.search_open.as_ref().map(|state| { 1653 + let items = state.results.iter().enumerate().map(|(ix, hit)| { 1654 + let snippet = render_snippet(&hit.snippet_html); 1655 + let hit = hit.clone(); 1656 + let mut item = div() 1657 + .id(("search-item", ix)) 1658 + .px_2() 1659 + .py_1() 1660 + .cursor_pointer() 1661 + .flex() 1662 + .flex_col() 1663 + .gap_1() 1664 + .on_mouse_down( 1665 + MouseButton::Left, 1666 + cx.listener(move |this, _event, window, cx| { 1667 + this.search_open = None; 1668 + this.navigate_to_search_hit(hit.clone(), window, cx); 1669 + }), 1670 + ) 1671 + .child(snippet); 1672 + if ix == state.selected { 1673 + item = item.bg(rgb(0x2a3a55)); 1674 + } 1675 + item 1676 + }); 1677 + deferred( 1678 + gpui::anchored().position(point(px(220.0), px(70.0))).child( 1679 + div() 1680 + .id("search") 1681 + .track_focus(&self.search_focus) 1682 + .on_key_down(cx.listener(Self::search_key_down)) 1683 + .w(px(480.0)) 1684 + .max_h(px(400.0)) 1685 + .overflow_hidden() 1686 + .flex() 1687 + .flex_col() 1688 + .bg(rgb(0x252525)) 1689 + .border_1() 1690 + .border_color(rgb(0x3a3a3a)) 1691 + .rounded_md() 1692 + .shadow_lg() 1693 + .child( 1694 + div() 1695 + .px_2() 1696 + .py_1() 1697 + .border_b_1() 1698 + .border_color(rgb(0x3a3a3a)) 1699 + .child(format!("Search: {}", state.query)), 1700 + ) 1701 + .children(items), 1702 + ), 1703 + ) 1704 + .with_priority(2) 1705 + .into_any_element() 1706 + }); 1707 + 1451 1708 div() 1452 1709 .size_full() 1453 1710 .flex() ··· 1457 1714 .on_action(cx.listener(Self::navigate_back)) 1458 1715 .on_action(cx.listener(Self::navigate_forward)) 1459 1716 .on_action(cx.listener(Self::toggle_quick_open)) 1717 + .on_action(cx.listener(Self::toggle_search)) 1460 1718 .on_action(cx.listener(Self::create_page_for_view)) 1461 1719 .on_action(cx.listener(Self::zoom_in)) 1462 1720 .on_action(cx.listener(Self::zoom_out)) ··· 1560 1818 ) 1561 1819 .children(backlinks_panel) 1562 1820 .children(quick_open_overlay) 1821 + .children(search_overlay) 1563 1822 } 1564 1823 } 1565 1824
+1 -1
openspec/changes/trawler-mvp/tasks.md
··· 52 52 - [x] 6.4 Backlinks section on every page/zoom view, grouped by source page, navigable 53 53 - [x] 6.5 Block zoom with ancestor breadcrumb; zoom-out 54 54 - [x] 6.6 Quick open: fuzzy switcher over pages/tags/dates 55 - - [ ] 6.7 Search UI: invoke, ranked results with highlights, jump to block 55 + - [x] 6.7 Search UI: invoke, ranked results with highlights, jump to block 56 56 - [ ] 6.8 Similar-blocks surface for the focused block (placement per prototype feedback; update design.md Open Questions with the outcome) 57 57 58 58 ## 7. Query Blocks UI (trawler)