Personal outliner built with Rust.
3

Configure Feed

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

phase 6 (6.1-6.5): journal timeline, navigation history, follow-reference, backlinks panel, block zoom

- journal timeline home view with date-rollover handling and empty-page suppression
- Ctrl+Enter follow-reference; Alt+Left/Right navigation history
- backlinks panel grouped by containing page
- Ctrl+./Ctrl+, block zoom with ancestor breadcrumb
- fix invisible/unclickable empty blocks (zero-height Markdown rows) and the page-title-as-block corruption bug it caused (Enter/Backspace on a page root could fragment or delete pages)
- replace uniform_list with gpui::list for correct variable-height rows; align bullet/fold marker to first line

graham.systems (Jul 12, 2026, 2:52 PM -0700) bb4fb08e 315c4d52

+672 -117
+28
crates/trawler/src/editor.rs
··· 25 25 PaintQuad, ParentElement, Pixels, Point, Render, ShapedLine, SharedString, Style, Styled, 26 26 Subscription, TextRun, UTF16Selection, Window, 27 27 }; 28 + use trawler_core::refs::{parse_references, ReferenceKind}; 28 29 use unicode_segmentation::UnicodeSegmentation; 29 30 30 31 pub const CONTEXT: &str = "BlockEditor"; ··· 53 54 EditorPaste, 54 55 EditorCut, 55 56 EditorCopy, 57 + EditorFollowReference, 56 58 ] 57 59 ); 58 60 ··· 79 81 KeyBinding::new("ctrl-v", EditorPaste, Some(CONTEXT)), 80 82 KeyBinding::new("ctrl-c", EditorCopy, Some(CONTEXT)), 81 83 KeyBinding::new("ctrl-x", EditorCut, Some(CONTEXT)), 84 + KeyBinding::new("ctrl-enter", EditorFollowReference, Some(CONTEXT)), 82 85 ]); 83 86 } 84 87 ··· 112 115 trigger: char, 113 116 query: String, 114 117 }, 118 + /// Ctrl+Enter with the cursor at/inside a reference span (spec: 119 + /// graph-navigation "Follow reference from keyboard"). 120 + FollowReference(ReferenceKind), 115 121 } 116 122 117 123 /// One candidate in the reference-completion popup. ··· 490 496 } 491 497 } 492 498 499 + /// Find the reference span containing (or immediately before) the 500 + /// cursor on its current line and emit it for the owner to navigate to. 501 + /// A no-op if the cursor isn't on a reference. 502 + fn follow_reference( 503 + &mut self, 504 + _: &EditorFollowReference, 505 + _: &mut Window, 506 + cx: &mut Context<Self>, 507 + ) { 508 + let cursor = self.cursor_offset(); 509 + let line = self.line_bounds(cursor); 510 + let line_text = &self.content[line.clone()]; 511 + let cursor_in_line = cursor - line.start; 512 + let target = parse_references(line_text) 513 + .into_iter() 514 + .find(|span| span.range.contains(&cursor_in_line) || span.range.end == cursor_in_line); 515 + if let Some(span) = target { 516 + cx.emit(BlockEditorEvent::FollowReference(span.kind)); 517 + } 518 + } 519 + 493 520 fn cut(&mut self, _: &EditorCut, window: &mut Window, cx: &mut Context<Self>) { 494 521 if !self.selected_range.is_empty() { 495 522 cx.write_to_clipboard(ClipboardItem::new_string( ··· 976 1003 .on_action(cx.listener(Self::paste)) 977 1004 .on_action(cx.listener(Self::copy)) 978 1005 .on_action(cx.listener(Self::cut)) 1006 + .on_action(cx.listener(Self::follow_reference)) 979 1007 .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) 980 1008 .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) 981 1009 .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
+639 -112
crates/trawler/src/main.rs
··· 1 - //! Trawler app shell: window, graph open/create, a virtualized outline 2 - //! view over real block-graph data rendered as Markdown, a one-hot editor 3 - //! for the focused block, and outline keybindings. 1 + //! Trawler app shell: window, graph open/create, a journal-timeline home 2 + //! view with node zoom/backlinks, a one-hot editor for the focused block, 3 + //! and outline keybindings. 4 4 //! 5 - //! See openspec/changes/trawler-mvp specs/outline-editor/spec.md and 6 - //! tasks.md 5.1-5.5. Reference completion (5.6) is not wired up yet. 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. 7 9 //! 8 10 //! The one-hot editor is `editor::BlockEditor`, built directly on GPUI's 9 11 //! own `EntityInputHandler`/`Element` primitives rather than 10 12 //! `gpui-component`'s `Input` — see that module's doc for why. Every 11 - //! outline keybinding (Enter/Tab/Shift+Tab/Backspace-at-start/Alt+Up/ 12 - //! Alt+Down) is owned end-to-end there and reported up as a 13 + //! outline keybinding is owned end-to-end there and reported up as a 13 14 //! `BlockEditorEvent`; this module only decides what those intents mean in 14 15 //! graph terms. 16 + //! 17 + //! ## The view model 18 + //! 19 + //! [`View`] is either the journal timeline or a single [`trawler_core::graph::NodeId`]. 20 + //! Using `NodeId` (not just `TreeID`) for the latter lets "zoom into a 21 + //! block", "view a page", and "view a tag or date's backlinks" share one 22 + //! code path: a `NodeId::Tree` that's a root renders as a full page: a 23 + //! non-root renders as a zoomed block with an ancestor breadcrumb; a `Tag` 24 + //! or `Date` has no content of its own, so it renders as just a header and 25 + //! its backlinks. Backlinks are always shown below the main content for 26 + //! any non-Journal view (spec: "Backlinks are always visible"). 15 27 16 28 mod editor; 17 29 mod markdown; 18 30 19 31 use std::collections::HashSet; 20 - use std::ops::Range; 21 32 use std::path::{Path, PathBuf}; 22 33 34 + use chrono::NaiveDate; 23 35 use editor::{BlockEditor, BlockEditorEvent, CompletionCandidate}; 24 36 use gpui::{ 25 - div, px, rgb, size, uniform_list, App, AppContext as _, Application, Bounds, ClickEvent, 26 - Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, 27 - ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription, Window, 28 - WindowBounds, WindowOptions, 37 + actions, div, list, px, rgb, size, App, AppContext as _, Application, Bounds, ClickEvent, 38 + Context, Entity, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, KeyBinding, 39 + ListAlignment, ListState, ParentElement, Render, StatefulInteractiveElement as _, Styled, 40 + Subscription, Window, WindowBounds, WindowOptions, 29 41 }; 30 42 use loro::TreeID; 31 43 use markdown::{Block, Inline}; 44 + use trawler_core::graph::NodeId; 32 45 use trawler_core::index::GraphIndex; 33 46 use trawler_core::outline::{Outline, Position}; 47 + use trawler_core::refs::ReferenceKind; 34 48 use trawler_core::storage::GraphStorage; 35 49 50 + actions!(trawler, [NavigateBack, NavigateForward, ZoomIn, ZoomOut]); 51 + 52 + const APP_CONTEXT: &str = "Trawler"; 53 + 54 + fn init_keymap(cx: &mut App) { 55 + cx.bind_keys([ 56 + KeyBinding::new("alt-left", NavigateBack, Some(APP_CONTEXT)), 57 + KeyBinding::new("alt-right", NavigateForward, Some(APP_CONTEXT)), 58 + KeyBinding::new("ctrl-.", ZoomIn, Some(APP_CONTEXT)), 59 + KeyBinding::new("ctrl-,", ZoomOut, Some(APP_CONTEXT)), 60 + ]); 61 + } 62 + 63 + /// Where the app is currently looking. See the module doc for why a single 64 + /// `NodeId` variant covers pages, zoomed blocks, and tag/date backlink 65 + /// views. 66 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 67 + enum View { 68 + Journal, 69 + Node(NodeIdKey), 70 + } 71 + 72 + /// `NodeId` isn't `Copy` (it owns a `String`/`NaiveDate`), which makes it 73 + /// awkward to stash in `View`/history without cloning constantly. Since 74 + /// only tree nodes are ever navigated to as a `View` target in this MVP 75 + /// (tags/dates are reachable only via their backlinks, which key on the 76 + /// full `NodeId` at lookup time, not through `View`), a plain `TreeID` is 77 + /// enough here. 78 + type NodeIdKey = TreeID; 79 + 36 80 /// A single visible outline row: a block's id, its depth for indentation, 37 81 /// and its content parsed into renderable Markdown blocks. 38 82 struct VisibleRow { ··· 43 87 folded: bool, 44 88 } 45 89 90 + /// One entry in the backlinks panel: a referencing block, its containing 91 + /// page, and the block's rendered content. 92 + struct BacklinkRow { 93 + page: TreeID, 94 + page_name: String, 95 + block: TreeID, 96 + blocks: Vec<Block>, 97 + } 98 + 46 99 /// The one live text input, per the one-hot editing model (spec: 47 100 /// "One-hot block editing" — at most one live input at any time). 48 101 struct FocusedEditor { 49 102 block: TreeID, 50 103 input: Entity<BlockEditor>, 51 104 _event_subscription: Subscription, 105 + /// Re-measures this row whenever the editor's content changes height 106 + /// (e.g. wrapping to another line while typing) — plain keystrokes 107 + /// don't emit a `BlockEditorEvent`, so this is the only signal telling 108 + /// the list its cached height for this row is now stale. 109 + _content_observation: Subscription, 52 110 } 53 111 54 112 struct TrawlerApp { 55 113 graph_dir: PathBuf, 56 114 storage: GraphStorage, 115 + view: View, 116 + history_back: Vec<View>, 117 + history_forward: Vec<View>, 57 118 rows: Vec<VisibleRow>, 119 + /// Virtualized-list state for `self.rows`. Rows have varying heights 120 + /// (a heading or code block or multi-line paragraph needs more room 121 + /// than a plain one-liner), which is why this is `gpui::list` rather 122 + /// than `uniform_list` — `uniform_list` measures a single item and 123 + /// forces every row into that one fixed height, which silently clips 124 + /// or overflows any row that doesn't match it (see the "overflowing 125 + /// underneath subsequent siblings" bug this replaced). 126 + list_state: ListState, 127 + backlinks: Vec<BacklinkRow>, 128 + /// Ancestor chain (root-first) above the zoomed node, if `view` is a 129 + /// non-root `NodeId::Tree`. 130 + breadcrumb: Vec<(TreeID, String)>, 58 131 editor: Option<FocusedEditor>, 59 132 /// Blocks whose descendants are hidden (spec: "Fold hides descendants"). 60 133 folded: HashSet<TreeID>, 134 + /// The local date as of the last journal refresh, checked on every 135 + /// render so a rollover while the app stays open picks up a new 136 + /// today's page without needing a restart or an edit to trigger it 137 + /// (spec: journal/"Date rollover"). 138 + journal_today: NaiveDate, 61 139 /// This window's handle, stashed so event-emitter callbacks (which 62 140 /// only get `&mut Context<Self>`, no `&mut Window`) can hop back into 63 141 /// a window context via `cx.defer` + `AnyWindowHandle::update` when ··· 66 144 } 67 145 68 146 impl TrawlerApp { 69 - fn new(graph_dir: PathBuf, window: &Window) -> Self { 147 + fn new(graph_dir: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self { 70 148 let is_new = !GraphStorage::exists(&graph_dir); 71 149 let storage = if is_new { 72 150 let storage = GraphStorage::create(&graph_dir).expect("create graph directory"); ··· 76 154 GraphStorage::open(&graph_dir).expect("open graph directory") 77 155 }; 78 156 79 - let folded = HashSet::new(); 80 - let rows = visible_rows(&storage, &folded); 81 - Self { 157 + let today = chrono::Local::now().date_naive(); 158 + let today_page = ensure_today_journal_page(&storage, today); 159 + 160 + let mut app = Self { 82 161 graph_dir, 83 162 storage, 84 - rows, 163 + view: View::Journal, 164 + history_back: Vec::new(), 165 + history_forward: Vec::new(), 166 + rows: Vec::new(), 167 + list_state: ListState::new(0, ListAlignment::Top, px(600.0)), 168 + backlinks: Vec::new(), 169 + breadcrumb: Vec::new(), 85 170 editor: None, 86 - folded, 171 + folded: HashSet::new(), 172 + journal_today: today, 87 173 window_handle: window.window_handle(), 174 + }; 175 + app.refresh_view_data(); 176 + 177 + // "today's journal page is displayed with focus in its first 178 + // block" (spec: journal/"Opening the app"). 179 + let outline = Outline::new(app.storage.doc()); 180 + let first_block = outline.children(Some(today_page)).first().copied(); 181 + if let Some(block) = first_block.or(Some(today_page)) { 182 + app.focus_block(block, window, cx); 88 183 } 184 + app 89 185 } 90 186 91 187 fn refresh_rows(&mut self) { 92 - self.rows = visible_rows(&self.storage, &self.folded); 188 + self.rows = self.main_rows(); 189 + self.list_state.reset(self.rows.len()); 190 + } 191 + 192 + /// Pick up a midnight rollover while the app is left running: if the 193 + /// local date has advanced since the last check, ensure a fresh 194 + /// journal page exists and, if the journal timeline is what's on 195 + /// screen, refresh it so the new day appears without requiring an 196 + /// edit or restart (spec: journal/"Date rollover handling"). 197 + fn check_journal_rollover(&mut self, cx: &mut Context<Self>) { 198 + let today = chrono::Local::now().date_naive(); 199 + if today == self.journal_today { 200 + return; 201 + } 202 + self.journal_today = today; 203 + ensure_today_journal_page(&self.storage, today); 204 + if matches!(self.view, View::Journal) { 205 + self.refresh_view_data(); 206 + cx.notify(); 207 + } 208 + } 209 + 210 + /// Recompute rows, backlinks, and breadcrumb for the current view — 211 + /// call after any edit or navigation. 212 + fn refresh_view_data(&mut self) { 213 + self.rows = self.main_rows(); 214 + self.list_state.reset(self.rows.len()); 215 + self.backlinks = self.backlinks_for_current_view(); 216 + self.breadcrumb = self.breadcrumb_for_current_view(); 217 + } 218 + 219 + fn main_rows(&self) -> Vec<VisibleRow> { 220 + let outline = Outline::new(self.storage.doc()); 221 + let mut rows = Vec::new(); 222 + match self.view { 223 + View::Journal => { 224 + let today = chrono::Local::now().date_naive(); 225 + for (_, page) in journal_pages(&self.storage, today) { 226 + push_subtree(&outline, page, 0, &self.folded, &mut rows); 227 + } 228 + } 229 + View::Node(id) => { 230 + push_subtree(&outline, id, 0, &self.folded, &mut rows); 231 + } 232 + } 233 + rows 234 + } 235 + 236 + fn breadcrumb_for_current_view(&self) -> Vec<(TreeID, String)> { 237 + let View::Node(id) = self.view else { 238 + return Vec::new(); 239 + }; 240 + let outline = Outline::new(self.storage.doc()); 241 + let mut chain = Vec::new(); 242 + let mut current = outline.parent(id); 243 + while let Some(ancestor) = current { 244 + let name = outline.content(ancestor).unwrap_or_default(); 245 + chain.push((ancestor, name)); 246 + current = outline.parent(ancestor); 247 + } 248 + chain.reverse(); 249 + chain 250 + } 251 + 252 + /// Backlinks grouped by containing page (spec: "Backlinks are always 253 + /// visible" ... "grouped by their containing page"). 254 + fn backlinks_for_current_view(&self) -> Vec<BacklinkRow> { 255 + let View::Node(id) = self.view else { 256 + return Vec::new(); 257 + }; 258 + let index = GraphIndex::rebuild(self.storage.doc()); 259 + let outline = Outline::new(self.storage.doc()); 260 + let mut backlinks: Vec<TreeID> = 261 + index.backlinks_of(&NodeId::tree(id)).into_iter().collect(); 262 + backlinks.sort(); 263 + 264 + backlinks 265 + .into_iter() 266 + .map(|block| { 267 + let mut page = block; 268 + while let Some(parent) = outline.parent(page) { 269 + page = parent; 270 + } 271 + let page_name = outline.content(page).unwrap_or_default(); 272 + let content = outline.content(block).unwrap_or_default(); 273 + BacklinkRow { 274 + page, 275 + page_name, 276 + block, 277 + blocks: markdown::parse_block(&content), 278 + } 279 + }) 280 + .collect() 93 281 } 94 282 95 283 fn toggle_fold(&mut self, id: TreeID, cx: &mut Context<Self>) { ··· 100 288 cx.notify(); 101 289 } 102 290 291 + /// Navigate to a new view, recording history (spec: "Navigation 292 + /// history") and focusing `focus_hint` if given, else the first block 293 + /// of the new view. 294 + fn navigate_to( 295 + &mut self, 296 + view: View, 297 + focus_hint: Option<TreeID>, 298 + window: &mut Window, 299 + cx: &mut Context<Self>, 300 + ) { 301 + self.commit_editor(cx); 302 + if view != self.view { 303 + self.history_back.push(self.view); 304 + self.history_forward.clear(); 305 + } 306 + self.view = view; 307 + self.refresh_view_data(); 308 + 309 + let target = focus_hint.or_else(|| self.rows.first().map(|r| r.id)); 310 + if let Some(block) = target { 311 + self.focus_block(block, window, cx); 312 + } 313 + cx.notify(); 314 + } 315 + 316 + fn navigate_back(&mut self, _: &NavigateBack, window: &mut Window, cx: &mut Context<Self>) { 317 + let Some(view) = self.history_back.pop() else { 318 + return; 319 + }; 320 + self.commit_editor(cx); 321 + self.history_forward.push(self.view); 322 + self.view = view; 323 + self.refresh_view_data(); 324 + if let Some(block) = self.rows.first().map(|r| r.id) { 325 + self.focus_block(block, window, cx); 326 + } 327 + cx.notify(); 328 + } 329 + 330 + fn navigate_forward( 331 + &mut self, 332 + _: &NavigateForward, 333 + window: &mut Window, 334 + cx: &mut Context<Self>, 335 + ) { 336 + let Some(view) = self.history_forward.pop() else { 337 + return; 338 + }; 339 + self.commit_editor(cx); 340 + self.history_back.push(self.view); 341 + self.view = view; 342 + self.refresh_view_data(); 343 + if let Some(block) = self.rows.first().map(|r| r.id) { 344 + self.focus_block(block, window, cx); 345 + } 346 + cx.notify(); 347 + } 348 + 349 + /// Ctrl+. : zoom into the currently focused block, making it the 350 + /// temporary root with an ancestor breadcrumb (spec: "Block zoom"). 351 + /// No-op if nothing is focused. 352 + fn zoom_in(&mut self, _: &ZoomIn, window: &mut Window, cx: &mut Context<Self>) { 353 + let Some(block) = self.editor.as_ref().map(|e| e.block) else { 354 + return; 355 + }; 356 + self.navigate_to(View::Node(block), None, window, cx); 357 + } 358 + 359 + /// Ctrl+, : zoom out to the parent of the current zoomed block, or to 360 + /// the journal timeline if already at a page root (spec: "Zoom-out 361 + /// restores the wider view"). 362 + fn zoom_out(&mut self, _: &ZoomOut, window: &mut Window, cx: &mut Context<Self>) { 363 + let View::Node(id) = self.view else { 364 + return; 365 + }; 366 + let outline = Outline::new(self.storage.doc()); 367 + match outline.parent(id) { 368 + Some(parent) => self.navigate_to(View::Node(parent), Some(id), window, cx), 369 + None => self.navigate_to(View::Journal, Some(id), window, cx), 370 + } 371 + } 372 + 373 + /// Resolve a parsed reference to a graph node and navigate to it 374 + /// (spec: "Follow reference from keyboard" / clicking a reference). 375 + /// A no-op if the reference doesn't resolve to anything that exists 376 + /// (e.g. a page that's never been created). 377 + fn navigate_to_reference( 378 + &mut self, 379 + kind: ReferenceKind, 380 + window: &mut Window, 381 + cx: &mut Context<Self>, 382 + ) { 383 + let index = GraphIndex::rebuild(self.storage.doc()); 384 + let target = match kind { 385 + ReferenceKind::Page(name) => index.pages_by_name.get(&name).map(|&id| NodeId::tree(id)), 386 + ReferenceKind::Tag(name) => Some(NodeId::tag(name)), 387 + ReferenceKind::Date(date) => { 388 + let outline = Outline::new(self.storage.doc()); 389 + outline 390 + .children(None) 391 + .into_iter() 392 + .find(|&page| journal_page_date(&outline, page) == Some(date)) 393 + .map(NodeId::tree) 394 + .or(Some(NodeId::date(date))) 395 + } 396 + ReferenceKind::Block(id_str) => { 397 + TreeID::try_from(id_str.as_str()).ok().map(NodeId::tree) 398 + } 399 + }; 400 + match target { 401 + Some(NodeId::Tree(id_str)) => { 402 + if let Ok(id) = TreeID::try_from(id_str.as_str()) { 403 + self.navigate_to(View::Node(id), None, window, cx); 404 + } 405 + } 406 + _ => { 407 + // Tags/dates with no materialized page still have 408 + // backlinks worth viewing, but `View::Node` only carries a 409 + // `TreeID` (see `NodeIdKey`'s doc) — a future task can 410 + // widen it to show a pure-backlinks view for these. 411 + } 412 + } 413 + } 414 + 103 415 /// Write the currently-focused editor's content back into the graph, 104 416 /// persist it, and drop the editor — the "commit" half of one-hot 105 417 /// editing (spec: "Moving focus SHALL commit the current block's ··· 116 428 self.storage 117 429 .persist_update() 118 430 .expect("persist committed edit"); 119 - self.refresh_rows(); 431 + self.refresh_view_data(); 120 432 } 121 433 122 434 /// Commit whatever was focused, then attach a fresh multi-line editor ··· 150 462 BlockEditorEvent::CompletionQueryChanged { trigger, query } => { 151 463 this.update_completions(*trigger, query.clone(), cx); 152 464 } 465 + BlockEditorEvent::FollowReference(kind) => { 466 + let kind = kind.clone(); 467 + let entity = cx.entity(); 468 + let handle = this.window_handle; 469 + cx.defer(move |cx| { 470 + let _ = handle.update(cx, move |_, window, cx| { 471 + entity.update(cx, |this, cx| { 472 + this.navigate_to_reference(kind, window, cx); 473 + }); 474 + }); 475 + }); 476 + } 153 477 }); 154 478 479 + let content_observation = cx.observe(&input, move |this: &mut Self, _input, cx| { 480 + if let Some(ix) = this.rows.iter().position(|r| r.id == id) { 481 + this.list_state.splice(ix..ix + 1, 1); 482 + } 483 + cx.notify(); 484 + }); 485 + 155 486 let focus_handle = input.read(cx).focus_handle(cx); 156 487 window.focus(&focus_handle); 157 488 ··· 159 490 block: id, 160 491 input, 161 492 _event_subscription: event_subscription, 493 + _content_observation: content_observation, 162 494 }); 163 495 } 164 496 165 497 /// Enter was pressed (spec: "Newline within a block vs. new block" — 166 498 /// Shift+Enter is handled entirely inside `BlockEditor` and never 167 499 /// reaches here). Split the block's content at the cursor via the 168 - /// already-tested `Outline::split_block`. 500 + /// already-tested `Outline::split_block` — unless the focused block is 501 + /// a page root, where "splitting" would fragment the graph into a new 502 + /// top-level page instead of adding outline content under it. Enter on 503 + /// a page title instead moves focus into (or creates) its first child. 169 504 fn split_focused_block(&mut self, cx: &mut Context<Self>) { 170 505 let Some(editor) = self.editor.as_ref() else { 171 506 return; ··· 179 514 outline 180 515 .set_content(block, &full_text) 181 516 .expect("focused block still exists"); 182 - let new_block = outline 183 - .split_block(block, char_offset) 184 - .expect("focused block still exists"); 517 + 518 + let is_page = outline.parent(block).is_none(); 519 + let new_block = if is_page { 520 + match outline.children(Some(block)).first().copied() { 521 + Some(first_child) => first_child, 522 + None => outline 523 + .create_block(Some(block), Position::Index(0), "") 524 + .expect("create first child under page"), 525 + } 526 + } else { 527 + outline 528 + .split_block(block, char_offset) 529 + .expect("focused block still exists") 530 + }; 185 531 self.storage.persist_update().expect("persist block split"); 186 532 // The old editor's content is now stale (superseded by the split 187 533 // above); drop it without re-committing through `commit_editor`. 188 534 self.editor = None; 189 - self.refresh_rows(); 535 + self.refresh_view_data(); 190 536 191 537 // Focusing the new sibling needs a `&mut Window`, which this 192 538 // event-emitter callback doesn't have — hop back into one via the ··· 205 551 /// Backspace at the start of a block (no selection): merge it into the 206 552 /// end of its previous sibling's content and focus that sibling there 207 553 /// (spec: "delete/merge with previous"). No-op if there's no previous 208 - /// sibling or the block has children (matching `Outline::merge_block`'s 209 - /// precondition — merging a block with descendants would orphan them). 554 + /// sibling, the block has children (matching `Outline::merge_block`'s 555 + /// precondition — merging a block with descendants would orphan them), 556 + /// or the block is a page root (its "siblings" are other top-level 557 + /// pages — merging those together would silently delete a whole page 558 + /// into another page's title, not merge outline content). 210 559 /// 211 560 /// `BlockEditor` itself already confirmed the cursor was at position 0 212 561 /// with no selection before emitting `MergeWithPreviousRequested` — ··· 223 572 return; 224 573 } 225 574 let parent = outline.parent(block); 575 + if parent.is_none() { 576 + return; 577 + } 226 578 let siblings = outline.children(parent); 227 579 let Some(pos) = siblings.iter().position(|&s| s == block) else { 228 580 return; ··· 241 593 .expect("merge with previous sibling"); 242 594 self.storage.persist_update().expect("persist merge"); 243 595 self.editor = None; 244 - self.refresh_rows(); 596 + self.refresh_view_data(); 245 597 246 598 let entity = cx.entity(); 247 599 let handle = self.window_handle; ··· 275 627 .move_subtree(block, Some(prev_sibling), Position::Index(new_index)) 276 628 .expect("move under previous sibling"); 277 629 self.storage.persist_update().expect("persist indent"); 278 - self.refresh_rows(); 630 + self.refresh_view_data(); 279 631 cx.notify(); 280 632 } 281 633 ··· 294 646 .move_subtree(block, grandparent, Position::After(parent)) 295 647 .expect("move to grandparent level"); 296 648 self.storage.persist_update().expect("persist outdent"); 297 - self.refresh_rows(); 649 + self.refresh_view_data(); 298 650 cx.notify(); 299 651 } 300 652 ··· 324 676 .reorder_sibling(block, position) 325 677 .expect("reorder sibling"); 326 678 self.storage.persist_update().expect("persist reorder"); 327 - self.refresh_rows(); 679 + self.refresh_view_data(); 328 680 cx.notify(); 329 681 } 330 682 ··· 435 787 .collect() 436 788 } 437 789 790 + /// A journal page is a root page whose own content is exactly an ISO date 791 + /// (spec: journal/"Automatic daily pages" — "distinguished by a date 792 + /// attribute"; using the page's title as that attribute keeps the MVP 793 + /// simple rather than adding a dedicated typed property for it). 794 + fn journal_page_date(outline: &Outline, page: TreeID) -> Option<NaiveDate> { 795 + let content = outline.content(page).ok()?; 796 + NaiveDate::parse_from_str(content.trim(), "%Y-%m-%d").ok() 797 + } 798 + 799 + /// Find or create today's journal page, seeding it with one empty block 800 + /// ready for input (spec scenario: "First launch of the day"). 801 + fn ensure_today_journal_page(storage: &GraphStorage, today: NaiveDate) -> TreeID { 802 + let outline = Outline::new(storage.doc()); 803 + for page in outline.children(None) { 804 + if journal_page_date(&outline, page) == Some(today) { 805 + return page; 806 + } 807 + } 808 + let title = today.format("%Y-%m-%d").to_string(); 809 + let index = outline.children(None).len(); 810 + let page = outline 811 + .create_block(None, Position::Index(index), &title) 812 + .expect("create journal page"); 813 + outline 814 + .create_block(Some(page), Position::Index(0), "") 815 + .expect("create empty first block"); 816 + storage.persist_update().expect("persist new journal page"); 817 + page 818 + } 819 + 820 + /// Journal pages to show in the timeline, newest first: today's page 821 + /// always (even if still empty, so there's somewhere to type — spec: 822 + /// "First launch of the day"), earlier days only if they ever received 823 + /// content (spec: "No empty-page litter"). 824 + fn journal_pages(storage: &GraphStorage, today: NaiveDate) -> Vec<(NaiveDate, TreeID)> { 825 + let outline = Outline::new(storage.doc()); 826 + let mut pages: Vec<(NaiveDate, TreeID)> = outline 827 + .children(None) 828 + .into_iter() 829 + .filter_map(|page| { 830 + let date = journal_page_date(&outline, page)?; 831 + let has_content = outline.children(Some(page)).iter().any(|&child| { 832 + !outline.content(child).unwrap_or_default().trim().is_empty() 833 + || !outline.children(Some(child)).is_empty() 834 + }); 835 + (date == today || has_content).then_some((date, page)) 836 + }) 837 + .collect(); 838 + pages.sort_by(|a, b| b.0.cmp(&a.0)); 839 + pages 840 + } 841 + 438 842 /// First-run content so the outline view has something real to render and 439 843 /// verify against, rather than starting on a blank page. 440 844 fn seed_welcome_page(storage: &GraphStorage) { ··· 487 891 /// descendants of any block in `folded` (spec: "Fold hides descendants" — 488 892 /// "keyboard navigation skips the hidden blocks" falls out naturally since 489 893 /// folded descendants are never turned into rows at all). 490 - fn visible_rows(storage: &GraphStorage, folded: &HashSet<TreeID>) -> Vec<VisibleRow> { 491 - let outline = Outline::new(storage.doc()); 492 - let index = GraphIndex::rebuild(storage.doc()); 493 - 494 - let mut rows = Vec::new(); 495 - for &page in &index.roots { 496 - push_subtree(&outline, page, 0, folded, &mut rows); 497 - } 498 - rows 499 - } 500 - 501 894 fn push_subtree( 502 895 outline: &Outline, 503 896 id: TreeID, ··· 527 920 const REFERENCE_COLOR: u32 = 0x6ab0f3; 528 921 const CODE_BG: u32 = 0x2a2a2a; 529 922 const MUTED_COLOR: u32 = 0x9a9a9a; 923 + /// Minimum row content height, so an empty block (whose Markdown view has 924 + /// zero rendered children) still occupies real, clickable space instead of 925 + /// collapsing to nothing. 926 + const ROW_MIN_HEIGHT: f32 = 22.0; 530 927 531 - fn render_inline(inline: &Inline) -> gpui::AnyElement { 928 + /// Renders a single inline run. References/links get a click handler that 929 + /// navigates via `TrawlerApp::navigate_to_reference` — `key` must be 930 + /// unique among all inline elements rendered in the same frame (callers 931 + /// pass a per-block counter). 932 + fn render_inline(inline: &Inline, key: usize, cx: &mut Context<TrawlerApp>) -> gpui::AnyElement { 532 933 match inline { 533 934 Inline::Run { 534 935 text, ··· 548 949 } 549 950 el.into_any_element() 550 951 } 551 - Inline::Reference { display, .. } => div() 552 - .text_color(rgb(REFERENCE_COLOR)) 553 - .child(format!("[[{display}]]")) 554 - .into_any_element(), 952 + Inline::Reference { display, kind } => { 953 + let kind = kind.clone(); 954 + div() 955 + .id(("reference", key)) 956 + .cursor_pointer() 957 + .text_color(rgb(REFERENCE_COLOR)) 958 + .child(format!("[[{display}]]")) 959 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 960 + this.navigate_to_reference(kind.clone(), window, cx); 961 + })) 962 + .into_any_element() 963 + } 555 964 Inline::Link { text, .. } => div() 556 965 .text_color(rgb(REFERENCE_COLOR)) 557 966 .child(text.clone()) ··· 559 968 } 560 969 } 561 970 562 - fn render_inlines(inlines: &[Inline]) -> gpui::AnyElement { 971 + fn render_inlines( 972 + inlines: &[Inline], 973 + key_base: usize, 974 + cx: &mut Context<TrawlerApp>, 975 + ) -> gpui::AnyElement { 563 976 div() 564 977 .flex() 565 978 .flex_row() 566 979 .flex_wrap() 567 - .children(inlines.iter().map(render_inline)) 980 + .children( 981 + inlines 982 + .iter() 983 + .enumerate() 984 + .map(|(ix, inline)| render_inline(inline, key_base * 64 + ix, cx)), 985 + ) 568 986 .into_any_element() 569 987 } 570 988 571 - fn render_block(block: &Block) -> gpui::AnyElement { 989 + fn render_block(block: &Block, key_base: usize, cx: &mut Context<TrawlerApp>) -> gpui::AnyElement { 572 990 match block { 573 - Block::Paragraph(inlines) => render_inlines(inlines), 991 + Block::Paragraph(inlines) => render_inlines(inlines, key_base, cx), 574 992 Block::Heading { level, inlines } => div() 575 993 .font_weight(FontWeight::BOLD) 576 994 .text_size(px(24.0 - 2.0 * (*level as f32))) 577 - .child(render_inlines(inlines)) 995 + .child(render_inlines(inlines, key_base, cx)) 578 996 .into_any_element(), 579 997 Block::Code { code, .. } => div() 580 998 .font_family("monospace") ··· 588 1006 .border_color(rgb(MUTED_COLOR)) 589 1007 .pl_2() 590 1008 .text_color(rgb(MUTED_COLOR)) 591 - .child(render_inlines(inlines)) 1009 + .child(render_inlines(inlines, key_base, cx)) 592 1010 .into_any_element(), 593 1011 Block::ListItem { ordered, inlines } => div() 594 1012 .flex() 595 1013 .flex_row() 596 1014 .gap_1() 597 1015 .child(if *ordered { "1." } else { "•" }) 598 - .child(render_inlines(inlines)) 1016 + .child(render_inlines(inlines, key_base, cx)) 599 1017 .into_any_element(), 600 1018 } 601 1019 } 602 1020 1021 + fn render_blocks( 1022 + blocks: &[Block], 1023 + row_key: usize, 1024 + cx: &mut Context<TrawlerApp>, 1025 + ) -> Vec<gpui::AnyElement> { 1026 + blocks 1027 + .iter() 1028 + .enumerate() 1029 + .map(|(ix, block)| render_block(block, row_key * 1024 + ix, cx)) 1030 + .collect() 1031 + } 1032 + 603 1033 /// Row data extracted for the list-item closure: everything it needs to 604 1034 /// render either a Markdown view or (for the one focused row) the live 605 1035 /// editor, without borrowing `self` — `cx.processor`'s closure is ··· 611 1041 612 1042 impl Render for TrawlerApp { 613 1043 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 1044 + self.check_journal_rollover(cx); 614 1045 let row_count = self.rows.len(); 615 1046 let focused_block = self.editor.as_ref().map(|e| e.block); 616 1047 let editor_input = self.editor.as_ref().map(|e| e.input.clone()); ··· 627 1058 }) 628 1059 .collect(); 629 1060 1061 + let title = match self.view { 1062 + View::Journal => "Journal".to_string(), 1063 + View::Node(_) => "Page".to_string(), 1064 + }; 1065 + 1066 + let breadcrumb = self.breadcrumb.clone(); 1067 + let breadcrumb_bar = (!breadcrumb.is_empty()).then(|| { 1068 + let crumbs = breadcrumb.iter().enumerate().flat_map(|(ix, (id, name))| { 1069 + let id = *id; 1070 + let crumb = div() 1071 + .id(("breadcrumb", ix)) 1072 + .cursor_pointer() 1073 + .child(name.clone()) 1074 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1075 + this.navigate_to(View::Node(id), None, window, cx); 1076 + })); 1077 + [ 1078 + crumb.into_any_element(), 1079 + div().child("/").into_any_element(), 1080 + ] 1081 + }); 1082 + div() 1083 + .flex() 1084 + .flex_row() 1085 + .gap_1() 1086 + .px_2() 1087 + .py_1() 1088 + .text_color(rgb(MUTED_COLOR)) 1089 + .children(crumbs) 1090 + }); 1091 + 1092 + let backlinks_panel = (!self.backlinks.is_empty()).then(|| { 1093 + let entries = self.backlinks.iter().enumerate().map(|(ix, entry)| { 1094 + let block = entry.block; 1095 + let page = entry.page; 1096 + div() 1097 + .id(("backlink", ix)) 1098 + .cursor_pointer() 1099 + .px_2() 1100 + .py_1() 1101 + .flex() 1102 + .flex_col() 1103 + .gap_1() 1104 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1105 + this.navigate_to(View::Node(page), Some(block), window, cx); 1106 + })) 1107 + .child( 1108 + div() 1109 + .text_color(rgb(MUTED_COLOR)) 1110 + .text_size(px(12.0)) 1111 + .child(entry.page_name.clone()), 1112 + ) 1113 + .children(render_blocks(&entry.blocks, 100_000 + ix, cx)) 1114 + }); 1115 + div() 1116 + .flex() 1117 + .flex_col() 1118 + .border_t_1() 1119 + .border_color(rgb(0x3a3a3a)) 1120 + .child( 1121 + div() 1122 + .px_2() 1123 + .py_1() 1124 + .font_weight(FontWeight::BOLD) 1125 + .child("Backlinks"), 1126 + ) 1127 + .children(entries) 1128 + }); 1129 + 630 1130 div() 631 1131 .size_full() 632 1132 .flex() 633 1133 .flex_col() 1134 + .key_context(APP_CONTEXT) 1135 + .on_action(cx.listener(Self::navigate_back)) 1136 + .on_action(cx.listener(Self::navigate_forward)) 1137 + .on_action(cx.listener(Self::zoom_in)) 1138 + .on_action(cx.listener(Self::zoom_out)) 634 1139 .bg(rgb(0x1e1e1e)) 635 1140 .text_color(rgb(0xffffff)) 636 1141 .child( ··· 639 1144 .border_b_1() 640 1145 .border_color(rgb(0x3a3a3a)) 641 1146 .child(format!( 642 - "trawler — {} ({} blocks)", 1147 + "trawler — {title} — {} ({} blocks)", 643 1148 self.graph_dir.display(), 644 1149 row_count 645 1150 )), 646 1151 ) 1152 + .children(breadcrumb_bar) 647 1153 .child( 648 1154 div().flex_1().child( 649 - uniform_list( 650 - "outline-rows", 651 - row_count, 652 - cx.processor(move |_this, range: Range<usize>, _window, cx| { 653 - range 654 - .map(|ix| { 655 - let (id, depth, has_children, folded, content) = &rows[ix]; 656 - let id = *id; 657 - let row = div() 658 - .id(ix) 659 - .w_full() 660 - .pl(px(16.0 * *depth as f32 + 8.0)) 661 - .pr_2() 662 - .py_1() 1155 + list( 1156 + self.list_state.clone(), 1157 + cx.processor(move |_this, ix: usize, _window, cx| { 1158 + let (id, depth, has_children, folded, content) = &rows[ix]; 1159 + let id = *id; 1160 + // `items_start` (not `items_center`) so the 1161 + // fold arrow/bullet line up with the first line 1162 + // of the content instead of the vertical center 1163 + // of the whole row — matters once a row has 1164 + // more than one line (a wrapped/multi-paragraph 1165 + // block, a code fence, a heading). 1166 + let row = div() 1167 + .id(ix) 1168 + .w_full() 1169 + .pl(px(16.0 * *depth as f32 + 8.0)) 1170 + .pr_2() 1171 + .py_1() 1172 + .flex() 1173 + .flex_row() 1174 + .items_start() 1175 + .gap_1(); 1176 + let fold_indicator: gpui::AnyElement = if *has_children { 1177 + div() 1178 + .id(("fold", ix)) 1179 + .cursor_pointer() 1180 + .w(px(14.0)) 1181 + .text_color(rgb(MUTED_COLOR)) 1182 + .on_click(cx.listener( 1183 + move |this, _event: &ClickEvent, _window, cx| { 1184 + this.toggle_fold(id, cx); 1185 + }, 1186 + )) 1187 + .child(if *folded { "▶" } else { "▼" }) 1188 + .into_any_element() 1189 + } else { 1190 + div().w(px(14.0)).into_any_element() 1191 + }; 1192 + // A persistent bullet per row (not just the 1193 + // fold arrow, which only appears when a block 1194 + // has children) so every block — including an 1195 + // empty leaf, whose Markdown view otherwise 1196 + // renders zero children — stays visible. 1197 + let bullet = div() 1198 + .w(px(14.0)) 1199 + .flex() 1200 + .justify_center() 1201 + .text_color(rgb(MUTED_COLOR)) 1202 + .child("•") 1203 + .into_any_element(); 1204 + let row = row.child(fold_indicator).child(bullet); 1205 + let row = match content { 1206 + RowContent::Editor(input) => row.child( 1207 + div() 1208 + .flex_1() 1209 + .min_h(px(ROW_MIN_HEIGHT)) 1210 + .child(input.clone()), 1211 + ), 1212 + RowContent::Markdown(blocks) => row.child( 1213 + div() 1214 + .id(("content", ix)) 1215 + .flex_1() 1216 + .min_h(px(ROW_MIN_HEIGHT)) 663 1217 .flex() 664 - .flex_row() 665 - .items_center() 666 - .gap_1(); 667 - let fold_indicator: gpui::AnyElement = if *has_children { 668 - div() 669 - .id(("fold", ix)) 670 - .cursor_pointer() 671 - .w(px(14.0)) 672 - .text_color(rgb(MUTED_COLOR)) 673 - .on_click(cx.listener( 674 - move |this, _event: &ClickEvent, _window, cx| { 675 - this.toggle_fold(id, cx); 676 - }, 677 - )) 678 - .child(if *folded { "▶" } else { "▼" }) 679 - .into_any_element() 680 - } else { 681 - div().w(px(14.0)).into_any_element() 682 - }; 683 - let row = row.child(fold_indicator); 684 - match content { 685 - RowContent::Editor(input) => { 686 - row.child(div().flex_1().child(input.clone())) 687 - } 688 - RowContent::Markdown(blocks) => row.child( 689 - div() 690 - .id(("content", ix)) 691 - .flex_1() 692 - .flex() 693 - .flex_col() 694 - .gap_1() 695 - .cursor_pointer() 696 - .on_click(cx.listener( 697 - move |this, _event: &ClickEvent, window, cx| { 698 - this.focus_block(id, window, cx); 699 - }, 700 - )) 701 - .children(blocks.iter().map(render_block)), 702 - ), 703 - } 704 - }) 705 - .collect::<Vec<_>>() 1218 + .flex_col() 1219 + .justify_start() 1220 + .gap_1() 1221 + .cursor_pointer() 1222 + .on_click(cx.listener( 1223 + move |this, _event: &ClickEvent, window, cx| { 1224 + this.focus_block(id, window, cx); 1225 + }, 1226 + )) 1227 + .children(render_blocks(blocks, ix, cx)), 1228 + ), 1229 + }; 1230 + row.into_any_element() 706 1231 }), 707 1232 ) 708 1233 .h_full(), 709 1234 ), 710 1235 ) 1236 + .children(backlinks_panel) 711 1237 } 712 1238 } 713 1239 ··· 725 1251 let graph_dir = default_graph_dir(); 726 1252 Application::new().run(move |cx: &mut App| { 727 1253 editor::init(cx); 1254 + init_keymap(cx); 728 1255 let bounds = Bounds::centered(None, size(px(900.0), px(600.0)), cx); 729 1256 cx.open_window( 730 1257 WindowOptions { 731 1258 window_bounds: Some(WindowBounds::Windowed(bounds)), 732 1259 ..Default::default() 733 1260 }, 734 - move |window, cx| cx.new(|_| TrawlerApp::new(graph_dir.clone(), window)), 1261 + move |window, cx| cx.new(|cx| TrawlerApp::new(graph_dir.clone(), window, cx)), 735 1262 ) 736 1263 .unwrap(); 737 1264 cx.activate(true);
+5 -5
openspec/changes/trawler-mvp/tasks.md
··· 46 46 47 47 ## 6. Journal & Navigation (trawler) 48 48 49 - - [ ] 6.1 Journal timeline home view: today's page auto-created and focused, prior non-empty days lazy-load below 50 - - [ ] 6.2 Date rollover handling while app is running; empty-page suppression 51 - - [ ] 6.3 Follow-reference keyboard action; navigation history with back/forward restoring scroll+focus 52 - - [ ] 6.4 Backlinks section on every page/zoom view, grouped by source page, navigable 53 - - [ ] 6.5 Block zoom with ancestor breadcrumb; zoom-out 49 + - [x] 6.1 Journal timeline home view: today's page auto-created and focused, prior non-empty days lazy-load below 50 + - [x] 6.2 Date rollover handling while app is running; empty-page suppression 51 + - [x] 6.3 Follow-reference keyboard action; navigation history with back/forward restoring scroll+focus 52 + - [x] 6.4 Backlinks section on every page/zoom view, grouped by source page, navigable 53 + - [x] 6.5 Block zoom with ancestor breadcrumb; zoom-out 54 54 - [ ] 6.6 Quick open: fuzzy switcher over pages/tags/dates 55 55 - [ ] 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)