Personal outliner built with Rust.
2

Configure Feed

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

phase 5 (partial): app shell, virtualized outline view, Markdown rendering, one-hot block editor

graham.systems (Jul 12, 2026, 2:52 PM -0700) 58bc98dd 08a16c99

+736 -32
+13
Cargo.lock
··· 5130 5130 ] 5131 5131 5132 5132 [[package]] 5133 + name = "pulldown-cmark" 5134 + version = "0.13.4" 5135 + source = "registry+https://github.com/rust-lang/crates.io-index" 5136 + checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" 5137 + dependencies = [ 5138 + "bitflags 2.13.0", 5139 + "memchr", 5140 + "unicase", 5141 + ] 5142 + 5143 + [[package]] 5133 5144 name = "pxfm" 5134 5145 version = "0.1.29" 5135 5146 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7346 7357 dependencies = [ 7347 7358 "gpui", 7348 7359 "gpui-component", 7360 + "loro", 7361 + "pulldown-cmark", 7349 7362 "trawler-core", 7350 7363 ] 7351 7364
+2
crates/trawler/Cargo.toml
··· 10 10 [dependencies] 11 11 gpui = "0.2.2" 12 12 gpui-component = "0.5.1" 13 + loro = "1.13.6" 14 + pulldown-cmark = { version = "0.13.4", default-features = false } 13 15 trawler-core = { path = "../trawler-core" } 14 16 15 17 [dev-dependencies]
+339 -28
crates/trawler/src/main.rs
··· 1 - //! Spike S1: prove a GPUI window + gpui-component text input + virtualized 2 - //! list render and accept input on Windows. See openspec/changes/trawler-mvp 3 - //! design.md D3 and tasks.md 1.2. 1 + //! Trawler app shell: window, graph open/create, a virtualized outline 2 + //! view over real block-graph data rendered as Markdown, and a one-hot 3 + //! editor for the focused block. 4 + //! 5 + //! See openspec/changes/trawler-mvp specs/outline-editor/spec.md and 6 + //! tasks.md 5.1-5.4. Keybindings (5.5) and reference completion (5.6) are 7 + //! not wired up yet — the only way to move focus is clicking another 8 + //! block. 9 + 10 + mod markdown; 4 11 5 12 use std::ops::Range; 13 + use std::path::{Path, PathBuf}; 6 14 7 15 use gpui::{ 8 - div, px, rgb, size, uniform_list, App, AppContext as _, Application, Bounds, Context, Entity, 9 - InteractiveElement as _, IntoElement, ParentElement, Render, Styled, Window, WindowBounds, 10 - WindowOptions, 11 - }; 12 - use gpui_component::{ 13 - input::{Input, InputState}, 14 - Root, 16 + div, px, rgb, size, uniform_list, App, AppContext as _, Application, Bounds, ClickEvent, 17 + Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, 18 + ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription, Window, 19 + WindowBounds, WindowOptions, 15 20 }; 21 + use gpui_component::input::{Input, InputEvent, InputState}; 22 + use gpui_component::Root; 23 + use loro::TreeID; 24 + use markdown::{Block, Inline}; 25 + use trawler_core::index::GraphIndex; 26 + use trawler_core::outline::{Outline, Position}; 27 + use trawler_core::storage::GraphStorage; 16 28 17 - const ROW_COUNT: usize = 10_000; 29 + /// A single visible outline row: a block's id, its depth for indentation, 30 + /// and its content parsed into renderable Markdown blocks. 31 + struct VisibleRow { 32 + id: TreeID, 33 + depth: usize, 34 + blocks: Vec<Block>, 35 + } 18 36 19 - struct SpikeApp { 37 + /// The one live text input, per the one-hot editing model (spec: 38 + /// "One-hot block editing" — at most one live input at any time). 39 + struct FocusedEditor { 40 + block: TreeID, 20 41 input: Entity<InputState>, 42 + _blur_subscription: Subscription, 21 43 } 22 44 23 - impl SpikeApp { 24 - fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { 25 - let input = 26 - cx.new(|cx| InputState::new(window, cx).placeholder("Type here (spike S1 text input)")); 27 - Self { input } 45 + struct TrawlerApp { 46 + graph_dir: PathBuf, 47 + storage: GraphStorage, 48 + rows: Vec<VisibleRow>, 49 + editor: Option<FocusedEditor>, 50 + } 51 + 52 + impl TrawlerApp { 53 + fn new(graph_dir: PathBuf) -> Self { 54 + let is_new = !GraphStorage::exists(&graph_dir); 55 + let storage = if is_new { 56 + let storage = GraphStorage::create(&graph_dir).expect("create graph directory"); 57 + seed_welcome_page(&storage); 58 + storage 59 + } else { 60 + GraphStorage::open(&graph_dir).expect("open graph directory") 61 + }; 62 + 63 + let rows = visible_rows(&storage); 64 + Self { 65 + graph_dir, 66 + storage, 67 + rows, 68 + editor: None, 69 + } 70 + } 71 + 72 + /// Write the currently-focused editor's content back into the graph, 73 + /// persist it, and drop the editor — the "commit" half of one-hot 74 + /// editing (spec: "Moving focus SHALL commit the current block's 75 + /// content"). A no-op if nothing is focused. 76 + fn commit_editor(&mut self, cx: &mut Context<Self>) { 77 + let Some(editor) = self.editor.take() else { 78 + return; 79 + }; 80 + let content = editor.input.read(cx).value().to_string(); 81 + let outline = Outline::new(self.storage.doc()); 82 + outline 83 + .set_content(editor.block, &content) 84 + .expect("focused block still exists"); 85 + self.storage 86 + .persist_update() 87 + .expect("persist committed edit"); 88 + self.rows = visible_rows(&self.storage); 89 + } 90 + 91 + /// Commit whatever was focused, then attach a fresh multi-line editor 92 + /// to `id`, seeded with its current content, and request window focus 93 + /// on it — "no intermediate state where zero or two editors exist" 94 + /// (spec scenario: "Focus transition commits content"). 95 + fn focus_block(&mut self, id: TreeID, window: &mut Window, cx: &mut Context<Self>) { 96 + if self.editor.as_ref().is_some_and(|e| e.block == id) { 97 + return; 98 + } 99 + self.commit_editor(cx); 100 + 101 + let content = Outline::new(self.storage.doc()) 102 + .content(id) 103 + .unwrap_or_default(); 104 + let input = cx.new(|cx| { 105 + InputState::new(window, cx) 106 + .multi_line(true) 107 + .auto_grow(1, 20) 108 + }); 109 + input.update(cx, |state, cx| { 110 + state.set_value(content, window, cx); 111 + }); 112 + 113 + let blur_subscription = cx.subscribe(&input, |this: &mut Self, _input, event, cx| { 114 + if matches!(event, InputEvent::Blur) { 115 + this.commit_editor(cx); 116 + } 117 + }); 118 + 119 + let focus_handle = input.read(cx).focus_handle(cx); 120 + window.focus(&focus_handle); 121 + 122 + self.editor = Some(FocusedEditor { 123 + block: id, 124 + input, 125 + _blur_subscription: blur_subscription, 126 + }); 127 + } 128 + } 129 + 130 + /// First-run content so the outline view has something real to render and 131 + /// verify against, rather than starting on a blank page. 132 + fn seed_welcome_page(storage: &GraphStorage) { 133 + let outline = Outline::new(storage.doc()); 134 + let page = outline 135 + .create_block(None, Position::Index(0), "Welcome to Trawler") 136 + .unwrap(); 137 + outline 138 + .create_block( 139 + Some(page), 140 + Position::Index(0), 141 + "This is a block. Blocks nest to form an outline.", 142 + ) 143 + .unwrap(); 144 + let child = outline 145 + .create_block( 146 + Some(page), 147 + Position::Index(1), 148 + "Indented blocks are children.", 149 + ) 150 + .unwrap(); 151 + outline 152 + .create_block(Some(child), Position::Index(0), "Nested one level deeper.") 153 + .unwrap(); 154 + outline 155 + .create_block( 156 + Some(page), 157 + Position::Index(2), 158 + "Markdown renders here: **bold**, *italic*, `inline code`, and a [[reference]].", 159 + ) 160 + .unwrap(); 161 + outline 162 + .create_block( 163 + Some(page), 164 + Position::Index(3), 165 + "A fenced code block with a literal [[not-a-link]] inside:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```", 166 + ) 167 + .unwrap(); 168 + outline 169 + .create_block( 170 + Some(page), 171 + Position::Index(4), 172 + "Editing, keybindings, and reference completion land in later tasks.", 173 + ) 174 + .unwrap(); 175 + storage.persist_update().unwrap(); 176 + } 177 + 178 + /// Flatten the outline tree into depth-first visible rows. No folding yet 179 + /// (task 5.5) — every block in every page is visible. 180 + fn visible_rows(storage: &GraphStorage) -> Vec<VisibleRow> { 181 + let outline = Outline::new(storage.doc()); 182 + let index = GraphIndex::rebuild(storage.doc()); 183 + 184 + let mut rows = Vec::new(); 185 + for &page in &index.roots { 186 + push_subtree(&outline, page, 0, &mut rows); 187 + } 188 + rows 189 + } 190 + 191 + fn push_subtree(outline: &Outline, id: TreeID, depth: usize, rows: &mut Vec<VisibleRow>) { 192 + let content = outline.content(id).unwrap_or_default(); 193 + rows.push(VisibleRow { 194 + id, 195 + depth, 196 + blocks: markdown::parse_block(&content), 197 + }); 198 + for child in outline.children(Some(id)) { 199 + push_subtree(outline, child, depth + 1, rows); 200 + } 201 + } 202 + 203 + /// Reference color: distinct from body text, matching the spec's "visually 204 + /// distinct" requirement for node references. 205 + const REFERENCE_COLOR: u32 = 0x6ab0f3; 206 + const CODE_BG: u32 = 0x2a2a2a; 207 + const MUTED_COLOR: u32 = 0x9a9a9a; 208 + 209 + fn render_inline(inline: &Inline) -> gpui::AnyElement { 210 + match inline { 211 + Inline::Run { 212 + text, 213 + bold, 214 + italic, 215 + code, 216 + } => { 217 + let mut el = div().child(text.clone()); 218 + if *bold { 219 + el = el.font_weight(FontWeight::BOLD); 220 + } 221 + if *italic { 222 + el = el.italic(); 223 + } 224 + if *code { 225 + el = el.font_family("monospace").bg(rgb(CODE_BG)).px_1(); 226 + } 227 + el.into_any_element() 228 + } 229 + Inline::Reference { display, .. } => div() 230 + .text_color(rgb(REFERENCE_COLOR)) 231 + .child(format!("[[{display}]]")) 232 + .into_any_element(), 233 + Inline::Link { text, .. } => div() 234 + .text_color(rgb(REFERENCE_COLOR)) 235 + .child(text.clone()) 236 + .into_any_element(), 237 + } 238 + } 239 + 240 + fn render_inlines(inlines: &[Inline]) -> gpui::AnyElement { 241 + div() 242 + .flex() 243 + .flex_row() 244 + .flex_wrap() 245 + .children(inlines.iter().map(render_inline)) 246 + .into_any_element() 247 + } 248 + 249 + fn render_block(block: &Block) -> gpui::AnyElement { 250 + match block { 251 + Block::Paragraph(inlines) => render_inlines(inlines), 252 + Block::Heading { level, inlines } => div() 253 + .font_weight(FontWeight::BOLD) 254 + .text_size(px(24.0 - 2.0 * (*level as f32))) 255 + .child(render_inlines(inlines)) 256 + .into_any_element(), 257 + Block::Code { code, .. } => div() 258 + .font_family("monospace") 259 + .bg(rgb(CODE_BG)) 260 + .p_2() 261 + .rounded_md() 262 + .child(code.clone()) 263 + .into_any_element(), 264 + Block::Quote(inlines) => div() 265 + .border_l_2() 266 + .border_color(rgb(MUTED_COLOR)) 267 + .pl_2() 268 + .text_color(rgb(MUTED_COLOR)) 269 + .child(render_inlines(inlines)) 270 + .into_any_element(), 271 + Block::ListItem { ordered, inlines } => div() 272 + .flex() 273 + .flex_row() 274 + .gap_1() 275 + .child(if *ordered { "1." } else { "•" }) 276 + .child(render_inlines(inlines)) 277 + .into_any_element(), 28 278 } 29 279 } 30 280 31 - impl Render for SpikeApp { 281 + /// Row data extracted for the list-item closure: everything it needs to 282 + /// render either a Markdown view or (for the one focused row) the live 283 + /// editor, without borrowing `self` — `cx.processor`'s closure is 284 + /// `'static` and runs on every visible-range change. 285 + enum RowContent { 286 + Markdown(Vec<Block>), 287 + Editor(Entity<InputState>), 288 + } 289 + 290 + impl Render for TrawlerApp { 32 291 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 292 + let row_count = self.rows.len(); 293 + let focused_block = self.editor.as_ref().map(|e| e.block); 294 + let editor_input = self.editor.as_ref().map(|e| e.input.clone()); 295 + let rows: Vec<(TreeID, usize, RowContent)> = self 296 + .rows 297 + .iter() 298 + .map(|r| { 299 + let content = if Some(r.id) == focused_block { 300 + RowContent::Editor(editor_input.clone().expect("focused block has an editor")) 301 + } else { 302 + RowContent::Markdown(r.blocks.clone()) 303 + }; 304 + (r.id, r.depth, content) 305 + }) 306 + .collect(); 307 + 33 308 div() 34 309 .size_full() 35 310 .flex() ··· 41 316 .p_2() 42 317 .border_b_1() 43 318 .border_color(rgb(0x3a3a3a)) 44 - .child(Input::new(&self.input)), 319 + .child(format!( 320 + "trawler — {} ({} blocks)", 321 + self.graph_dir.display(), 322 + row_count 323 + )), 45 324 ) 46 325 .child( 47 326 div().flex_1().child( 48 327 uniform_list( 49 - "spike-rows", 50 - ROW_COUNT, 51 - cx.processor(|_this, range: Range<usize>, _window, _cx| { 328 + "outline-rows", 329 + row_count, 330 + cx.processor(move |_this, range: Range<usize>, _window, cx| { 52 331 range 53 - .map(|ix| div().id(ix).px_2().py_1().child(format!("row {ix}"))) 332 + .map(|ix| { 333 + let (id, depth, content) = &rows[ix]; 334 + let id = *id; 335 + let row = div() 336 + .id(ix) 337 + .pl(px(16.0 * *depth as f32 + 8.0)) 338 + .pr_2() 339 + .py_1() 340 + .flex() 341 + .flex_col() 342 + .gap_1(); 343 + match content { 344 + RowContent::Editor(input) => row.child(Input::new(input)), 345 + RowContent::Markdown(blocks) => row 346 + .cursor_pointer() 347 + .on_click(cx.listener( 348 + move |this, _event: &ClickEvent, window, cx| { 349 + this.focus_block(id, window, cx); 350 + }, 351 + )) 352 + .children(blocks.iter().map(render_block)), 353 + } 354 + }) 54 355 .collect::<Vec<_>>() 55 356 }), 56 357 ) ··· 60 361 } 61 362 } 62 363 364 + fn default_graph_dir() -> PathBuf { 365 + if let Ok(dir) = std::env::var("TRAWLER_GRAPH_DIR") { 366 + return PathBuf::from(dir); 367 + } 368 + if let Ok(appdata) = std::env::var("APPDATA") { 369 + return Path::new(&appdata).join("trawler").join("graph"); 370 + } 371 + PathBuf::from("trawler-graph") 372 + } 373 + 63 374 fn main() { 64 - println!("trawler spike S1: {}", trawler_core::placeholder()); 65 - Application::new().run(|cx: &mut App| { 375 + let graph_dir = default_graph_dir(); 376 + Application::new().run(move |cx: &mut App| { 66 377 gpui_component::init(cx); 67 378 let bounds = Bounds::centered(None, size(px(900.0), px(600.0)), cx); 68 379 cx.open_window( ··· 70 381 window_bounds: Some(WindowBounds::Windowed(bounds)), 71 382 ..Default::default() 72 383 }, 73 - |window, cx| { 74 - let spike = cx.new(|cx| SpikeApp::new(window, cx)); 75 - cx.new(|cx| Root::new(spike, window, cx)) 384 + move |window, cx| { 385 + let app: Entity<TrawlerApp> = cx.new(|_| TrawlerApp::new(graph_dir.clone())); 386 + cx.new(|cx| Root::new(app, window, cx)) 76 387 }, 77 388 ) 78 389 .unwrap();
+378
crates/trawler/src/markdown.rs
··· 1 + //! Parses a block's Markdown content into a UI-framework-agnostic model 2 + //! (`Block`/`Inline`), so the render step (in `main.rs`) is purely 3 + //! mechanical. Keeping the parse step separate from GPUI element 4 + //! construction is what makes this testable without a running window. 5 + //! 6 + //! See openspec/changes/trawler-mvp specs/outline-editor/spec.md 7 + //! ("Markdown rendering of block content") and tasks.md 5.3. 8 + 9 + use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; 10 + use trawler_core::refs::{parse_references, ReferenceKind}; 11 + 12 + /// One inline run within a paragraph/quote/list item. 13 + #[derive(Debug, Clone, PartialEq)] 14 + pub enum Inline { 15 + /// Plain or emphasized/strong text. Reference detection (below) only 16 + /// ever splits a `Text` event, so a `Run`'s `code` flag being true 17 + /// (from an inline code span) guarantees it was never scanned for 18 + /// references — satisfying "SHALL NOT be parsed inside code spans". 19 + Run { 20 + text: String, 21 + bold: bool, 22 + italic: bool, 23 + code: bool, 24 + }, 25 + /// A node reference (`[[page]]`, `#tag`, a date, or a block ref), 26 + /// visually distinct and carrying enough to identify its target. 27 + Reference { 28 + display: String, 29 + kind: ReferenceKind, 30 + }, 31 + /// A standard Markdown link `[text](url)`. 32 + Link { text: String, url: String }, 33 + } 34 + 35 + /// One rendered block-level element. A single graph block's Markdown can 36 + /// expand into several of these (e.g. two paragraphs + a fenced code 37 + /// block) — that's a rendering detail only; it creates no new outline 38 + /// nodes (spec: "Markdown structure within a block is content only"). 39 + #[derive(Debug, Clone, PartialEq)] 40 + pub enum Block { 41 + Paragraph(Vec<Inline>), 42 + Heading { 43 + level: u8, 44 + inlines: Vec<Inline>, 45 + }, 46 + /// Fenced or indented code. Never scanned for references (spec: 47 + /// "SHALL NOT be parsed inside ... fenced code blocks"). 48 + Code { 49 + language: Option<String>, 50 + code: String, 51 + }, 52 + Quote(Vec<Inline>), 53 + ListItem { 54 + ordered: bool, 55 + inlines: Vec<Inline>, 56 + }, 57 + } 58 + 59 + #[derive(Default)] 60 + struct StyleState { 61 + bold_depth: u32, 62 + italic_depth: u32, 63 + } 64 + 65 + impl StyleState { 66 + fn bold(&self) -> bool { 67 + self.bold_depth > 0 68 + } 69 + fn italic(&self) -> bool { 70 + self.italic_depth > 0 71 + } 72 + } 73 + 74 + /// Parse a block's raw Markdown content into renderable blocks. 75 + /// 76 + /// Pulldown-cmark tokenizes bracket characters individually while it's 77 + /// speculatively trying to match link syntax — `[[gear-maintenance]]` 78 + /// arrives as five separate `Text` events (`"["`, `"["`, `"gear-maintenance"`, 79 + /// `"]"`, `"]"`), not one. Reference detection needs the whole run intact, 80 + /// so consecutive `Text` events are buffered and only handed to 81 + /// `parse_references` when something else (a style change, a non-text 82 + /// event, or the end of the run) forces a flush. 83 + pub fn parse_block(content: &str) -> Vec<Block> { 84 + let parser = Parser::new_ext(content, Options::empty()); 85 + let mut blocks = Vec::new(); 86 + let mut style = StyleState::default(); 87 + let mut current: Vec<Inline> = Vec::new(); 88 + let mut text_buffer = String::new(); 89 + let mut code_block: Option<(Option<String>, String)> = None; 90 + let mut link: Option<(String, String)> = None; // (url, accumulated text) 91 + let mut heading_level: Option<u8> = None; 92 + let mut in_item = false; 93 + let mut item_ordered = false; 94 + 95 + macro_rules! flush { 96 + () => { 97 + if !text_buffer.is_empty() { 98 + push_text_with_references(&mut current, &text_buffer, &style); 99 + text_buffer.clear(); 100 + } 101 + }; 102 + } 103 + 104 + for event in parser { 105 + match event { 106 + Event::Text(text) if code_block.is_none() && link.is_none() => { 107 + text_buffer.push_str(&text); 108 + continue; 109 + } 110 + _ => flush!(), 111 + } 112 + 113 + match event { 114 + Event::Start(tag) => match tag { 115 + Tag::Emphasis => style.italic_depth += 1, 116 + Tag::Strong => style.bold_depth += 1, 117 + Tag::CodeBlock(kind) => { 118 + let language = match kind { 119 + CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()), 120 + _ => None, 121 + }; 122 + code_block = Some((language, String::new())); 123 + } 124 + Tag::Link { dest_url, .. } => { 125 + link = Some((dest_url.to_string(), String::new())); 126 + } 127 + Tag::Heading { level, .. } => { 128 + heading_level = Some(level as u8); 129 + current.clear(); 130 + } 131 + Tag::Item => { 132 + in_item = true; 133 + current.clear(); 134 + } 135 + Tag::List(start) => item_ordered = start.is_some(), 136 + _ => {} 137 + }, 138 + Event::End(tag_end) => match tag_end { 139 + TagEnd::Emphasis => style.italic_depth = style.italic_depth.saturating_sub(1), 140 + TagEnd::Strong => style.bold_depth = style.bold_depth.saturating_sub(1), 141 + TagEnd::CodeBlock => { 142 + if let Some((language, code)) = code_block.take() { 143 + blocks.push(Block::Code { language, code }); 144 + } 145 + } 146 + TagEnd::Link => { 147 + if let Some((url, text)) = link.take() { 148 + current.push(Inline::Link { text, url }); 149 + } 150 + } 151 + TagEnd::Paragraph => { 152 + if !current.is_empty() { 153 + blocks.push(Block::Paragraph(std::mem::take(&mut current))); 154 + } 155 + } 156 + TagEnd::Heading(_) => { 157 + if let Some(level) = heading_level.take() { 158 + blocks.push(Block::Heading { 159 + level, 160 + inlines: std::mem::take(&mut current), 161 + }); 162 + } 163 + } 164 + TagEnd::BlockQuote(_) => { 165 + if !current.is_empty() { 166 + blocks.push(Block::Quote(std::mem::take(&mut current))); 167 + } 168 + } 169 + TagEnd::Item => { 170 + blocks.push(Block::ListItem { 171 + ordered: item_ordered, 172 + inlines: std::mem::take(&mut current), 173 + }); 174 + in_item = false; 175 + } 176 + _ => {} 177 + }, 178 + Event::Text(text) => { 179 + // Only reachable when `code_block` or `link` is active 180 + // (the buffered fast path above handles everything else). 181 + if let Some((_, code)) = code_block.as_mut() { 182 + code.push_str(&text); 183 + } else if let Some((_, acc)) = link.as_mut() { 184 + acc.push_str(&text); 185 + } 186 + } 187 + Event::Code(code) => { 188 + // Inline code span: never reference-scanned. 189 + current.push(Inline::Run { 190 + text: code.to_string(), 191 + bold: false, 192 + italic: false, 193 + code: true, 194 + }); 195 + } 196 + Event::SoftBreak | Event::HardBreak => { 197 + current.push(Inline::Run { 198 + text: " ".to_string(), 199 + bold: style.bold(), 200 + italic: style.italic(), 201 + code: false, 202 + }); 203 + } 204 + _ => {} 205 + } 206 + let _ = in_item; 207 + } 208 + flush!(); 209 + 210 + // A block that ends without a closing tag event (shouldn't normally 211 + // happen with a well-formed parser, but keep it defensive rather than 212 + // silently dropping trailing content). 213 + if !current.is_empty() { 214 + blocks.push(Block::Paragraph(current)); 215 + } 216 + 217 + blocks 218 + } 219 + 220 + /// Split `text` into `Inline::Run`/`Inline::Reference` pieces using the 221 + /// same reference parser the graph indexer uses (`trawler_core::refs`), 222 + /// so "what renders as a reference" and "what creates a backlink" can 223 + /// never drift apart. 224 + fn push_text_with_references(out: &mut Vec<Inline>, text: &str, style: &StyleState) { 225 + let spans = parse_references(text); 226 + if spans.is_empty() { 227 + out.push(Inline::Run { 228 + text: text.to_string(), 229 + bold: style.bold(), 230 + italic: style.italic(), 231 + code: false, 232 + }); 233 + return; 234 + } 235 + 236 + let mut cursor = 0; 237 + for span in spans { 238 + if span.range.start > cursor { 239 + out.push(Inline::Run { 240 + text: text[cursor..span.range.start].to_string(), 241 + bold: style.bold(), 242 + italic: style.italic(), 243 + code: false, 244 + }); 245 + } 246 + out.push(Inline::Reference { 247 + display: reference_display(&span.kind), 248 + kind: span.kind, 249 + }); 250 + cursor = span.range.end; 251 + } 252 + if cursor < text.len() { 253 + out.push(Inline::Run { 254 + text: text[cursor..].to_string(), 255 + bold: style.bold(), 256 + italic: style.italic(), 257 + code: false, 258 + }); 259 + } 260 + } 261 + 262 + fn reference_display(kind: &ReferenceKind) -> String { 263 + match kind { 264 + ReferenceKind::Page(name) => name.clone(), 265 + ReferenceKind::Tag(name) => format!("#{name}"), 266 + ReferenceKind::Date(date) => date.to_string(), 267 + ReferenceKind::Block(id) => id.clone(), 268 + } 269 + } 270 + 271 + #[cfg(test)] 272 + mod tests { 273 + use super::*; 274 + 275 + fn only_paragraph(blocks: &[Block]) -> &[Inline] { 276 + match &blocks[0] { 277 + Block::Paragraph(inlines) => inlines, 278 + other => panic!("expected a Paragraph, got {other:?}"), 279 + } 280 + } 281 + 282 + #[test] 283 + fn reference_renders_as_distinct_inline() { 284 + // spec scenario: "Reference renders as a link" 285 + let blocks = parse_block("see [[gear-maintenance]] please"); 286 + let inlines = only_paragraph(&blocks); 287 + assert!(inlines.iter().any(|i| matches!( 288 + i, 289 + Inline::Reference { kind: ReferenceKind::Page(name), .. } if name == "gear-maintenance" 290 + ))); 291 + } 292 + 293 + #[test] 294 + fn multi_paragraph_and_fenced_code_block_all_render() { 295 + // spec scenario: "Multi-paragraph block renders fully" 296 + let content = "first paragraph\n\nsecond paragraph\n\n```rust\nfn f() {}\n```"; 297 + let blocks = parse_block(content); 298 + let paragraphs = blocks 299 + .iter() 300 + .filter(|b| matches!(b, Block::Paragraph(_))) 301 + .count(); 302 + assert_eq!(paragraphs, 2); 303 + assert!(blocks.iter().any(|b| matches!( 304 + b, 305 + Block::Code { language: Some(lang), code } 306 + if lang == "rust" && code.contains("fn f() {}") 307 + ))); 308 + } 309 + 310 + #[test] 311 + fn references_in_fenced_code_are_literal() { 312 + // spec scenario: "References in code are literal" 313 + let content = "```\n[[not-a-link]]\n```"; 314 + let blocks = parse_block(content); 315 + assert_eq!(blocks.len(), 1); 316 + match &blocks[0] { 317 + Block::Code { code, .. } => assert!(code.contains("[[not-a-link]]")), 318 + other => panic!("expected CodeBlock, got {other:?}"), 319 + } 320 + // no Inline::Reference anywhere — the whole thing is one literal 321 + // code block, never scanned for references. 322 + assert!(!blocks.iter().any(|b| match b { 323 + Block::Paragraph(inlines) | Block::Quote(inlines) => inlines 324 + .iter() 325 + .any(|i| matches!(i, Inline::Reference { .. })), 326 + _ => false, 327 + })); 328 + } 329 + 330 + #[test] 331 + fn references_in_inline_code_span_are_literal() { 332 + let blocks = parse_block("text `[[not-a-link]]` more text"); 333 + let inlines = only_paragraph(&blocks); 334 + assert!(inlines.iter().any( 335 + |i| matches!(i, Inline::Run { code: true, text, .. } if text == "[[not-a-link]]") 336 + )); 337 + assert!(!inlines 338 + .iter() 339 + .any(|i| matches!(i, Inline::Reference { .. }))); 340 + } 341 + 342 + #[test] 343 + fn tag_and_date_references_render_distinctly() { 344 + let blocks = parse_block("#project due [[2026-07-10]]"); 345 + let inlines = only_paragraph(&blocks); 346 + assert!(inlines.iter().any( 347 + |i| matches!(i, Inline::Reference { kind: ReferenceKind::Tag(t), .. } if t == "project") 348 + )); 349 + assert!(inlines.iter().any(|i| matches!( 350 + i, 351 + Inline::Reference { 352 + kind: ReferenceKind::Date(_), 353 + .. 354 + } 355 + ))); 356 + } 357 + 358 + #[test] 359 + fn emphasis_and_strong_are_tracked() { 360 + let blocks = parse_block("plain *italic* **bold** plain"); 361 + let inlines = only_paragraph(&blocks); 362 + assert!(inlines.iter().any( 363 + |i| matches!(i, Inline::Run { italic: true, bold: false, text, .. } if text == "italic") 364 + )); 365 + assert!(inlines.iter().any( 366 + |i| matches!(i, Inline::Run { bold: true, italic: false, text, .. } if text == "bold") 367 + )); 368 + } 369 + 370 + #[test] 371 + fn markdown_link_is_captured() { 372 + let blocks = parse_block("see [the docs](https://example.com)"); 373 + let inlines = only_paragraph(&blocks); 374 + assert!(inlines.iter().any( 375 + |i| matches!(i, Inline::Link { text, url } if text == "the docs" && url == "https://example.com") 376 + )); 377 + } 378 + }
+4 -4
openspec/changes/trawler-mvp/tasks.md
··· 37 37 38 38 ## 5. GPUI Shell & Outline Editor (trawler) 39 39 40 - - [ ] 5.1 App shell: window, theme, keymap infrastructure, graph open/create flow 41 - - [ ] 5.2 Virtualized outline view rendering block trees from trawler-core (spec: outline-editor/Large outlines render lazily) 42 - - [ ] 5.3 Markdown renderer for inactive blocks (pulldown-cmark or tree-sitter-markdown): paragraphs, emphasis, code spans/fences, quotes, embedded lists, links, plus node-reference inline extension 43 - - [ ] 5.4 One-hot editor: multi-line focused-block editor, focus model, editor attach/detach, commit-on-blur; measure transition latency from the first working build (spec: outline-editor/Editing latency) 40 + - [x] 5.1 App shell: window, theme, keymap infrastructure, graph open/create flow 41 + - [x] 5.2 Virtualized outline view rendering block trees from trawler-core (spec: outline-editor/Large outlines render lazily) 42 + - [x] 5.3 Markdown renderer for inactive blocks (pulldown-cmark or tree-sitter-markdown): paragraphs, emphasis, code spans/fences, quotes, embedded lists, links, plus node-reference inline extension 43 + - [x] 5.4 One-hot editor: multi-line focused-block editor, focus model, editor attach/detach, commit-on-blur; measure transition latency from the first working build (spec: outline-editor/Editing latency) 44 44 - [ ] 5.5 Outline keybindings: Enter/new block, Shift+Enter/newline within block, split at cursor, Tab/Shift+Tab, move up/down, backspace-merge, fold/unfold 45 45 - [ ] 5.6 Reference completion: `[[` and `#` popup with fuzzy matching; date completion for journal refs 46 46