Personal outliner built with Rust.
3

Configure Feed

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

phase 6.8: similar-blocks surface

- dedicated 'Similar' panel below backlinks, refreshed on every focus change, using GraphIndex-based lexical similarity (excludes self/ancestors/descendants) with the same highlighted-snippet rendering as search
- resolves design.md's 'Similar-blocks UX' open question in favor of a panel over an inline footer, reusing the backlinks/search pattern instead of teaching every row renderer a second footer kind

Phase 6 (Journal & Navigation) complete: 6.1-6.8.

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

+61 -3
+59 -1
crates/trawler/src/main.rs
··· 45 45 use trawler_core::index::GraphIndex; 46 46 use trawler_core::outline::{Outline, Position}; 47 47 use trawler_core::refs::ReferenceKind; 48 - use trawler_core::search::{SearchHit, SearchIndex}; 48 + use trawler_core::search::{SearchHit, SearchIndex, Similarity as _}; 49 49 use trawler_core::storage::GraphStorage; 50 50 51 51 actions!( ··· 186 186 /// Dedicated focus target for the search query field — same reasoning 187 187 /// as `quick_open_focus`. 188 188 search_focus: FocusHandle, 189 + /// Blocks lexically similar to the currently focused block (spec: 190 + /// "Similar blocks (lexical)"), refreshed on every focus change. 191 + /// Shown as a dedicated panel rather than an inline footer on the 192 + /// focused row — design.md's "Similar-blocks UX" open question, 193 + /// resolved this way to reuse the same panel pattern as backlinks 194 + /// rather than teaching every row renderer about a second kind of 195 + /// footer. 196 + similar: Vec<SearchHit>, 189 197 /// The local date as of the last journal refresh, checked on every 190 198 /// render so a rollover while the app stays open picks up a new 191 199 /// today's page without needing a restart or an edit to trigger it ··· 237 245 search, 238 246 search_open: None, 239 247 search_focus: cx.focus_handle(), 248 + similar: Vec::new(), 240 249 journal_today: today, 241 250 window_handle: window.window_handle(), 242 251 }; ··· 796 805 .persist_update() 797 806 .expect("persist committed edit"); 798 807 let _ = self.search.upsert_block(editor.block, &content); 808 + self.similar.clear(); 799 809 self.refresh_view_data(); 810 + } 811 + 812 + /// Recompute "similar blocks" for whatever is currently focused (spec: 813 + /// "Similar blocks (lexical)"); empty when nothing is. 814 + fn refresh_similar(&mut self) { 815 + let Some(editor) = self.editor.as_ref() else { 816 + self.similar.clear(); 817 + return; 818 + }; 819 + let block = editor.block; 820 + let outline = Outline::new(self.storage.doc()); 821 + let content = outline.content(block).unwrap_or_default(); 822 + let graph = GraphIndex::rebuild(self.storage.doc()); 823 + self.similar = self 824 + .search 825 + .similar_blocks(block, &content, &graph, 5) 826 + .unwrap_or_default(); 800 827 } 801 828 802 829 /// Commit whatever was focused, then attach a fresh multi-line editor ··· 860 887 _event_subscription: event_subscription, 861 888 _content_observation: content_observation, 862 889 }); 890 + self.refresh_similar(); 863 891 } 864 892 865 893 /// Enter was pressed (spec: "Newline within a block vs. new block" — ··· 1593 1621 .children(entries) 1594 1622 }); 1595 1623 1624 + let similar_panel = (!self.similar.is_empty()).then(|| { 1625 + let entries = self.similar.iter().enumerate().map(|(ix, hit)| { 1626 + let snippet = render_snippet(&hit.snippet_html); 1627 + let hit = hit.clone(); 1628 + div() 1629 + .id(("similar", ix)) 1630 + .cursor_pointer() 1631 + .px_2() 1632 + .py_1() 1633 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1634 + this.navigate_to_search_hit(hit.clone(), window, cx); 1635 + })) 1636 + .child(snippet) 1637 + }); 1638 + div() 1639 + .flex() 1640 + .flex_col() 1641 + .border_t_1() 1642 + .border_color(rgb(0x3a3a3a)) 1643 + .child( 1644 + div() 1645 + .px_2() 1646 + .py_1() 1647 + .font_weight(FontWeight::BOLD) 1648 + .child("Similar"), 1649 + ) 1650 + .children(entries) 1651 + }); 1652 + 1596 1653 let quick_open_overlay = self.quick_open.as_ref().map(|state| { 1597 1654 let items = state 1598 1655 .results ··· 1817 1874 ), 1818 1875 ) 1819 1876 .children(backlinks_panel) 1877 + .children(similar_panel) 1820 1878 .children(quick_open_overlay) 1821 1879 .children(search_overlay) 1822 1880 }
+1 -1
openspec/changes/trawler-mvp/design.md
··· 113 113 ## Open Questions 114 114 115 115 - **Query surface shape**: datalog-style relations as Scheme macros vs. functional set combinators (current lean: combinators first — they fall out of the API design; a relational macro layer can be pure Scheme on top later). 116 - - **Similar-blocks UX**: where do suggestions surface (dedicated panel vs. inline footer on focused block)? Defer to prototype feedback. 116 + - ~~**Similar-blocks UX**: where do suggestions surface (dedicated panel vs. inline footer on focused block)? Defer to prototype feedback.~~ **Resolved (task 6.8)**: dedicated panel, refreshed on every focus change and shown below the backlinks panel whenever a block is focused. An inline footer would have meant every row renderer (Markdown view, `BlockEditor`, `gpui::list`'s per-item height accounting) needed to know about a second kind of variable-height footer alongside backlinks; a shared panel reused the pattern (and the snippet-rendering code) already built for backlinks/search with no new plumbing. 117 117 118 118 ## Spike Findings (2026-07-02, go/no-go checkpoint) 119 119
+1 -1
openspec/changes/trawler-mvp/tasks.md
··· 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 55 - [x] 6.7 Search UI: invoke, ranked results with highlights, jump to block 56 - - [ ] 6.8 Similar-blocks surface for the focused block (placement per prototype feedback; update design.md Open Questions with the outcome) 56 + - [x] 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) 59 59