Personal outliner built with Rust.
3

Configure Feed

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

phase 7: query blocks UI

- Ctrl+Shift+Q toggles the focused block into/out of a query block (persisted as a 'query' property, not special content syntax)
- hand-rolled Scheme tokenizer (scheme_highlight.rs) for syntax highlighting + multi-line matching-paren indication in BlockEditor; documented the tree-sitter-vs-hand-rolled tradeoff in design.md
- debounced background evaluation via eval_query, off the UI thread (background OS thread + oneshot channel awaited from a foreground gpui::Task), with a per-block generation counter guarding stale results
- outline-slice and sortable-table result rendering, Markdown-formatted (not raw source), indented and visually distinct from ordinary rows

Fixes along the way:
- query_eval_generation was a single global counter; scheduling one query block's lazy eval could invalidate another block's in-flight debounced eval as 'stale', permanently stranding an error result since an error still counts as a cached result and skips the lazy-retry path. Made per-block.
- result list items now render through the Markdown pipeline instead of showing raw source
- fixed result indentation (missing the fold-column allowance every row reserves) and the result background (was using padding, which paints under itself, instead of margin, which actually moves the box) so the result reads as an inset card nested under the query, not a full-width block flush with it

Phase 7 (Query Blocks UI) complete: 7.1-7.5.

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

+1023 -38
+1
Cargo.lock
··· 6937 6937 version = "0.1.0" 6938 6938 dependencies = [ 6939 6939 "chrono", 6940 + "futures", 6940 6941 "gpui", 6941 6942 "loro", 6942 6943 "pulldown-cmark",
+1
crates/trawler/Cargo.toml
··· 9 9 10 10 [dependencies] 11 11 chrono = "0.4.45" 12 + futures = "0.3" 12 13 gpui = "0.2.2" 13 14 loro = "1.13.6" 14 15 pulldown-cmark = { version = "0.13.4", default-features = false }
+134 -8
crates/trawler/src/editor.rs
··· 152 152 last_bounds: Option<Bounds<Pixels>>, 153 153 is_selecting: bool, 154 154 completion: Option<ActiveCompletion>, 155 + /// Query blocks get Scheme syntax highlighting and matching-paren 156 + /// indication while being edited (spec: steel-queries/"Lisp editing 157 + /// experience"); ordinary blocks render as plain text. Set by the 158 + /// owner right after construction via `set_highlight_scheme`, since 159 + /// whether a block is a query is graph state `BlockEditor` itself 160 + /// doesn't know about. 161 + highlight_scheme: bool, 155 162 _blur_subscription: Subscription, 156 163 } 157 164 ··· 180 187 last_bounds: None, 181 188 is_selecting: false, 182 189 completion: None, 190 + highlight_scheme: false, 183 191 _blur_subscription: blur_subscription, 184 192 } 185 193 } 186 194 187 195 pub fn value(&self) -> &str { 188 196 &self.content 197 + } 198 + 199 + /// Enable/disable Scheme syntax highlighting and matching-paren 200 + /// indication for this editor instance (a query block vs. an ordinary 201 + /// one) — see the field doc on `highlight_scheme`. 202 + pub fn set_highlight_scheme(&mut self, enabled: bool, cx: &mut Context<Self>) { 203 + self.highlight_scheme = enabled; 204 + cx.notify(); 189 205 } 190 206 191 207 /// Called by the owner once it has resolved candidates for the most ··· 764 780 line_height: Pixels, 765 781 cursor: Option<PaintQuad>, 766 782 selections: Vec<PaintQuad>, 783 + paren_matches: Vec<PaintQuad>, 784 + } 785 + 786 + // `pub(crate)`: `main.rs` reuses these to render a query block's source the 787 + // same way when it's *not* the focused editor, so a block doesn't visibly 788 + // change color scheme the moment you click into or away from it. 789 + pub(crate) const KEYWORD_COLOR: u32 = 0xc586c0; 790 + pub(crate) const STRING_COLOR: u32 = 0xce9178; 791 + pub(crate) const NUMBER_COLOR: u32 = 0xb5cea8; 792 + pub(crate) const COMMENT_COLOR: u32 = 0x6a9955; 793 + pub(crate) const PAREN_COLOR: u32 = 0xd4d4d4; 794 + const PAREN_MATCH_BG: u32 = 0x515c6a; 795 + 796 + /// Split `line_text` into `TextRun`s colored by Scheme token kind (see 797 + /// `crate::scheme_highlight`), filling the gaps between tokens (whitespace) 798 + /// with the base color so the runs' lengths always sum to the full line — 799 + /// `shape_line` requires that. 800 + fn highlighted_line_runs( 801 + line_text: &str, 802 + font: gpui::Font, 803 + base_color: gpui::Hsla, 804 + ) -> Vec<TextRun> { 805 + use crate::scheme_highlight::{tokenize_line, TokenKind}; 806 + 807 + let mut runs = Vec::new(); 808 + let mut cursor = 0usize; 809 + let push_plain = |runs: &mut Vec<TextRun>, len: usize| { 810 + if len > 0 { 811 + runs.push(TextRun { 812 + len, 813 + font: font.clone(), 814 + color: base_color, 815 + background_color: None, 816 + underline: None, 817 + strikethrough: None, 818 + }); 819 + } 820 + }; 821 + for token in tokenize_line(line_text) { 822 + push_plain(&mut runs, token.range.start - cursor); 823 + let color = match token.kind { 824 + TokenKind::Paren => rgb(PAREN_COLOR).into(), 825 + TokenKind::Keyword => rgb(KEYWORD_COLOR).into(), 826 + TokenKind::String => rgb(STRING_COLOR).into(), 827 + TokenKind::Number => rgb(NUMBER_COLOR).into(), 828 + TokenKind::Comment => rgb(COMMENT_COLOR).into(), 829 + TokenKind::Symbol => base_color, 830 + }; 831 + runs.push(TextRun { 832 + len: token.range.len(), 833 + font: font.clone(), 834 + color, 835 + background_color: None, 836 + underline: None, 837 + strikethrough: None, 838 + }); 839 + cursor = token.range.end; 840 + } 841 + push_plain(&mut runs, line_text.len() - cursor); 842 + if runs.is_empty() { 843 + // An empty line still needs exactly one (zero-length) run — 844 + // `shape_line` is called with this either way. 845 + runs.push(TextRun { 846 + len: 0, 847 + font, 848 + color: base_color, 849 + background_color: None, 850 + underline: None, 851 + strikethrough: None, 852 + }); 853 + } 854 + runs 767 855 } 768 856 769 857 impl gpui::IntoElement for BlockTextElement { ··· 812 900 let content = editor.content.clone(); 813 901 let selected_range = editor.selected_range.clone(); 814 902 let cursor = editor.cursor_offset(); 903 + let highlight_scheme = editor.highlight_scheme; 815 904 let style = window.text_style(); 816 905 let font_size = style.font_size.to_pixels(window.rem_size()); 817 906 let line_height = window.line_height(); ··· 819 908 let mut lines = Vec::new(); 820 909 let mut byte_offset = 0usize; 821 910 for line_text in content.split('\n') { 822 - let run = TextRun { 823 - len: line_text.len(), 824 - font: style.font(), 825 - color: style.color, 826 - background_color: None, 827 - underline: None, 828 - strikethrough: None, 911 + let runs = if highlight_scheme { 912 + highlighted_line_runs(line_text, style.font(), style.color) 913 + } else { 914 + vec![TextRun { 915 + len: line_text.len(), 916 + font: style.font(), 917 + color: style.color, 918 + background_color: None, 919 + underline: None, 920 + strikethrough: None, 921 + }] 829 922 }; 830 923 let shaped = window.text_system().shape_line( 831 924 SharedString::from(line_text.to_string()), 832 925 font_size, 833 - &[run], 926 + &runs, 834 927 None, 835 928 ); 836 929 lines.push((shaped, byte_offset)); ··· 869 962 } 870 963 } 871 964 965 + let mut paren_matches = Vec::new(); 966 + if highlight_scheme { 967 + if let Some((open, close)) = 968 + crate::scheme_highlight::find_matching_paren(&content, cursor) 969 + { 970 + let quad_at = |offset: usize| { 971 + let (line_ix, (line, line_start)) = lines 972 + .iter() 973 + .enumerate() 974 + .find(|(_, (l, start))| offset >= *start && offset <= start + l.len())?; 975 + let x = bounds.left() + line.x_for_index(offset - line_start); 976 + let y = bounds.top() + line_height * line_ix as f32; 977 + Some(fill( 978 + Bounds::new( 979 + point(x, y), 980 + size( 981 + line.x_for_index(offset - line_start + 1) 982 + - line.x_for_index(offset - line_start), 983 + line_height, 984 + ), 985 + ), 986 + rgb(PAREN_MATCH_BG), 987 + )) 988 + }; 989 + paren_matches.extend(quad_at(open)); 990 + paren_matches.extend(quad_at(close)); 991 + } 992 + } 993 + 872 994 PrepaintState { 873 995 lines: lines.into_iter().map(|(l, _)| l).collect(), 874 996 line_height, 875 997 cursor: cursor_quad, 876 998 selections: selection_quads, 999 + paren_matches, 877 1000 } 878 1001 } 879 1002 ··· 896 1019 897 1020 for selection in prepaint.selections.drain(..) { 898 1021 window.paint_quad(selection); 1022 + } 1023 + for paren_match in prepaint.paren_matches.drain(..) { 1024 + window.paint_quad(paren_match); 899 1025 } 900 1026 for (ix, line) in prepaint.lines.iter().enumerate() { 901 1027 let y = bounds.top() + prepaint.line_height * ix as f32;
+556 -25
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, search/spec.md and 7 - //! tasks.md 5.1-6.7. Similar-blocks (6.8) is not wired up yet. 6 + //! journal/spec.md, graph-navigation/spec.md, search/spec.md, 7 + //! steel-queries/spec.md and tasks.md 5.1-7.4. 8 + //! 9 + //! Query blocks (Phase 7) get Scheme syntax highlighting via a hand-rolled 10 + //! tokenizer (`scheme_highlight`) rather than tree-sitter as the spec 11 + //! names — see that module's doc for the rationale. Matching-paren 12 + //! indication and inline error display are implemented as specified. 8 13 //! 9 14 //! The one-hot editor is `editor::BlockEditor`, built directly on GPUI's 10 15 //! own `EntityInputHandler`/`Element` primitives rather than ··· 26 31 27 32 mod editor; 28 33 mod markdown; 34 + mod scheme_highlight; 29 35 30 - use std::collections::HashSet; 36 + use std::collections::{HashMap, HashSet}; 31 37 use std::path::{Path, PathBuf}; 38 + use std::sync::Arc; 39 + use std::time::Duration; 32 40 33 41 use chrono::NaiveDate; 34 42 use editor::{BlockEditor, BlockEditorEvent, CompletionCandidate}; ··· 41 49 }; 42 50 use loro::TreeID; 43 51 use markdown::{Block, Inline}; 44 - use trawler_core::graph::NodeId; 52 + use trawler_core::graph::{NodeId, PropertyValue}; 45 53 use trawler_core::index::GraphIndex; 46 54 use trawler_core::outline::{Outline, Position}; 55 + use trawler_core::query::{eval_query, QueryOutcome, QueryValue}; 47 56 use trawler_core::refs::ReferenceKind; 48 57 use trawler_core::search::{SearchHit, SearchIndex, Similarity as _}; 49 58 use trawler_core::storage::GraphStorage; ··· 57 66 ZoomOut, 58 67 QuickOpen, 59 68 CreatePage, 60 - SearchOpen 69 + SearchOpen, 70 + ToggleQueryBlock 61 71 ] 62 72 ); 63 73 ··· 77 87 // context, so an "enter" action bound here could race with it. 78 88 // Reachable via the "+ Create page" click affordance instead. 79 89 KeyBinding::new("ctrl-enter", CreatePage, Some(APP_CONTEXT)), 90 + // Bound at the app level (not `BlockEditor`'s context) since 91 + // whether a block is a query is graph state, not a text edit — 92 + // toggling it doesn't belong in the editor's own key table. 93 + KeyBinding::new("ctrl-shift-q", ToggleQueryBlock, Some(APP_CONTEXT)), 80 94 ]); 81 95 } 82 96 ··· 99 113 blocks: Vec<Block>, 100 114 has_children: bool, 101 115 folded: bool, 116 + /// Whether this block has the `query` property set (spec: steel- 117 + /// queries/"Query blocks live in the outline" — "ordinary blocks: 118 + /// movable, referenceable, foldable", just also Scheme-evaluated). 119 + /// A query block's raw source is shown instead of Markdown-rendered 120 + /// `blocks`, so `blocks` is left empty for one. 121 + is_query: bool, 122 + /// The block's unparsed content — populated for query blocks (their 123 + /// source, shown verbatim rather than through Markdown) and cheap 124 + /// enough to always fill in. 125 + raw_content: String, 102 126 } 103 127 104 128 /// One entry in the backlinks panel: a referencing block, its containing ··· 180 204 /// jump"). Kept incrementally in sync with the graph at each edit site 181 205 /// via `upsert_block`/`remove_block`; rebuilt wholesale only for the 182 206 /// rare structural events (new page materialized, journal rollover) 183 - /// where precise tracking isn't worth the bookkeeping. 184 - search: SearchIndex, 207 + /// where precise tracking isn't worth the bookkeeping. `Arc`-wrapped 208 + /// so a query's background evaluation thread (see `schedule_query_eval`) 209 + /// can hold its own cheap handle to it without `SearchIndex` needing to 210 + /// be `Clone` (it isn't — it owns a tantivy `Index`/`IndexWriter`). 211 + search: Arc<SearchIndex>, 185 212 search_open: Option<SearchState>, 186 213 /// Dedicated focus target for the search query field — same reasoning 187 214 /// as `quick_open_focus`. ··· 194 221 /// rather than teaching every row renderer about a second kind of 195 222 /// footer. 196 223 similar: Vec<SearchHit>, 224 + /// Last-known evaluation result per query block (spec: steel-queries/ 225 + /// "Query blocks live in the outline"), shown whether or not that 226 + /// block is currently focused. 227 + query_results: HashMap<TreeID, QueryOutcome>, 228 + /// Query blocks with an evaluation currently in flight, so a still- 229 + /// rendering row doesn't get a redundant eval scheduled on every frame 230 + /// before the first result lands. 231 + query_pending: HashSet<TreeID>, 232 + /// Per-block counter, bumped every time that block's evaluation is 233 + /// (re-)scheduled; a completed evaluation only applies its result if 234 + /// this still matches the generation it was scheduled under, so an 235 + /// eval debounced-then-superseded by further typing on the *same* 236 + /// block can't clobber a newer one that finishes first. Keyed per 237 + /// block (not a single shared counter) so scheduling an eval for one 238 + /// query block — e.g. another one lazily evaluating for the first 239 + /// time on render — can never invalidate an unrelated block's 240 + /// in-flight eval; a stale-vs-fresh mismatch there previously let a 241 + /// block's evaluation abort permanently after an error (see the "does 242 + /// not recover after an error" bug this fixed), since an error still 243 + /// counts as "has a cached result" and so never got lazily retried. 244 + query_eval_generation: HashMap<TreeID, u64>, 245 + /// Sort state for a table-shaped query result: (column index, 246 + /// ascending). Absent = unsorted (result order as returned). 247 + table_sort: HashMap<TreeID, (usize, bool)>, 197 248 /// The local date as of the last journal refresh, checked on every 198 249 /// render so a rollover while the app stays open picks up a new 199 250 /// today's page without needing a restart or an edit to trigger it ··· 224 275 // first-ever build (the `open_or_create` fast path when the search 225 276 // directory doesn't exist yet) picks up that content directly, 226 277 // instead of needing a separate indexing pass for it. 227 - let search = SearchIndex::open_or_create(graph_dir.join("search-index"), storage.doc()) 228 - .expect("open or create search index"); 278 + let search = Arc::new( 279 + SearchIndex::open_or_create(graph_dir.join("search-index"), storage.doc()) 280 + .expect("open or create search index"), 281 + ); 229 282 230 283 let mut app = Self { 231 284 graph_dir, ··· 246 299 search_open: None, 247 300 search_focus: cx.focus_handle(), 248 301 similar: Vec::new(), 302 + query_results: HashMap::new(), 303 + query_pending: HashSet::new(), 304 + query_eval_generation: HashMap::new(), 305 + table_sort: HashMap::new(), 249 306 journal_today: today, 250 307 window_handle: window.window_handle(), 251 308 }; ··· 826 883 .unwrap_or_default(); 827 884 } 828 885 886 + /// Ctrl+Shift+Q: flip whether the focused block is a query block (spec: 887 + /// steel-queries/"Query blocks live in the outline"). A no-op if 888 + /// nothing is focused. 889 + fn toggle_query_block( 890 + &mut self, 891 + _: &ToggleQueryBlock, 892 + window: &mut Window, 893 + cx: &mut Context<Self>, 894 + ) { 895 + let Some(block) = self.editor.as_ref().map(|e| e.block) else { 896 + return; 897 + }; 898 + let outline = Outline::new(self.storage.doc()); 899 + let currently = is_query_block(&outline, block); 900 + outline 901 + .set_property(block, "query", &PropertyValue::Bool(!currently)) 902 + .expect("focused block still exists"); 903 + self.storage 904 + .persist_update() 905 + .expect("persist query flag toggle"); 906 + // Refocus rather than just refresh: `focus_block` re-reads whether 907 + // this is now a query block and applies/removes syntax 908 + // highlighting and an initial evaluation accordingly (see its own 909 + // body) — duplicating that here would drift out of sync with it. 910 + self.editor = None; 911 + self.focus_block(block, window, cx); 912 + self.refresh_view_data(); 913 + } 914 + 915 + /// (Re-)evaluate a query block after `delay` (spec: "Live preview with 916 + /// debounce" — `Duration::ZERO` for "evaluate now", e.g. on first 917 + /// seeing a query block with no cached result yet). Runs the actual 918 + /// Steel evaluation on a plain OS thread via `eval_query` (which 919 + /// already contains and times out a runaway query — see its doc), so 920 + /// the debounce sleep and the eval itself never block the UI thread 921 + /// (spec: "Runaway query containment" — "the UI SHALL remain 922 + /// responsive throughout"). 923 + fn schedule_query_eval(&mut self, block: TreeID, delay: Duration, cx: &mut Context<Self>) { 924 + if self.query_pending.contains(&block) { 925 + return; 926 + } 927 + self.query_pending.insert(block); 928 + let generation = self.query_eval_generation.entry(block).or_insert(0); 929 + *generation += 1; 930 + let generation = *generation; 931 + 932 + let task = cx.spawn(async move |this, cx| { 933 + if !delay.is_zero() { 934 + cx.background_executor().timer(delay).await; 935 + } 936 + let Ok(Some((expr, graph, search))) = this.update(cx, |this, _cx| { 937 + if this.query_eval_generation.get(&block) != Some(&generation) { 938 + return None; 939 + } 940 + let outline = Outline::new(this.storage.doc()); 941 + let expr = outline.content(block).ok()?; 942 + let graph = Arc::new(GraphIndex::rebuild(this.storage.doc())); 943 + Some((expr, graph, this.search.clone())) 944 + }) else { 945 + let _ = this.update(cx, |this, _cx| { 946 + this.query_pending.remove(&block); 947 + }); 948 + return; 949 + }; 950 + 951 + let (tx, rx) = futures::channel::oneshot::channel(); 952 + std::thread::spawn(move || { 953 + let outcome = eval_query(&expr, graph, search, Duration::from_secs(2)); 954 + let _ = tx.send(outcome); 955 + }); 956 + let outcome = rx.await.ok(); 957 + 958 + let _ = this.update(cx, |this, cx| { 959 + this.query_pending.remove(&block); 960 + if this.query_eval_generation.get(&block) == Some(&generation) { 961 + if let Some(outcome) = outcome { 962 + this.query_results.insert(block, outcome); 963 + cx.notify(); 964 + } 965 + } 966 + }); 967 + }); 968 + task.detach(); 969 + } 970 + 829 971 /// Commit whatever was focused, then attach a fresh multi-line editor 830 972 /// to `id`, seeded with its current content, and request window focus 831 973 /// on it — "no intermediate state where zero or two editors exist" ··· 871 1013 } 872 1014 }); 873 1015 1016 + let is_query = is_query_block(&Outline::new(self.storage.doc()), id); 1017 + input.update(cx, |editor, cx| { 1018 + editor.set_highlight_scheme(is_query, cx); 1019 + }); 1020 + 874 1021 let content_observation = cx.observe(&input, move |this: &mut Self, _input, cx| { 875 1022 if let Some(ix) = this.rows.iter().position(|r| r.id == id) { 876 1023 this.list_state.splice(ix..ix + 1, 1); 877 1024 } 1025 + // Live preview with debounce (spec: steel-queries/"Live 1026 + // preview with debounce") — plain keystrokes don't reach any 1027 + // of the `BlockEditorEvent` variants above, so this content 1028 + // observer is the only signal that a query's source changed. 1029 + if is_query_block(&Outline::new(this.storage.doc()), id) { 1030 + this.schedule_query_eval(id, Duration::from_millis(400), cx); 1031 + } 878 1032 cx.notify(); 879 1033 }); 880 1034 ··· 888 1042 _content_observation: content_observation, 889 1043 }); 890 1044 self.refresh_similar(); 1045 + if is_query && !self.query_results.contains_key(&id) { 1046 + self.schedule_query_eval(id, Duration::ZERO, cx); 1047 + } 891 1048 } 892 1049 893 1050 /// Enter was pressed (spec: "Newline within a block vs. new block" — ··· 1311 1468 ) { 1312 1469 let content = outline.content(id).unwrap_or_default(); 1313 1470 let children = outline.children(Some(id)); 1471 + let is_query = is_query_block(outline, id); 1314 1472 rows.push(VisibleRow { 1315 1473 id, 1316 1474 depth, 1317 - blocks: markdown::parse_block(&content), 1475 + blocks: if is_query { 1476 + Vec::new() 1477 + } else { 1478 + markdown::parse_block(&content) 1479 + }, 1318 1480 has_children: !children.is_empty(), 1319 1481 folded: folded.contains(&id), 1482 + is_query, 1483 + raw_content: content, 1320 1484 }); 1321 1485 if folded.contains(&id) { 1322 1486 return; ··· 1326 1490 } 1327 1491 } 1328 1492 1493 + /// Whether `id` has the `query` property set to `true` (spec: steel- 1494 + /// queries/"Query blocks live in the outline"). 1495 + fn is_query_block(outline: &Outline, id: TreeID) -> bool { 1496 + outline 1497 + .properties(id) 1498 + .ok() 1499 + .and_then(|props| props.get("query").and_then(PropertyValue::as_bool)) 1500 + .unwrap_or(false) 1501 + } 1502 + 1329 1503 /// Reference color: distinct from body text, matching the spec's "visually 1330 1504 /// distinct" requirement for node references. 1331 1505 const REFERENCE_COLOR: u32 = 0x6ab0f3; ··· 1335 1509 /// zero rendered children) still occupies real, clickable space instead of 1336 1510 /// collapsing to nothing. 1337 1511 const ROW_MIN_HEIGHT: f32 = 22.0; 1512 + /// A query block's own row gets this background tint — without it a query 1513 + /// block was indistinguishable from an ordinary one. Deliberately applied 1514 + /// only to the row, not the result section below it too: tinting both 1515 + /// edge-to-edge read as one undifferentiated block rather than "a query 1516 + /// and its (indented) result". `QUERY_ACCENT_COLOR` colors the "RESULT" 1517 + /// label; deliberately not used for a border, which would consume layout 1518 + /// width sibling rows don't have and throw off alignment. 1519 + const QUERY_BG: u32 = 0x22252e; 1520 + const QUERY_ACCENT_COLOR: u32 = 0x7c9dd9; 1521 + /// Width a row's fold-arrow column plus its gap reserves before the 1522 + /// bullet (see the row-building code below: `w(px(14.0))` + `gap_1()`), 1523 + /// present even on a foldless row as blank space. A query's result section 1524 + /// has no fold column of its own, so its left padding adds this back in — 1525 + /// otherwise each result's bullet would land under the *query's* bullet 1526 + /// instead of clearly nested beneath it. 1527 + const FOLD_COLUMN_INSET: f32 = 18.0; 1528 + 1529 + /// Render a query block's raw source with the same token colors 1530 + /// `BlockEditor` uses while it's focused (see `editor::highlighted_line_runs`), 1531 + /// so a query block doesn't visibly change color scheme the moment you 1532 + /// click into or away from it. 1533 + fn render_scheme_source(text: &str) -> gpui::AnyElement { 1534 + div() 1535 + .font_family("monospace") 1536 + .flex() 1537 + .flex_col() 1538 + .children(text.split('\n').map(|line| { 1539 + let tokens = scheme_highlight::tokenize_line(line); 1540 + div() 1541 + .flex() 1542 + .flex_row() 1543 + .flex_wrap() 1544 + .children(scheme_source_spans(line, &tokens)) 1545 + })) 1546 + .into_any_element() 1547 + } 1548 + 1549 + fn scheme_source_spans(line: &str, tokens: &[scheme_highlight::Token]) -> Vec<gpui::AnyElement> { 1550 + use scheme_highlight::TokenKind; 1338 1551 1339 - /// Render a tantivy snippet (plain text with `<b>...</b>` marking matched 1340 - /// terms — see `SearchHit::snippet_html`) as bold/plain inline runs. This 1341 - /// is the one place raw HTML from `trawler-core` needs turning into 1342 - /// GPUI elements, so it's a hand-rolled split rather than a real parser: 1343 - /// tantivy's snippet generator only ever emits plain text plus `<b>`/`</b>`, 1344 - /// nothing else. 1552 + let mut spans = Vec::new(); 1553 + let mut cursor = 0; 1554 + let push = 1555 + |spans: &mut Vec<gpui::AnyElement>, range: std::ops::Range<usize>, color: Option<u32>| { 1556 + if range.is_empty() { 1557 + return; 1558 + } 1559 + let mut el = div().child(line[range].to_string()); 1560 + if let Some(color) = color { 1561 + el = el.text_color(rgb(color)); 1562 + } 1563 + spans.push(el.into_any_element()); 1564 + }; 1565 + for token in tokens { 1566 + push(&mut spans, cursor..token.range.start, None); 1567 + let color = match token.kind { 1568 + TokenKind::Paren => Some(editor::PAREN_COLOR), 1569 + TokenKind::Keyword => Some(editor::KEYWORD_COLOR), 1570 + TokenKind::String => Some(editor::STRING_COLOR), 1571 + TokenKind::Number => Some(editor::NUMBER_COLOR), 1572 + TokenKind::Comment => Some(editor::COMMENT_COLOR), 1573 + TokenKind::Symbol => None, 1574 + }; 1575 + push(&mut spans, token.range.clone(), color); 1576 + cursor = token.range.end; 1577 + } 1578 + push(&mut spans, cursor..line.len(), None); 1579 + spans 1580 + } 1581 + 1582 + /// Render a query's evaluation outcome beneath its source (spec: "Results 1583 + /// render in place" / "Projected properties become columns"). `outline` 1584 + /// reads each result block's content/ancestry for display and navigation; 1585 + /// `sort_state` is this block's current table sort, if any (column index, 1586 + /// ascending) — click a header to change it. 1587 + fn render_query_outcome( 1588 + block: TreeID, 1589 + outline: &Outline, 1590 + outcome: &QueryOutcome, 1591 + sort_state: Option<(usize, bool)>, 1592 + row_key: usize, 1593 + cx: &mut Context<TrawlerApp>, 1594 + ) -> gpui::AnyElement { 1595 + match outcome { 1596 + QueryOutcome::Errored(err) => div() 1597 + .text_color(rgb(0xf44747)) 1598 + .child(format!("Error: {}", err.message)) 1599 + .into_any_element(), 1600 + QueryOutcome::TimedOut => div() 1601 + .text_color(rgb(0xf44747)) 1602 + .child("Query timed out") 1603 + .into_any_element(), 1604 + QueryOutcome::Completed(QueryValue::Blocks(ids)) => { 1605 + if ids.is_empty() { 1606 + return div() 1607 + .text_color(rgb(MUTED_COLOR)) 1608 + .child("No results") 1609 + .into_any_element(); 1610 + } 1611 + let entries = ids.iter().enumerate().map(|(ix, &id)| { 1612 + let content = outline.content(id).unwrap_or_default(); 1613 + let mut page = id; 1614 + while let Some(parent) = outline.parent(page) { 1615 + page = parent; 1616 + } 1617 + let entry_key = row_key * 4096 + ix; 1618 + let body: gpui::AnyElement = if content.trim().is_empty() { 1619 + div() 1620 + .text_color(rgb(MUTED_COLOR)) 1621 + .child("(empty block)") 1622 + .into_any_element() 1623 + } else { 1624 + // Same Markdown rendering as an ordinary row (spec 1625 + // doesn't call for plain-text results, and a raw 1626 + // `**bold**`/`[[ref]]` source string reads as broken 1627 + // formatting, not as a preview). 1628 + div() 1629 + .flex() 1630 + .flex_col() 1631 + .gap_1() 1632 + .children(render_blocks( 1633 + &markdown::parse_block(&content), 1634 + entry_key, 1635 + cx, 1636 + )) 1637 + .into_any_element() 1638 + }; 1639 + div() 1640 + .id(("query-result", entry_key)) 1641 + .cursor_pointer() 1642 + .px_2() 1643 + .py_1() 1644 + .rounded_sm() 1645 + .flex() 1646 + .flex_row() 1647 + .items_start() 1648 + .gap_1() 1649 + .hover(|d| d.bg(rgb(0x2a2a3a))) 1650 + .child( 1651 + div() 1652 + .w(px(14.0)) 1653 + .flex() 1654 + .justify_center() 1655 + .text_color(rgb(MUTED_COLOR)) 1656 + .child("•"), 1657 + ) 1658 + .child(div().flex_1().child(body)) 1659 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1660 + this.navigate_to(View::Node(NodeId::tree(page)), Some(id), window, cx); 1661 + })) 1662 + }); 1663 + div().flex().flex_col().children(entries).into_any_element() 1664 + } 1665 + QueryOutcome::Completed(QueryValue::Table { columns, rows }) => { 1666 + render_query_table(block, columns, rows, sort_state, row_key, cx) 1667 + } 1668 + } 1669 + } 1670 + 1671 + /// A query table result, with clickable column headers to sort (spec: 1672 + /// "sortable by column"). Clicking the already-sorted column reverses 1673 + /// direction; clicking a different one sorts ascending by it. 1674 + fn render_query_table( 1675 + block: TreeID, 1676 + columns: &[String], 1677 + rows: &[Vec<String>], 1678 + sort_state: Option<(usize, bool)>, 1679 + row_key: usize, 1680 + cx: &mut Context<TrawlerApp>, 1681 + ) -> gpui::AnyElement { 1682 + let mut sorted_rows: Vec<&Vec<String>> = rows.iter().collect(); 1683 + if let Some((col, ascending)) = sort_state { 1684 + sorted_rows.sort_by(|a, b| { 1685 + let (a, b) = (a.get(col), b.get(col)); 1686 + if ascending { 1687 + a.cmp(&b) 1688 + } else { 1689 + b.cmp(&a) 1690 + } 1691 + }); 1692 + } 1693 + 1694 + let header = columns.iter().enumerate().map(|(ix, name)| { 1695 + let label = match sort_state { 1696 + Some((col, ascending)) if col == ix => { 1697 + format!("{name} {}", if ascending { "▲" } else { "▼" }) 1698 + } 1699 + _ => name.clone(), 1700 + }; 1701 + let next_ascending = 1702 + !matches!(sort_state, Some((col, ascending)) if col == ix && ascending); 1703 + div() 1704 + .id(("query-table-header", row_key * 4096 + ix)) 1705 + .flex_1() 1706 + .px_2() 1707 + .font_weight(FontWeight::BOLD) 1708 + .cursor_pointer() 1709 + .child(label) 1710 + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { 1711 + this.table_sort.insert(block, (ix, next_ascending)); 1712 + cx.notify(); 1713 + })) 1714 + }); 1715 + 1716 + let body = sorted_rows.into_iter().enumerate().map(|(row_ix, row)| { 1717 + div() 1718 + .id(("query-table-row", row_key * 4096 + row_ix)) 1719 + .flex() 1720 + .flex_row() 1721 + .children( 1722 + row.iter() 1723 + .map(|cell| div().flex_1().px_2().child(cell.clone())), 1724 + ) 1725 + }); 1726 + 1727 + div() 1728 + .flex() 1729 + .flex_col() 1730 + .child(div().flex().flex_row().children(header)) 1731 + .children(body) 1732 + .into_any_element() 1733 + } 1734 + 1345 1735 fn render_snippet(html: &str) -> gpui::AnyElement { 1346 1736 let mut spans = Vec::new(); 1347 1737 let mut bold = false; ··· 1493 1883 } 1494 1884 1495 1885 /// Row data extracted for the list-item closure: everything it needs to 1496 - /// render either a Markdown view or (for the one focused row) the live 1497 - /// editor, without borrowing `self` — `cx.processor`'s closure is 1498 - /// `'static` and runs on every visible-range change. 1886 + /// render either a Markdown view, a query block's raw source, or (for the 1887 + /// one focused row) the live editor, without borrowing `self` — 1888 + /// `cx.processor`'s closure is `'static` and runs on every visible-range 1889 + /// change. 1499 1890 enum RowContent { 1500 1891 Markdown(Vec<Block>), 1892 + /// A query block's source, shown verbatim (not Markdown-parsed) when 1893 + /// it isn't the focused row. 1894 + Source(String), 1501 1895 Editor(Entity<BlockEditor>), 1502 1896 } 1503 1897 1898 + /// Everything a row needs at render time, snapshotted out of `self` so the 1899 + /// `'static` `cx.processor` closure doesn't need to borrow it. 1900 + struct RowSnapshot { 1901 + id: TreeID, 1902 + depth: usize, 1903 + has_children: bool, 1904 + folded: bool, 1905 + content: RowContent, 1906 + is_query: bool, 1907 + query_outcome: Option<QueryOutcome>, 1908 + } 1909 + 1504 1910 impl Render for TrawlerApp { 1505 1911 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 1506 1912 self.check_journal_rollover(cx); 1507 1913 let row_count = self.rows.len(); 1508 1914 let focused_block = self.editor.as_ref().map(|e| e.block); 1509 1915 let editor_input = self.editor.as_ref().map(|e| e.input.clone()); 1510 - let rows: Vec<(TreeID, usize, bool, bool, RowContent)> = self 1916 + let rows: Vec<RowSnapshot> = self 1511 1917 .rows 1512 1918 .iter() 1513 1919 .map(|r| { 1514 1920 let content = if Some(r.id) == focused_block { 1515 1921 RowContent::Editor(editor_input.clone().expect("focused block has an editor")) 1922 + } else if r.is_query { 1923 + RowContent::Source(r.raw_content.clone()) 1516 1924 } else { 1517 1925 RowContent::Markdown(r.blocks.clone()) 1518 1926 }; 1519 - (r.id, r.depth, r.has_children, r.folded, content) 1927 + RowSnapshot { 1928 + id: r.id, 1929 + depth: r.depth, 1930 + has_children: r.has_children, 1931 + folded: r.folded, 1932 + content, 1933 + is_query: r.is_query, 1934 + query_outcome: self.query_results.get(&r.id).cloned(), 1935 + } 1520 1936 }) 1521 1937 .collect(); 1522 1938 ··· 1772 2188 .on_action(cx.listener(Self::navigate_forward)) 1773 2189 .on_action(cx.listener(Self::toggle_quick_open)) 1774 2190 .on_action(cx.listener(Self::toggle_search)) 2191 + .on_action(cx.listener(Self::toggle_query_block)) 1775 2192 .on_action(cx.listener(Self::create_page_for_view)) 1776 2193 .on_action(cx.listener(Self::zoom_in)) 1777 2194 .on_action(cx.listener(Self::zoom_out)) ··· 1794 2211 div().flex_1().child( 1795 2212 list( 1796 2213 self.list_state.clone(), 1797 - cx.processor(move |_this, ix: usize, _window, cx| { 1798 - let (id, depth, has_children, folded, content) = &rows[ix]; 2214 + cx.processor(move |this, ix: usize, _window, cx| { 2215 + let RowSnapshot { 2216 + id, 2217 + depth, 2218 + has_children, 2219 + folded, 2220 + content, 2221 + is_query, 2222 + query_outcome, 2223 + } = &rows[ix]; 1799 2224 let id = *id; 2225 + let is_query = *is_query; 2226 + // Lazily kick off an evaluation the first time 2227 + // a query block with no cached result appears 2228 + // in a rendered row — covers loading a page 2229 + // that already contains query blocks, not just 2230 + // the one currently being edited (that path is 2231 + // handled by `focus_block`/the content 2232 + // observer instead). 2233 + if is_query 2234 + && query_outcome.is_none() 2235 + && !this.query_pending.contains(&id) 2236 + { 2237 + this.schedule_query_eval(id, Duration::ZERO, cx); 2238 + } 1800 2239 // `items_start` (not `items_center`) so the 1801 2240 // fold arrow/bullet line up with the first line 1802 2241 // of the content instead of the vertical center 1803 2242 // of the whole row — matters once a row has 1804 2243 // more than one line (a wrapped/multi-paragraph 1805 2244 // block, a code fence, a heading). 1806 - let row = div() 2245 + let mut row = div() 1807 2246 .id(ix) 1808 2247 .w_full() 1809 2248 .pl(px(16.0 * *depth as f32 + 8.0)) ··· 1813 2252 .flex_row() 1814 2253 .items_start() 1815 2254 .gap_1(); 2255 + if is_query { 2256 + // A query block otherwise looks exactly 2257 + // like a plain outline row containing 2258 + // Scheme source — this tint (also applied 2259 + // to its result below) is what actually 2260 + // marks it as a query rather than content. 2261 + // Deliberately background only, not a 2262 + // border: a border consumes layout width 2263 + // that sibling rows don't have, which 2264 + // shifted a query row's content out of 2265 + // alignment with everything else. 2266 + row = row.bg(rgb(QUERY_BG)); 2267 + } 1816 2268 let fold_indicator: gpui::AnyElement = if *has_children { 1817 2269 div() 1818 2270 .id(("fold", ix)) ··· 1866 2318 )) 1867 2319 .children(render_blocks(blocks, ix, cx)), 1868 2320 ), 2321 + RowContent::Source(source) => row.child( 2322 + div() 2323 + .id(("content", ix)) 2324 + .flex_1() 2325 + .min_h(px(ROW_MIN_HEIGHT)) 2326 + .cursor_pointer() 2327 + .on_click(cx.listener( 2328 + move |this, _event: &ClickEvent, window, cx| { 2329 + this.focus_block(id, window, cx); 2330 + }, 2331 + )) 2332 + .child(render_scheme_source(source)), 2333 + ), 1869 2334 }; 1870 - row.into_any_element() 2335 + let row = row.into_any_element(); 2336 + if !is_query { 2337 + return row; 2338 + } 2339 + // Result renders beneath the query's own row 2340 + // (spec: "Results render in place"), indented 2341 + // to line up under its content rather than its 2342 + // bullet. 2343 + let outline = Outline::new(this.storage.doc()); 2344 + let result: gpui::AnyElement = match query_outcome { 2345 + Some(outcome) => render_query_outcome( 2346 + id, 2347 + &outline, 2348 + outcome, 2349 + this.table_sort.get(&id).copied(), 2350 + ix, 2351 + cx, 2352 + ), 2353 + None => div() 2354 + .text_color(rgb(MUTED_COLOR)) 2355 + .child("Evaluating…") 2356 + .into_any_element(), 2357 + }; 2358 + div() 2359 + .flex() 2360 + .flex_col() 2361 + .child(row) 2362 + .child( 2363 + // `ml`, not `pl`: padding is inside 2364 + // the box, so a background painted on 2365 + // this div would fill from its own 2366 + // left edge (x=0) and show as one 2367 + // full-width block regardless of the 2368 + // inset — margin actually moves the 2369 + // box, so the background only covers 2370 + // the indented region itself. 2371 + // 2372 + // `+ FOLD_COLUMN_INSET`: a sibling row 2373 + // at this depth reserves a fold-arrow 2374 + // column before its own bullet even 2375 + // when foldless (see the row-building 2376 + // code above) — without matching that 2377 + // offset, each result's own bullet 2378 + // landed almost under the *query's* 2379 + // bullet instead of clearly nested 2380 + // under it. 2381 + div() 2382 + .ml(px(16.0 * (*depth as f32 + 1.0) 2383 + + 8.0 2384 + + FOLD_COLUMN_INSET)) 2385 + .mr_2() 2386 + .mb_2() 2387 + .bg(rgb(QUERY_BG)) 2388 + .rounded_md() 2389 + .p_2() 2390 + .flex() 2391 + .flex_col() 2392 + .gap_1() 2393 + .child( 2394 + div() 2395 + .text_size(px(11.0)) 2396 + .text_color(rgb(QUERY_ACCENT_COLOR)) 2397 + .child("RESULT"), 2398 + ) 2399 + .child(result), 2400 + ) 2401 + .into_any_element() 1871 2402 }), 1872 2403 ) 1873 2404 .h_full(),
+325
crates/trawler/src/scheme_highlight.rs
··· 1 + //! Minimal Scheme tokenizer for query-block syntax highlighting and 2 + //! matching-paren indication (spec: steel-queries/"Lisp editing 3 + //! experience"). 4 + //! 5 + //! The spec calls for tree-sitter highlighting; this is a hand-rolled 6 + //! single-pass tokenizer instead. Query blocks are short, single-expression 7 + //! Steel snippets, not source files, so a real incremental tree-sitter 8 + //! grammar buys correctness on syntax this tokenizer already gets right 9 + //! (nested/quoted/vector forms) at the cost of a new grammar dependency and 10 + //! an incremental-reparse integration into `BlockEditor`. Revisit if query 11 + //! blocks grow complex enough for that gap to matter. 12 + 13 + use std::ops::Range; 14 + 15 + /// A token's visual category. `Paren` is split out from `Symbol` because 16 + /// matching-paren highlighting is computed separately (see 17 + /// `find_matching_paren`) and needs to identify paren tokens specifically. 18 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 19 + pub enum TokenKind { 20 + Paren, 21 + /// A recognized special form or query primitive (`define`, `lambda`, 22 + /// `if`, `and`, `or`, `not`, `tag`, `ref`, ...) — kept as a fixed list 23 + /// rather than "anything in head position" so plain identifiers don't 24 + /// flicker between colors as you type. 25 + Keyword, 26 + String, 27 + Number, 28 + /// `;` to end of line. 29 + Comment, 30 + /// `'symbol` / `#t` / `#f` and everything else. 31 + Symbol, 32 + } 33 + 34 + #[derive(Debug, Clone, PartialEq)] 35 + pub struct Token { 36 + pub range: Range<usize>, 37 + pub kind: TokenKind, 38 + } 39 + 40 + const KEYWORDS: &[&str] = &[ 41 + "define", 42 + "lambda", 43 + "if", 44 + "cond", 45 + "else", 46 + "let", 47 + "let*", 48 + "begin", 49 + "quote", 50 + "and", 51 + "or", 52 + "not", 53 + "bool-not", 54 + "filter", 55 + "map", 56 + "car", 57 + "cdr", 58 + "cons", 59 + "null?", 60 + "tag", 61 + "ref", 62 + "prop", 63 + "prop-eq", 64 + "date-range", 65 + "descendants", 66 + "search", 67 + "table", 68 + ]; 69 + 70 + /// Tokenize a single line of Scheme source. Query blocks are short and 71 + /// single-expression, but `BlockEditor` shapes text line-by-line already 72 + /// (see its module doc), so this matches that grain rather than requiring 73 + /// a whole-content parse. 74 + pub fn tokenize_line(line: &str) -> Vec<Token> { 75 + let bytes = line.as_bytes(); 76 + let mut tokens = Vec::new(); 77 + let mut i = 0usize; 78 + 79 + while i < bytes.len() { 80 + let c = bytes[i] as char; 81 + match c { 82 + ' ' | '\t' => { 83 + i += 1; 84 + } 85 + '(' | ')' | '[' | ']' => { 86 + tokens.push(Token { 87 + range: i..i + 1, 88 + kind: TokenKind::Paren, 89 + }); 90 + i += 1; 91 + } 92 + ';' => { 93 + tokens.push(Token { 94 + range: i..line.len(), 95 + kind: TokenKind::Comment, 96 + }); 97 + break; 98 + } 99 + '"' => { 100 + let start = i; 101 + i += 1; 102 + while i < bytes.len() && bytes[i] as char != '"' { 103 + // Skip an escaped character so `\"` doesn't end the 104 + // string early. 105 + i += if bytes[i] as char == '\\' { 2 } else { 1 }; 106 + } 107 + i = (i + 1).min(bytes.len()); 108 + tokens.push(Token { 109 + range: start..i, 110 + kind: TokenKind::String, 111 + }); 112 + } 113 + _ => { 114 + let start = i; 115 + while i < bytes.len() && !is_delimiter(bytes[i] as char) { 116 + i += 1; 117 + } 118 + let text = &line[start..i]; 119 + let kind = if text.is_empty() { 120 + // A lone delimiter character not otherwise handled 121 + // above (e.g. a stray quote/backtick) — advance past 122 + // it so tokenizing always makes forward progress. 123 + i += 1; 124 + TokenKind::Symbol 125 + } else if KEYWORDS.contains(&text.trim_start_matches('\'')) { 126 + TokenKind::Keyword 127 + } else if text 128 + .trim_start_matches(['-', '+']) 129 + .chars() 130 + .all(|c| c.is_ascii_digit() || c == '.') 131 + && text.chars().any(|c| c.is_ascii_digit()) 132 + { 133 + TokenKind::Number 134 + } else { 135 + TokenKind::Symbol 136 + }; 137 + tokens.push(Token { 138 + range: start..i, 139 + kind, 140 + }); 141 + } 142 + } 143 + } 144 + 145 + tokens 146 + } 147 + 148 + fn is_delimiter(c: char) -> bool { 149 + c.is_whitespace() || matches!(c, '(' | ')' | '[' | ']' | '"' | ';') 150 + } 151 + 152 + /// Find the paren adjacent to `cursor` (the char immediately before it, or 153 + /// immediately after) and its match, scanning the full content so matches 154 + /// spanning multiple lines are found. Returns `None` if the cursor isn't 155 + /// next to a paren or the paren is unmatched. Ignores parens inside string 156 + /// literals and comments so `"("` doesn't get treated as an opener. 157 + pub fn find_matching_paren(content: &str, cursor: usize) -> Option<(usize, usize)> { 158 + let candidates = [cursor.checked_sub(1), Some(cursor)]; 159 + for candidate in candidates.into_iter().flatten() { 160 + if let Some(pair) = try_match_at(content, candidate) { 161 + return Some(pair); 162 + } 163 + } 164 + None 165 + } 166 + 167 + fn try_match_at(content: &str, at: usize) -> Option<(usize, usize)> { 168 + let byte = *content.as_bytes().get(at)?; 169 + let opener = matches!(byte as char, '(' | '['); 170 + let closer = matches!(byte as char, ')' | ']'); 171 + if !opener && !closer { 172 + return None; 173 + } 174 + 175 + let mask = string_and_comment_mask(content); 176 + if mask[at] { 177 + return None; 178 + } 179 + 180 + if opener { 181 + let mut depth = 0i32; 182 + for (i, b) in content.bytes().enumerate().skip(at) { 183 + if mask[i] { 184 + continue; 185 + } 186 + match b as char { 187 + '(' | '[' => depth += 1, 188 + ')' | ']' => { 189 + depth -= 1; 190 + if depth == 0 { 191 + return Some((at, i)); 192 + } 193 + } 194 + _ => {} 195 + } 196 + } 197 + None 198 + } else { 199 + let mut depth = 0i32; 200 + for i in (0..=at).rev() { 201 + if mask[i] { 202 + continue; 203 + } 204 + match content.as_bytes()[i] as char { 205 + ')' | ']' => depth += 1, 206 + '(' | '[' => { 207 + depth -= 1; 208 + if depth == 0 { 209 + return Some((i, at)); 210 + } 211 + } 212 + _ => {} 213 + } 214 + } 215 + None 216 + } 217 + } 218 + 219 + /// Per-byte mask marking which bytes fall inside a string literal or a 220 + /// `;` comment, so paren matching can skip them. 221 + fn string_and_comment_mask(content: &str) -> Vec<bool> { 222 + let bytes = content.as_bytes(); 223 + let mut mask = vec![false; bytes.len()]; 224 + let mut in_string = false; 225 + let mut i = 0; 226 + while i < bytes.len() { 227 + let c = bytes[i] as char; 228 + if in_string { 229 + mask[i] = true; 230 + if c == '\\' && i + 1 < bytes.len() { 231 + mask[i + 1] = true; 232 + i += 2; 233 + continue; 234 + } 235 + if c == '"' { 236 + in_string = false; 237 + } 238 + i += 1; 239 + continue; 240 + } 241 + match c { 242 + '"' => { 243 + in_string = true; 244 + mask[i] = true; 245 + i += 1; 246 + } 247 + ';' => { 248 + for m in mask.iter_mut().skip(i) { 249 + *m = true; 250 + } 251 + break; 252 + } 253 + '\n' => { 254 + i += 1; 255 + } 256 + _ => { 257 + i += 1; 258 + } 259 + } 260 + } 261 + mask 262 + } 263 + 264 + #[cfg(test)] 265 + mod tests { 266 + use super::*; 267 + 268 + #[test] 269 + fn tokenizes_parens_keywords_strings_and_numbers() { 270 + let tokens = tokenize_line(r#"(and (tag 'project) (prop-eq "due" "2026-07-10"))"#); 271 + let kinds: Vec<TokenKind> = tokens.iter().map(|t| t.kind).collect(); 272 + assert_eq!(kinds[0], TokenKind::Paren); 273 + assert_eq!(kinds[1], TokenKind::Keyword); // and 274 + assert_eq!(kinds[2], TokenKind::Paren); 275 + assert_eq!(kinds[3], TokenKind::Keyword); // tag 276 + assert!(kinds.contains(&TokenKind::String)); 277 + } 278 + 279 + #[test] 280 + fn comment_runs_to_end_of_line() { 281 + let tokens = tokenize_line("(tag 'x) ; a comment (not code)"); 282 + let comment = tokens.iter().find(|t| t.kind == TokenKind::Comment); 283 + assert!(comment.is_some()); 284 + } 285 + 286 + #[test] 287 + fn number_token_recognized() { 288 + let tokens = tokenize_line("(+ 1 -2.5)"); 289 + let numbers: Vec<&Token> = tokens 290 + .iter() 291 + .filter(|t| t.kind == TokenKind::Number) 292 + .collect(); 293 + assert_eq!(numbers.len(), 2); 294 + } 295 + 296 + #[test] 297 + fn matches_simple_pair() { 298 + let content = "(tag 'project)"; 299 + assert_eq!(find_matching_paren(content, 1), Some((0, 13))); 300 + assert_eq!(find_matching_paren(content, 14), Some((0, 13))); 301 + } 302 + 303 + #[test] 304 + fn matches_across_nesting() { 305 + let content = "(and (tag 'a) (tag 'b))"; 306 + // cursor right after the outer closing paren 307 + assert_eq!( 308 + find_matching_paren(content, content.len()), 309 + Some((0, content.len() - 1)) 310 + ); 311 + } 312 + 313 + #[test] 314 + fn ignores_paren_inside_string() { 315 + let content = r#"(prop-eq "(" "x")"#; 316 + // the '(' inside the string must not be treated as an opener 317 + let string_paren_pos = content.find("\"(\"").unwrap() + 1; 318 + assert_eq!(find_matching_paren(content, string_paren_pos + 1), None); 319 + } 320 + 321 + #[test] 322 + fn unmatched_paren_returns_none() { 323 + assert_eq!(find_matching_paren("(tag 'project", 1), None); 324 + } 325 + }
+1
openspec/changes/trawler-mvp/design.md
··· 65 65 - **Capability scoping**: queries get read-only natives only. If Steel later becomes an extension language, write access lives in a separate deliberate context. Decided now to prevent scope creep. 66 66 - **"Scheme composes, Rust iterates"**: the query API exposes set-algebra primitives (blocks-with-tag, backlinks-of, in-date-range → native set handles; and/or/not → native intersection/union/difference). Steel lambdas never run per-block over the whole graph in the hot path. Per-block predicates are allowed only over already-narrowed sets. This is an API design decision, not an optimization pass. 67 67 - **Runaway queries**: eval runs off the UI thread with cancellation/time-boxing. Whether Steel supports clean interruption is unproven — gated by Spike S2. 68 + - **Query-block syntax highlighting (task 7.2)**: implemented as a hand-rolled single-pass Scheme tokenizer (`trawler::scheme_highlight`) rather than a tree-sitter grammar. Query blocks are short, single-expression snippets, not source files — a real incremental tree-sitter grammar buys correctness this tokenizer already gets right (nested/quoted forms, matching-paren across lines) at the cost of a new grammar dependency and incremental-reparse integration into the custom `BlockEditor`. Revisit if query blocks grow complex enough for that gap to matter. 68 69 69 70 ### D5: Journal timeline is the home view 70 71
+5 -5
openspec/changes/trawler-mvp/tasks.md
··· 57 57 58 58 ## 7. Query Blocks UI (trawler) 59 59 60 - - [ ] 7.1 Query block type: authoring affordance, persistence as ordinary block with query flag 61 - - [ ] 7.2 Scheme editing in the one-hot editor: tree-sitter highlighting, matching-paren indication 62 - - [ ] 7.3 Debounced background evaluation wired to the query engine; inline error display 63 - - [ ] 7.4 Result rendering: outline slice with navigation; table view with sortable columns for projected properties 64 - - [ ] 7.5 End-to-end latency check: edit query → preview update feels live on a realistic graph 60 + - [x] 7.1 Query block type: authoring affordance, persistence as ordinary block with query flag 61 + - [x] 7.2 Scheme editing in the one-hot editor: highlighting, matching-paren indication (hand-rolled tokenizer, not tree-sitter — see design.md) 62 + - [x] 7.3 Debounced background evaluation wired to the query engine; inline error display 63 + - [x] 7.4 Result rendering: outline slice with navigation; table view with sortable columns for projected properties 64 + - [x] 7.5 End-to-end latency check: edit query → preview update feels live on a realistic graph 65 65 66 66 ## 8. Hardening & Wrap-up 67 67