Personal outliner built with Rust.
3

Configure Feed

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

phase 6.6: quick open + focus/identity fixes

- Ctrl+K fuzzy switcher over pages, tags, and journal-date shortcuts
- widen View::Node from TreeID to NodeId so tags/dates are navigable like pages (fixes following an uncreated [[Page]] or #tag reference being a dead end)
- resolve_date() unifies 'today'-style shortcuts with the actual journal page so they never diverge into two nodes
- root_focus fallback so app-level shortcuts (Ctrl+K, zoom, back/forward) still dispatch on a Tag/Date view with no editable row focused
- '+ Create page' / Ctrl+Enter materializes a real page from an uncreated tag/page/date view
- dedupe quick-open results by target node; drop the '#' prefix for NodeId::Tag display since it may equally be an uncreated page

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

+397 -69
+4 -2
crates/trawler-core/src/index.rs
··· 285 285 /// fall back to `NodeId::Tag(name)` otherwise so referencing an 286 286 /// as-yet-uncreated page still produces an immediately visible, 287 287 /// navigable backlink target (spec scenario: "Backlink appears 288 - /// immediately"). 289 - fn resolve_reference(&self, kind: ReferenceKind) -> NodeId { 288 + /// immediately"). Public so callers resolving a reference to navigate 289 + /// to it (rather than to index it) share this exact rule instead of 290 + /// re-implementing it. 291 + pub fn resolve_reference(&self, kind: ReferenceKind) -> NodeId { 290 292 match kind { 291 293 ReferenceKind::Page(name) => match self.pages_by_name.get(&name) { 292 294 Some(&id) => NodeId::tree(id),
+392 -66
crates/trawler/src/main.rs
··· 34 34 use chrono::NaiveDate; 35 35 use editor::{BlockEditor, BlockEditorEvent, CompletionCandidate}; 36 36 use gpui::{ 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, 37 + actions, deferred, div, list, point, px, rgb, size, App, AppContext as _, Application, Bounds, 38 + ClickEvent, Context, Entity, FocusHandle, Focusable as _, FontWeight, InteractiveElement as _, 39 + IntoElement, KeyBinding, KeyDownEvent, ListAlignment, ListState, MouseButton, ParentElement, 40 + Render, StatefulInteractiveElement as _, Styled, Subscription, Window, WindowBounds, 41 + WindowOptions, 41 42 }; 42 43 use loro::TreeID; 43 44 use markdown::{Block, Inline}; ··· 47 48 use trawler_core::refs::ReferenceKind; 48 49 use trawler_core::storage::GraphStorage; 49 50 50 - actions!(trawler, [NavigateBack, NavigateForward, ZoomIn, ZoomOut]); 51 + actions!( 52 + trawler, 53 + [ 54 + NavigateBack, 55 + NavigateForward, 56 + ZoomIn, 57 + ZoomOut, 58 + QuickOpen, 59 + CreatePage 60 + ] 61 + ); 51 62 52 63 const APP_CONTEXT: &str = "Trawler"; 53 64 ··· 57 68 KeyBinding::new("alt-right", NavigateForward, Some(APP_CONTEXT)), 58 69 KeyBinding::new("ctrl-.", ZoomIn, Some(APP_CONTEXT)), 59 70 KeyBinding::new("ctrl-,", ZoomOut, Some(APP_CONTEXT)), 71 + KeyBinding::new("ctrl-k", QuickOpen, Some(APP_CONTEXT)), 72 + // Deliberately not bound to plain "enter": the quick-open overlay 73 + // handles "enter" itself via a raw `on_key_down` listener (see 74 + // `quick_open_key_down`), and it isn't scoped to its own key 75 + // context, so an "enter" action bound here could race with it. 76 + // Reachable via the "+ Create page" click affordance instead. 77 + KeyBinding::new("ctrl-enter", CreatePage, Some(APP_CONTEXT)), 60 78 ]); 61 79 } 62 80 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)] 81 + /// Where the app is currently looking. A single `NodeId` variant covers 82 + /// pages, zoomed blocks, tags, and dates: a `NodeId::Tree` that's a root 83 + /// renders as a full page, a non-root as a zoomed block with an ancestor 84 + /// breadcrumb; a `Tag`/`Date` has no outline content of its own (no 85 + /// `TreeID` backs it) and renders as just a header plus its backlinks. 86 + #[derive(Debug, Clone, PartialEq, Eq)] 67 87 enum View { 68 88 Journal, 69 - Node(NodeIdKey), 89 + Node(NodeId), 70 90 } 71 91 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 - 80 92 /// A single visible outline row: a block's id, its depth for indentation, 81 93 /// and its content parsed into renderable Markdown blocks. 82 94 struct VisibleRow { ··· 109 121 _content_observation: Subscription, 110 122 } 111 123 124 + /// State for the Ctrl+K fuzzy switcher (spec: "Quick open" — fuzzy switcher 125 + /// over page/tag names including journal dates). 126 + struct QuickOpenState { 127 + query: String, 128 + results: Vec<(NodeId, String)>, 129 + selected: usize, 130 + } 131 + 112 132 struct TrawlerApp { 113 133 graph_dir: PathBuf, 114 134 storage: GraphStorage, ··· 131 151 editor: Option<FocusedEditor>, 132 152 /// Blocks whose descendants are hidden (spec: "Fold hides descendants"). 133 153 folded: HashSet<TreeID>, 154 + quick_open: Option<QuickOpenState>, 155 + /// Dedicated focus target for the quick-open query field. Stealing 156 + /// window focus onto this (rather than trying to intercept keys while 157 + /// a `BlockEditor` stays focused) is what makes plain typed characters 158 + /// arrive at `quick_open_key_down` instead of being swallowed by the 159 + /// editor's own text-input handling; it also means opening quick open 160 + /// triggers the editor's normal on-blur commit, for free. 161 + quick_open_focus: FocusHandle, 162 + /// Fallback focus target for the root container. GPUI only dispatches 163 + /// actions (Ctrl+K, zoom, back/forward, ...) along the currently 164 + /// focused element's context chain — with nothing focused at all, they 165 + /// silently don't fire. A Tag/Date view has no outline row to focus 166 + /// (see `main_rows`), so `navigate_to` grabs this instead of leaving 167 + /// the window with no focused element and every app-level shortcut 168 + /// dead. 169 + root_focus: FocusHandle, 134 170 /// The local date as of the last journal refresh, checked on every 135 171 /// render so a rollover while the app stays open picks up a new 136 172 /// today's page without needing a restart or an edit to trigger it ··· 169 205 breadcrumb: Vec::new(), 170 206 editor: None, 171 207 folded: HashSet::new(), 208 + quick_open: None, 209 + quick_open_focus: cx.focus_handle(), 210 + root_focus: cx.focus_handle(), 172 211 journal_today: today, 173 212 window_handle: window.window_handle(), 174 213 }; ··· 219 258 fn main_rows(&self) -> Vec<VisibleRow> { 220 259 let outline = Outline::new(self.storage.doc()); 221 260 let mut rows = Vec::new(); 222 - match self.view { 261 + match &self.view { 223 262 View::Journal => { 224 263 let today = chrono::Local::now().date_naive(); 225 264 for (_, page) in journal_pages(&self.storage, today) { ··· 227 266 } 228 267 } 229 268 View::Node(id) => { 230 - push_subtree(&outline, id, 0, &self.folded, &mut rows); 269 + // Tag/Date nodes have no `TreeID` and thus no outline 270 + // content of their own — they render as backlinks only. 271 + if let Some(id) = id.as_tree_id() { 272 + push_subtree(&outline, id, 0, &self.folded, &mut rows); 273 + } 231 274 } 232 275 } 233 276 rows 234 277 } 235 278 236 279 fn breadcrumb_for_current_view(&self) -> Vec<(TreeID, String)> { 237 - let View::Node(id) = self.view else { 280 + let View::Node(id) = &self.view else { 281 + return Vec::new(); 282 + }; 283 + let Some(id) = id.as_tree_id() else { 238 284 return Vec::new(); 239 285 }; 240 286 let outline = Outline::new(self.storage.doc()); ··· 252 298 /// Backlinks grouped by containing page (spec: "Backlinks are always 253 299 /// visible" ... "grouped by their containing page"). 254 300 fn backlinks_for_current_view(&self) -> Vec<BacklinkRow> { 255 - let View::Node(id) = self.view else { 301 + let View::Node(id) = &self.view else { 256 302 return Vec::new(); 257 303 }; 258 304 let index = GraphIndex::rebuild(self.storage.doc()); 259 305 let outline = Outline::new(self.storage.doc()); 260 - let mut backlinks: Vec<TreeID> = 261 - index.backlinks_of(&NodeId::tree(id)).into_iter().collect(); 306 + let mut backlinks: Vec<TreeID> = index.backlinks_of(id).into_iter().collect(); 262 307 backlinks.sort(); 263 308 264 309 backlinks ··· 300 345 ) { 301 346 self.commit_editor(cx); 302 347 if view != self.view { 303 - self.history_back.push(self.view); 348 + self.history_back.push(self.view.clone()); 304 349 self.history_forward.clear(); 305 350 } 306 351 self.view = view; 307 352 self.refresh_view_data(); 308 353 309 354 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); 355 + match target { 356 + Some(block) => self.focus_block(block, window, cx), 357 + None => window.focus(&self.root_focus), 312 358 } 313 359 cx.notify(); 314 360 } ··· 318 364 return; 319 365 }; 320 366 self.commit_editor(cx); 321 - self.history_forward.push(self.view); 367 + self.history_forward.push(self.view.clone()); 322 368 self.view = view; 323 369 self.refresh_view_data(); 324 370 if let Some(block) = self.rows.first().map(|r| r.id) { 325 371 self.focus_block(block, window, cx); 372 + } else { 373 + window.focus(&self.root_focus); 326 374 } 327 375 cx.notify(); 328 376 } ··· 337 385 return; 338 386 }; 339 387 self.commit_editor(cx); 340 - self.history_back.push(self.view); 388 + self.history_back.push(self.view.clone()); 341 389 self.view = view; 342 390 self.refresh_view_data(); 343 391 if let Some(block) = self.rows.first().map(|r| r.id) { 344 392 self.focus_block(block, window, cx); 393 + } else { 394 + window.focus(&self.root_focus); 345 395 } 346 396 cx.notify(); 347 397 } ··· 353 403 let Some(block) = self.editor.as_ref().map(|e| e.block) else { 354 404 return; 355 405 }; 356 - self.navigate_to(View::Node(block), None, window, cx); 406 + self.navigate_to(View::Node(NodeId::tree(block)), None, window, cx); 357 407 } 358 408 359 409 /// Ctrl+, : zoom out to the parent of the current zoomed block, or to 360 410 /// the journal timeline if already at a page root (spec: "Zoom-out 361 - /// restores the wider view"). 411 + /// restores the wider view"). A no-op from a Tag/Date view, which has 412 + /// no tree ancestor to zoom out to. 362 413 fn zoom_out(&mut self, _: &ZoomOut, window: &mut Window, cx: &mut Context<Self>) { 363 - let View::Node(id) = self.view else { 414 + let View::Node(id) = &self.view else { 415 + return; 416 + }; 417 + let Some(id) = id.as_tree_id() else { 364 418 return; 365 419 }; 366 420 let outline = Outline::new(self.storage.doc()); 367 421 match outline.parent(id) { 368 - Some(parent) => self.navigate_to(View::Node(parent), Some(id), window, cx), 422 + Some(parent) => { 423 + self.navigate_to(View::Node(NodeId::tree(parent)), Some(id), window, cx) 424 + } 369 425 None => self.navigate_to(View::Journal, Some(id), window, cx), 370 426 } 371 427 } 372 428 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( 429 + /// Ctrl+K: open (or close, if already open) the fuzzy switcher over 430 + /// pages, tags, and journal-date shortcuts. 431 + fn toggle_quick_open(&mut self, _: &QuickOpen, window: &mut Window, cx: &mut Context<Self>) { 432 + if self.quick_open.is_some() { 433 + self.quick_open = None; 434 + cx.notify(); 435 + return; 436 + } 437 + let index = GraphIndex::rebuild(self.storage.doc()); 438 + let results = self.quick_open_candidates(&index, ""); 439 + self.quick_open = Some(QuickOpenState { 440 + query: String::new(), 441 + results, 442 + selected: 0, 443 + }); 444 + // Stealing focus here blurs whatever `BlockEditor` was focused, 445 + // which triggers its normal on-blur commit — the same as clicking 446 + // away to a different block. 447 + window.focus(&self.quick_open_focus); 448 + cx.notify(); 449 + } 450 + 451 + fn quick_open_key_down( 378 452 &mut self, 379 - kind: ReferenceKind, 453 + event: &KeyDownEvent, 380 454 window: &mut Window, 381 455 cx: &mut Context<Self>, 382 456 ) { 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))) 457 + let Some(state) = self.quick_open.as_mut() else { 458 + return; 459 + }; 460 + match event.keystroke.key.as_str() { 461 + "escape" => { 462 + self.quick_open = None; 463 + cx.notify(); 464 + return; 395 465 } 396 - ReferenceKind::Block(id_str) => { 397 - TreeID::try_from(id_str.as_str()).ok().map(NodeId::tree) 466 + "enter" => { 467 + if let Some((target, _)) = state.results.get(state.selected).cloned() { 468 + self.quick_open = None; 469 + self.navigate_to(View::Node(target), None, window, cx); 470 + } 471 + return; 472 + } 473 + "up" => { 474 + if !state.results.is_empty() { 475 + state.selected = state 476 + .selected 477 + .checked_sub(1) 478 + .unwrap_or(state.results.len() - 1); 479 + } 480 + cx.notify(); 481 + return; 398 482 } 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); 483 + "down" => { 484 + if !state.results.is_empty() { 485 + state.selected = (state.selected + 1) % state.results.len(); 404 486 } 487 + cx.notify(); 488 + return; 489 + } 490 + "backspace" => { 491 + state.query.pop(); 405 492 } 406 493 _ => { 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. 494 + let modifiers = event.keystroke.modifiers; 495 + if modifiers.control || modifiers.alt || modifiers.platform { 496 + return; 497 + } 498 + let Some(text) = event.keystroke.key_char.clone() else { 499 + return; 500 + }; 501 + state.query.push_str(&text); 411 502 } 412 503 } 504 + let index = GraphIndex::rebuild(self.storage.doc()); 505 + let query_lower = self.quick_open.as_ref().unwrap().query.to_lowercase(); 506 + let results = self.quick_open_candidates(&index, &query_lower); 507 + let state = self.quick_open.as_mut().unwrap(); 508 + state.results = results; 509 + state.selected = 0; 510 + cx.notify(); 511 + } 512 + 513 + /// Resolve a date to the node it should actually navigate to: that 514 + /// day's journal page if one already exists (real editable content), 515 + /// falling back to the bare backlinks-only `NodeId::Date` otherwise. 516 + /// A journal page and its date are the same conceptual node — anywhere 517 + /// a date is offered as a navigation target (a `[[date]]` reference, 518 + /// the quick-open "Today" shortcut, ...) must apply this same rule, or 519 + /// two different views end up representing "today". 520 + fn resolve_date(&self, date: NaiveDate) -> NodeId { 521 + let outline = Outline::new(self.storage.doc()); 522 + outline 523 + .children(None) 524 + .into_iter() 525 + .find(|&page| journal_page_date(&outline, page) == Some(date)) 526 + .map(NodeId::tree) 527 + .unwrap_or(NodeId::date(date)) 528 + } 529 + 530 + /// Turn the current Tag/Date view into a real, editable page: a 531 + /// `[[Some New Page]]` reference or a `#tag` with nothing behind it 532 + /// yet resolves to a bare `NodeId::Tag`/`NodeId::Date` (see 533 + /// `GraphIndex::resolve_reference`'s MVP-simplification note) — which 534 + /// has no `TreeID` and therefore no outline row to click into, leaving 535 + /// no way to actually add content there. This creates the missing 536 + /// page (titled after the tag/date) with one empty first block and 537 + /// navigates to it. A no-op from any other view. 538 + fn create_page_for_view( 539 + &mut self, 540 + _: &CreatePage, 541 + window: &mut Window, 542 + cx: &mut Context<Self>, 543 + ) { 544 + let View::Node(id) = &self.view else { 545 + return; 546 + }; 547 + let page = match id.clone() { 548 + NodeId::Tag(name) => { 549 + let outline = Outline::new(self.storage.doc()); 550 + let index = outline.children(None).len(); 551 + let page = outline 552 + .create_block(None, Position::Index(index), &name) 553 + .expect("create page for tag"); 554 + outline 555 + .create_block(Some(page), Position::Index(0), "") 556 + .expect("create empty first block"); 557 + self.storage.persist_update().expect("persist new page"); 558 + page 559 + } 560 + NodeId::Date(date) => ensure_today_journal_page(&self.storage, date), 561 + NodeId::Tree(_) => return, 562 + }; 563 + self.navigate_to(View::Node(NodeId::tree(page)), None, window, cx); 564 + } 565 + 566 + /// Candidates for the Ctrl+K quick switcher: every page and tag, plus 567 + /// today/tomorrow/yesterday as journal-date shortcuts (spec: "Quick 568 + /// open" — "fuzzy switcher over pages/tags/dates"). Date shortcuts go 569 + /// through `resolve_date` so picking "Today" lands on the same node a 570 + /// `[[2026-07-10]]` reference or the journal timeline itself would. 571 + fn quick_open_candidates( 572 + &self, 573 + index: &GraphIndex, 574 + query_lower: &str, 575 + ) -> Vec<(NodeId, String)> { 576 + // Date shortcuts go first: "Today" and the journal page it 577 + // resolves to (via `resolve_date`) are the same node, so when a 578 + // page's title happens to be today's date, listing the plain page 579 + // name after the shortcut and de-duping by target keeps only the 580 + // more descriptive "Today (2026-07-04)" label instead of showing 581 + // both. 582 + let mut ordered: Vec<(NodeId, String)> = Vec::new(); 583 + 584 + let today = chrono::Local::now().date_naive(); 585 + for (label, date) in [ 586 + ("Today", today), 587 + ("Tomorrow", today + chrono::Duration::days(1)), 588 + ("Yesterday", today - chrono::Duration::days(1)), 589 + ] { 590 + let display = format!("{label} ({date})"); 591 + if fuzzy_contains(&display.to_lowercase(), query_lower) { 592 + ordered.push((self.resolve_date(date), display)); 593 + } 594 + } 595 + 596 + ordered.extend( 597 + index 598 + .pages_by_name 599 + .iter() 600 + .filter(|(name, _)| fuzzy_contains(&name.to_lowercase(), query_lower)) 601 + .map(|(name, &id)| (NodeId::tree(id), name.clone())), 602 + ); 603 + ordered.extend( 604 + index 605 + .tags 606 + .keys() 607 + .filter(|name| fuzzy_contains(&name.to_lowercase(), query_lower)) 608 + // No `#` prefix here: this node may equally be an 609 + // uncreated `[[Page]]` reference (see `resolve_reference`'s 610 + // MVP-simplification note — the two are indistinguishable 611 + // once collapsed to `NodeId::Tag`), and since we don't 612 + // differentiate them, presenting it as a page name reads 613 + // right either way; presenting an uncreated page as a 614 + // hashtag would not. 615 + .map(|name| (NodeId::tag(name.clone()), name.clone())), 616 + ); 617 + 618 + let mut seen = HashSet::new(); 619 + let mut results: Vec<(NodeId, String)> = ordered 620 + .into_iter() 621 + .filter(|(id, _)| seen.insert(id.clone())) 622 + .collect(); 623 + results.sort_by(|a, b| a.1.cmp(&b.1)); 624 + results.truncate(20); 625 + results 626 + } 627 + 628 + /// Resolve a parsed reference to a graph node and navigate to it 629 + /// (spec: "Follow reference from keyboard" / clicking a reference). 630 + /// Tags and dates always resolve to *some* view (their backlinks, even 631 + /// if empty) rather than being a no-op, matching "a tag is navigable 632 + /// like a page". 633 + fn navigate_to_reference( 634 + &mut self, 635 + kind: ReferenceKind, 636 + window: &mut Window, 637 + cx: &mut Context<Self>, 638 + ) { 639 + let target = if let ReferenceKind::Date(date) = kind { 640 + self.resolve_date(date) 641 + } else { 642 + let index = GraphIndex::rebuild(self.storage.doc()); 643 + index.resolve_reference(kind) 644 + }; 645 + self.navigate_to(View::Node(target), None, window, cx); 413 646 } 414 647 415 648 /// Write the currently-focused editor's content back into the graph, ··· 1058 1291 }) 1059 1292 .collect(); 1060 1293 1061 - let title = match self.view { 1294 + let title = match &self.view { 1062 1295 View::Journal => "Journal".to_string(), 1063 - View::Node(_) => "Page".to_string(), 1296 + View::Node(NodeId::Tree(_)) => "Page".to_string(), 1297 + // No `#` prefix: this node may equally be an uncreated 1298 + // `[[Page]]` reference as a real `#tag` (the two collapse to 1299 + // the same `NodeId::Tag` — see `resolve_reference`'s 1300 + // MVP-simplification note), and an uncreated page displaying 1301 + // as a hashtag reads more wrong than a hashtag displaying as 1302 + // a plain name. 1303 + View::Node(NodeId::Tag(name)) => name.clone(), 1304 + View::Node(NodeId::Date(date)) => date.to_string(), 1064 1305 }; 1306 + // Tag/Date views have no outline rows of their own (see 1307 + // `main_rows`) — without a header they'd render as a blank area 1308 + // above the backlinks panel with no indication of what's shown. 1309 + let view_header = 1310 + matches!(&self.view, View::Node(id) if id.as_tree_id().is_none()).then(|| { 1311 + div() 1312 + .px_2() 1313 + .pt_2() 1314 + .flex() 1315 + .flex_row() 1316 + .items_center() 1317 + .gap_2() 1318 + .child(div().font_weight(FontWeight::BOLD).child(title.clone())) 1319 + .child( 1320 + div() 1321 + .id("create-page") 1322 + .cursor_pointer() 1323 + .text_color(rgb(REFERENCE_COLOR)) 1324 + .child("+ Create page") 1325 + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { 1326 + this.create_page_for_view(&CreatePage, window, cx); 1327 + })), 1328 + ) 1329 + }); 1065 1330 1066 1331 let breadcrumb = self.breadcrumb.clone(); 1067 1332 let breadcrumb_bar = (!breadcrumb.is_empty()).then(|| { ··· 1072 1337 .cursor_pointer() 1073 1338 .child(name.clone()) 1074 1339 .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1075 - this.navigate_to(View::Node(id), None, window, cx); 1340 + this.navigate_to(View::Node(NodeId::tree(id)), None, window, cx); 1076 1341 })); 1077 1342 [ 1078 1343 crumb.into_any_element(), ··· 1102 1367 .flex_col() 1103 1368 .gap_1() 1104 1369 .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1105 - this.navigate_to(View::Node(page), Some(block), window, cx); 1370 + this.navigate_to(View::Node(NodeId::tree(page)), Some(block), window, cx); 1106 1371 })) 1107 1372 .child( 1108 1373 div() ··· 1127 1392 .children(entries) 1128 1393 }); 1129 1394 1395 + let quick_open_overlay = self.quick_open.as_ref().map(|state| { 1396 + let items = state 1397 + .results 1398 + .iter() 1399 + .enumerate() 1400 + .map(|(ix, (target, label))| { 1401 + let target = target.clone(); 1402 + let mut item = div() 1403 + .id(("quick-open-item", ix)) 1404 + .px_2() 1405 + .py_1() 1406 + .cursor_pointer() 1407 + .child(label.clone()) 1408 + .on_mouse_down( 1409 + MouseButton::Left, 1410 + cx.listener(move |this, _event, window, cx| { 1411 + this.quick_open = None; 1412 + this.navigate_to(View::Node(target.clone()), None, window, cx); 1413 + }), 1414 + ); 1415 + if ix == state.selected { 1416 + item = item.bg(rgb(0x2a3a55)); 1417 + } 1418 + item 1419 + }); 1420 + deferred( 1421 + gpui::anchored().position(point(px(220.0), px(70.0))).child( 1422 + div() 1423 + .id("quick-open") 1424 + .track_focus(&self.quick_open_focus) 1425 + .on_key_down(cx.listener(Self::quick_open_key_down)) 1426 + .w(px(420.0)) 1427 + .max_h(px(320.0)) 1428 + .overflow_hidden() 1429 + .flex() 1430 + .flex_col() 1431 + .bg(rgb(0x252525)) 1432 + .border_1() 1433 + .border_color(rgb(0x3a3a3a)) 1434 + .rounded_md() 1435 + .shadow_lg() 1436 + .child( 1437 + div() 1438 + .px_2() 1439 + .py_1() 1440 + .border_b_1() 1441 + .border_color(rgb(0x3a3a3a)) 1442 + .child(format!("> {}", state.query)), 1443 + ) 1444 + .children(items), 1445 + ), 1446 + ) 1447 + .with_priority(2) 1448 + .into_any_element() 1449 + }); 1450 + 1130 1451 div() 1131 1452 .size_full() 1132 1453 .flex() 1133 1454 .flex_col() 1134 1455 .key_context(APP_CONTEXT) 1456 + .track_focus(&self.root_focus) 1135 1457 .on_action(cx.listener(Self::navigate_back)) 1136 1458 .on_action(cx.listener(Self::navigate_forward)) 1459 + .on_action(cx.listener(Self::toggle_quick_open)) 1460 + .on_action(cx.listener(Self::create_page_for_view)) 1137 1461 .on_action(cx.listener(Self::zoom_in)) 1138 1462 .on_action(cx.listener(Self::zoom_out)) 1139 1463 .bg(rgb(0x1e1e1e)) ··· 1149 1473 row_count 1150 1474 )), 1151 1475 ) 1476 + .children(view_header) 1152 1477 .children(breadcrumb_bar) 1153 1478 .child( 1154 1479 div().flex_1().child( ··· 1234 1559 ), 1235 1560 ) 1236 1561 .children(backlinks_panel) 1562 + .children(quick_open_overlay) 1237 1563 } 1238 1564 } 1239 1565
+1 -1
openspec/changes/trawler-mvp/tasks.md
··· 51 51 - [x] 6.3 Follow-reference keyboard action; navigation history with back/forward restoring scroll+focus 52 52 - [x] 6.4 Backlinks section on every page/zoom view, grouped by source page, navigable 53 53 - [x] 6.5 Block zoom with ancestor breadcrumb; zoom-out 54 - - [ ] 6.6 Quick open: fuzzy switcher over pages/tags/dates 54 + - [x] 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) 57 57