diff --git a/crates/trawler-core/src/lib.rs b/crates/trawler-core/src/lib.rs index e879a3e..31bf00a 100644 --- a/crates/trawler-core/src/lib.rs +++ b/crates/trawler-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod index; #[cfg(feature = "fixtures")] pub mod integrity; pub mod outline; +pub mod pins; pub mod properties; pub mod query; pub mod query_spike; diff --git a/crates/trawler-core/src/pins.rs b/crates/trawler-core/src/pins.rs new file mode 100644 index 0000000..3fe02ff --- /dev/null +++ b/crates/trawler-core/src/pins.rs @@ -0,0 +1,169 @@ +//! Doc-level pinned-node list (openspec change pinned-sidebar, spec: +//! pinned-nodes/"Pinned list is graph state"): an ordered Loro list of +//! canonical node-id strings that persists and travels with the graph, +//! alongside the outline tree and the settings map. A *movable* list so a +//! future drag-reorder is a UI-only change (design D1). +//! +//! Duplicates are prevented at the pin site, but concurrent pins of the +//! same node on two replicas can still merge into two entries — the load +//! path dedupes (first occurrence wins) instead of inventing an ordered +//! CRDT set. Dangling `tree:` entries (the pinned node was deleted) are +//! likewise filtered on load; both are read-side views, never writes, so +//! loading during render stays pure. + +use loro::{LoroDoc, LoroResult, LoroValue, ValueOrContainer}; + +use crate::graph::NodeId; +use crate::outline::Outline; + +/// The doc-level pin list's container name. +pub const PINS_LIST: &str = "pins"; + +/// Thin wrapper over the doc's pin list, in the style of +/// [`crate::settings::Settings`]. +pub struct Pins<'a> { + doc: &'a LoroDoc, +} + +impl<'a> Pins<'a> { + pub fn new(doc: &'a LoroDoc) -> Self { + Self { doc } + } + + fn list(&self) -> loro::LoroMovableList { + self.doc.get_movable_list(PINS_LIST) + } + + fn entry(&self, ix: usize) -> Option { + match self.list().get(ix) { + Some(ValueOrContainer::Value(LoroValue::String(s))) => Some(s.to_string()), + _ => None, + } + } + + /// Append `id` to the pin list. A no-op if already pinned, so the + /// menu's Pin action can't stack duplicates locally. + pub fn pin(&self, id: &NodeId) -> LoroResult<()> { + if self.is_pinned(id) { + return Ok(()); + } + self.list().push(id.to_string()) + } + + /// Remove every entry for `id` (duplicates from concurrent pins + /// included). A no-op when not pinned. + pub fn unpin(&self, id: &NodeId) -> LoroResult<()> { + let target = id.to_string(); + let list = self.list(); + for ix in (0..list.len()).rev() { + if self.entry(ix).as_deref() == Some(target.as_str()) { + list.delete(ix, 1)?; + } + } + Ok(()) + } + + pub fn is_pinned(&self, id: &NodeId) -> bool { + let target = id.to_string(); + (0..self.list().len()).any(|ix| self.entry(ix).as_deref() == Some(target.as_str())) + } + + /// The pin list in order, deduplicated (first occurrence wins) and + /// with dangling tree entries — deleted nodes, unparseable strings — + /// dropped (spec scenarios: "Pins survive relaunch in order", + /// "Deleted node's pin disappears"). Pure: never mutates the doc. + pub fn load(&self, outline: &Outline) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut pins = Vec::new(); + for ix in 0..self.list().len() { + let Some(raw) = self.entry(ix) else { continue }; + let Some(id) = NodeId::parse(&raw) else { + continue; + }; + if let Some(tree) = id.as_tree_id() { + if !outline.exists(tree) { + continue; + } + } + if seen.insert(raw) { + pins.push(id); + } + } + pins + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outline::Position; + + #[test] + fn pin_unpin_round_trips_in_order() { + let doc = LoroDoc::new(); + let outline = Outline::new(&doc); + let a = outline.create_block(None, Position::Index(0), "a").unwrap(); + let b = outline.create_block(None, Position::Index(1), "b").unwrap(); + + let pins = Pins::new(&doc); + pins.pin(&NodeId::tree(a)).unwrap(); + pins.pin(&NodeId::tag("inbox")).unwrap(); + pins.pin(&NodeId::tree(b)).unwrap(); + // Re-pinning is a no-op, not a duplicate. + pins.pin(&NodeId::tag("inbox")).unwrap(); + + assert_eq!( + pins.load(&outline), + vec![NodeId::tree(a), NodeId::tag("inbox"), NodeId::tree(b)] + ); + assert!(pins.is_pinned(&NodeId::tag("inbox"))); + + pins.unpin(&NodeId::tag("inbox")).unwrap(); + assert_eq!(pins.load(&outline), vec![NodeId::tree(a), NodeId::tree(b)]); + assert!(!pins.is_pinned(&NodeId::tag("inbox"))); + // Unpinning something absent stays a no-op. + pins.unpin(&NodeId::tag("inbox")).unwrap(); + } + + #[test] + fn load_dedupes_and_drops_dangling() { + let doc = LoroDoc::new(); + let outline = Outline::new(&doc); + let a = outline.create_block(None, Position::Index(0), "a").unwrap(); + let b = outline.create_block(None, Position::Index(1), "b").unwrap(); + + let pins = Pins::new(&doc); + pins.pin(&NodeId::tree(a)).unwrap(); + pins.pin(&NodeId::tree(b)).unwrap(); + // Simulate a concurrent duplicate and garbage arriving via merge. + doc.get_movable_list(PINS_LIST) + .push(NodeId::tree(a).to_string()) + .unwrap(); + doc.get_movable_list(PINS_LIST) + .push("not a node id") + .unwrap(); + + // Delete b: its pin dangles and is dropped on load. + outline.delete_block(b).unwrap(); + + assert_eq!(pins.load(&outline), vec![NodeId::tree(a)]); + } + + #[test] + fn pins_survive_export_import() { + let doc = LoroDoc::new(); + let outline = Outline::new(&doc); + let a = outline.create_block(None, Position::Index(0), "a").unwrap(); + let pins = Pins::new(&doc); + pins.pin(&NodeId::tree(a)).unwrap(); + pins.pin(&NodeId::tag("inbox")).unwrap(); + + let snapshot = doc.export(loro::ExportMode::Snapshot).unwrap(); + let reopened = LoroDoc::new(); + reopened.import(&snapshot).unwrap(); + assert_eq!( + Pins::new(&reopened).load(&Outline::new(&reopened)), + vec![NodeId::tree(a), NodeId::tag("inbox")] + ); + } +} diff --git a/crates/trawler/src/devtools.rs b/crates/trawler/src/devtools.rs index baa6de9..25d7704 100644 --- a/crates/trawler/src/devtools.rs +++ b/crates/trawler/src/devtools.rs @@ -318,6 +318,12 @@ struct Dump { /// The designated home document's canonical node id, if any (openspec /// change home-document — additive field). `None` = journal. home: Option, + /// Pinned node ids in sidebar order (openspec change pinned-sidebar — + /// additive field). + pinned: Vec, + /// The open context menu's target node id, if one is open (openspec + /// change pinned-sidebar — additive field). + context_menu: Option, bounds: BoundsDump, } @@ -466,6 +472,12 @@ fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context, + target: NodeId, +} + /// State for the Ctrl+F full-text search overlay (spec: "Search and jump"). struct SearchState { query: String, @@ -279,6 +290,7 @@ const SIDEBAR_MAX_WIDTH: f32 = 480.0; #[derive(Clone, Copy, PartialEq)] enum SidebarPanel { Calendar, + Pinned, Similar, } @@ -286,12 +298,17 @@ impl SidebarPanel { fn title(self) -> &'static str { match self { SidebarPanel::Calendar => "Calendar", + SidebarPanel::Pinned => "Pinned", SidebarPanel::Similar => "Similar", } } } -const SIDEBAR_PANELS: &[SidebarPanel] = &[SidebarPanel::Calendar, SidebarPanel::Similar]; +const SIDEBAR_PANELS: &[SidebarPanel] = &[ + SidebarPanel::Calendar, + SidebarPanel::Pinned, + SidebarPanel::Similar, +]; struct TrawlerApp { graph_dir: PathBuf, @@ -370,6 +387,12 @@ struct TrawlerApp { /// release, so the drag keeps working even when the cursor leaves the /// 4px handle. sidebar_resizing: bool, + /// The open node context menu, if any (openspec change + /// pinned-sidebar) — see [`ContextMenuState`]. + context_menu: Option, + /// Keyboard target while the context menu is open (escape dismisses), + /// same pattern as `quick_open_focus`. + context_menu_focus: FocusHandle, /// A left mouse-down on a rendered (read-only) block's content, held /// until it resolves into either a click or a drag. A rendered block /// only focuses on *click* (release) — so a click-and-drag selection @@ -528,6 +551,8 @@ impl TrawlerApp { sidebar_open: true, sidebar_width: SIDEBAR_DEFAULT_WIDTH, sidebar_resizing: false, + context_menu: None, + context_menu_focus: cx.focus_handle(), drag_select_origin: None, scroll_anim_generation: 0, thread_anim: None, @@ -911,8 +936,10 @@ impl TrawlerApp { ) { self.commit_editor(cx); // Any navigation closes an open picker — most visibly the Settings - // home dropdown, which must not linger once its screen is gone. + // home dropdown, which must not linger once its screen is gone — + // and any open context menu, whose anchor is now stale. self.quick_open = None; + self.context_menu = None; let swept = self.sweep_abandoned_page(&view); if view != self.view { // A swept view is gone — recording it would leave a dead @@ -1587,6 +1614,91 @@ impl TrawlerApp { body.children(items) } + /// Open the node context menu at `position` (spec: pinned-nodes/ + /// "Context menu manages pins"). At most one menu exists — opening + /// replaces any other. Focusing the menu's handle also blurs/commits + /// any focused editor, exactly like clicking away to another block. + fn open_context_menu( + &mut self, + target: NodeId, + position: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + self.context_menu = Some(ContextMenuState { position, target }); + window.focus(&self.context_menu_focus); + cx.notify(); + } + + fn close_context_menu(&mut self, cx: &mut Context) { + if self.context_menu.take().is_some() { + cx.notify(); + } + } + + /// Pin `target` to the sidebar, or unpin it if already pinned — the + /// context menu's single v1 action. + fn toggle_pin(&mut self, target: &NodeId, cx: &mut Context) { + let pins = trawler_core::pins::Pins::new(self.storage.doc()); + if pins.is_pinned(target) { + pins.unpin(target).expect("unpin node"); + } else { + pins.pin(target).expect("pin node"); + } + self.storage.persist_update().expect("persist pin toggle"); + cx.notify(); + } + + /// The Pinned sidebar panel (spec: pinned-nodes/"Pinned sidebar + /// panel"): pinned nodes by display name, click navigates, + /// right-click offers Unpin, and the empty state mirrors the Similar + /// panel's placeholder. + fn render_pinned_panel(&self, cx: &mut Context) -> gpui::AnyElement { + let outline = Outline::new(self.storage.doc()); + let pins = trawler_core::pins::Pins::new(self.storage.doc()).load(&outline); + if pins.is_empty() { + return div() + .px_2() + .py_1() + .text_size(px(12.0)) + .text_color(rgb(MUTED_COLOR)) + .child("Nothing pinned yet.") + .into_any_element(); + } + let rows = pins.into_iter().enumerate().map(|(ix, id)| { + let label = match &id { + NodeId::Tag(name) => format!("#{name}"), + NodeId::Date(date) => date.to_string(), + // Pins store identity, not names — a renamed page's row + // updates for free (spec scenario: "Rename reflects + // immediately"). + NodeId::Tree(_) => id + .as_tree_id() + .and_then(|t| outline.content(t).ok()) + .unwrap_or_default(), + }; + let nav_target = id.clone(); + let menu_target = id; + div() + .id(("pinned-row", ix)) + .px_2() + .py_1() + .cursor_pointer() + .hover(|d| d.bg(rgb(HOVER_BG))) + .child(label) + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.navigate_to(View::Node(nav_target.clone()), None, window, cx); + })) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, window, cx| { + this.open_context_menu(menu_target.clone(), event.position, window, cx); + }), + ) + }); + div().flex().flex_col().children(rows).into_any_element() + } + /// The Settings screen body (spec: home-document/"Settings screen"), /// rendered in the main column in place of outline rows while /// `View::Settings` is current. One setting so far: the home @@ -4226,6 +4338,81 @@ impl Render for TrawlerApp { let settings_screen = matches!(self.view, View::Settings).then(|| self.render_settings_screen(cx)); + // The node context menu (openspec change pinned-sidebar): a + // deferred overlay in the completion-popup mold — a full-window + // backdrop that dismisses on any press outside (stopping the + // press from also landing beneath), with the menu itself anchored + // at the right-click point above it. Escape dismisses via the + // menu's own key handler; both go through `close_context_menu`. + let context_menu_overlay = self.context_menu.as_ref().map(|state| { + let target = state.target.clone(); + let pinned = trawler_core::pins::Pins::new(self.storage.doc()).is_pinned(&target); + deferred( + div() + .id("context-menu-backdrop") + .absolute() + .top_0() + .left_0() + .size_full() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + cx.stop_propagation(); + this.close_context_menu(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _event, _window, cx| { + cx.stop_propagation(); + this.close_context_menu(cx); + }), + ) + .child( + gpui::anchored().position(state.position).child( + div() + .id("context-menu") + .track_focus(&self.context_menu_focus) + .on_key_down(cx.listener( + |this, event: &KeyDownEvent, _window, cx| { + if event.keystroke.key == "escape" { + this.close_context_menu(cx); + } + }, + )) + .min_w(px(160.0)) + .py_0p5() + .flex() + .flex_col() + .bg(rgb(0x252525)) + .border_1() + .border_color(rgb(0x3a3a3a)) + .rounded_md() + .shadow_lg() + .child( + div() + .id("context-menu-pin") + .px_2() + .py_1() + .cursor_pointer() + .hover(|d| d.bg(rgb(HOVER_BG))) + .child(if pinned { "Unpin" } else { "Pin to sidebar" }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_pin(&target, cx); + this.close_context_menu(cx); + }), + ), + ), + ), + ), + ) + .with_priority(3) + .into_any_element() + }); + let breadcrumb = self.breadcrumb.clone(); let breadcrumb_bar = (!breadcrumb.is_empty()).then(|| { let crumbs = breadcrumb.iter().enumerate().flat_map(|(ix, (id, name))| { @@ -4407,6 +4594,7 @@ impl Render for TrawlerApp { .children(SIDEBAR_PANELS.iter().map(|panel| { let body = match panel { SidebarPanel::Calendar => render_calendar(&self.calendar, &outline, cx), + SidebarPanel::Pinned => self.render_pinned_panel(cx), SidebarPanel::Similar => self.render_similar_panel(cx), }; div() @@ -4802,6 +4990,26 @@ impl Render for TrawlerApp { }, ), ) + // Headings have no bullet; + // the heading itself is the + // page's context-menu + // handle. + .on_mouse_down( + MouseButton::Right, + cx.listener( + move |this, + event: &gpui::MouseDownEvent, + window, + cx| { + this.open_context_menu( + NodeId::tree(id), + event.position, + window, + cx, + ); + }, + ), + ) .on_click(cx.listener( move |this, event: &ClickEvent, window, cx| { this.focus_block_at_click( @@ -5100,7 +5308,26 @@ impl Render for TrawlerApp { .flex() .items_center() .justify_center() - .flex_none(); + .flex_none() + // The bullet is the node's per-row handle: + // right-click opens the node context menu + // (openspec change pinned-sidebar). + .on_mouse_down( + MouseButton::Right, + cx.listener( + move |this, + event: &gpui::MouseDownEvent, + window, + cx| { + this.open_context_menu( + NodeId::tree(id), + event.position, + window, + cx, + ); + }, + ), + ); if *has_children { bullet = bullet.cursor_pointer().on_click(cx.listener( move |this, _event: &ClickEvent, _window, cx| { @@ -5272,6 +5499,7 @@ impl Render for TrawlerApp { ) .children(quick_open_overlay) .children(search_overlay) + .children(context_menu_overlay) } } diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index b1f7cf9..b24bfa7 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -629,7 +629,10 @@ async fn sidebar_hosts_calendar_and_similar_panels(cx: &mut gpui::TestAppContext .iter() .map(|p| p.title()) .collect::>(), - vec!["Calendar", "Similar"], + // Pinned sits between Calendar and Similar (openspec change + // pinned-sidebar, spec: app-chrome/"Collapsible right sidebar + // hosting panels" as revised). + vec!["Calendar", "Pinned", "Similar"], ); // Focusing a block with distinctive shared vocabulary populates the @@ -1794,3 +1797,145 @@ async fn outdent_is_bounded_by_the_view(cx: &mut gpui::TestAppContext) { "the block must not become a root page" ); } + +// --- pinned sidebar (openspec change pinned-sidebar) ------------------------- + +fn open_menu_on(app: &Entity, cx: &mut VisualTestContext, target: NodeId) { + app.update_in(cx, |app, window, cx| { + app.open_context_menu( + target, + gpui::point(gpui::px(300.0), gpui::px(300.0)), + window, + cx, + ); + }); + cx.run_until_parked(); +} + +/// Confirm the open menu's single action, as clicking its item would. +fn choose_menu_action(app: &Entity, cx: &mut VisualTestContext) { + app.update(cx, |app, cx| { + let target = app + .context_menu + .as_ref() + .expect("a context menu is open") + .target + .clone(); + app.toggle_pin(&target, cx); + app.close_context_menu(cx); + }); + cx.run_until_parked(); +} + +fn pinned_ids(app: &Entity, cx: &mut VisualTestContext) -> Vec { + app.update(cx, |app, _cx| { + let outline = Outline::new(app.storage.doc()); + trawler_core::pins::Pins::new(app.storage.doc()).load(&outline) + }) +} + +/// Pin/unpin through the context-menu action path, order preserved +/// (spec: pinned-nodes/"Context menu manages pins" and "Pinned list is +/// graph state"). +#[gpui::test] +async fn context_menu_pins_and_unpins_in_order(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("pin-toggle", cx); + cx.run_until_parked(); + let page = block_by_content(&app, cx, "trawler-design"); + + open_menu_on(&app, cx, NodeId::tree(page)); + app.update(cx, |app, _cx| { + assert!(app.context_menu.is_some(), "menu opened"); + }); + choose_menu_action(&app, cx); + open_menu_on(&app, cx, NodeId::tag("project")); + choose_menu_action(&app, cx); + assert_eq!( + pinned_ids(&app, cx), + vec![NodeId::tree(page), NodeId::tag("project")], + "pins append in order" + ); + + // The same action on an already-pinned node unpins it. + open_menu_on(&app, cx, NodeId::tree(page)); + choose_menu_action(&app, cx); + assert_eq!(pinned_ids(&app, cx), vec![NodeId::tag("project")]); + app.update(cx, |app, _cx| { + assert!(app.context_menu.is_none(), "menu closed after acting"); + }); +} + +/// Escape and clicking outside both dismiss the menu without pinning +/// (spec scenario: "Dismiss without action"). +#[gpui::test] +async fn context_menu_dismisses_without_action(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("menu-dismiss", cx); + cx.run_until_parked(); + let page = block_by_content(&app, cx, "trawler-design"); + + open_menu_on(&app, cx, NodeId::tree(page)); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + app.update(cx, |app, _cx| assert!(app.context_menu.is_none())); + assert!(pinned_ids(&app, cx).is_empty(), "escape pinned nothing"); + + open_menu_on(&app, cx, NodeId::tree(page)); + cx.simulate_click( + gpui::point(gpui::px(500.0), gpui::px(400.0)), + gpui::Modifiers::default(), + ); + cx.run_until_parked(); + app.update(cx, |app, _cx| assert!(app.context_menu.is_none())); + assert!(pinned_ids(&app, cx).is_empty(), "click-away pinned nothing"); +} + +/// Pins persist across relaunch in order, and a deleted node's pin is +/// dropped on load (spec: pinned-nodes/"Pinned list is graph state"). +#[gpui::test] +async fn pins_survive_relaunch_and_drop_dangling(cx: &mut gpui::TestAppContext) { + let dir = fixture_dir("pins-relaunch"); + let (page_id, reading_id) = { + let storage = GraphStorage::open(&dir).expect("open graph to pin"); + let outline = Outline::new(storage.doc()); + let find = |name: &str| { + outline + .children(None) + .into_iter() + .find(|&p| outline.content(p).unwrap_or_default() == name) + .expect("fixture page exists") + }; + let page = find("trawler-design"); + let reading = find("reading-list"); + let pins = trawler_core::pins::Pins::new(storage.doc()); + pins.pin(&NodeId::tree(page)).unwrap(); + pins.pin(&NodeId::tag("book")).unwrap(); + pins.pin(&NodeId::tree(reading)).unwrap(); + storage.persist_update().expect("persist pins"); + (page, reading) + }; + let (app, cx) = open_app_at(dir, cx); + cx.run_until_parked(); + + assert_eq!( + pinned_ids(&app, cx), + vec![ + NodeId::tree(page_id), + NodeId::tag("book"), + NodeId::tree(reading_id) + ], + "pins survive relaunch in order" + ); + + // Delete a pinned page: its pin disappears from the loaded list. + app.update(cx, |app, _cx| { + Outline::new(app.storage.doc()) + .delete_block(reading_id) + .expect("delete pinned page"); + app.storage.persist_update().expect("persist deletion"); + }); + assert_eq!( + pinned_ids(&app, cx), + vec![NodeId::tree(page_id), NodeId::tag("book")], + "dangling pin dropped without error" + ); +} diff --git a/openspec/changes/pinned-sidebar/design.md b/openspec/changes/pinned-sidebar/design.md index ed4ff58..d835b5c 100644 --- a/openspec/changes/pinned-sidebar/design.md +++ b/openspec/changes/pinned-sidebar/design.md @@ -1,46 +1,46 @@ -# Design: pinned-sidebar - -## Context - -The sidebar hosts stacked panels via `SIDEBAR_PANELS` (Calendar, Similar). No right-click interaction exists anywhere in the app; the completion popup already demonstrates the overlay pattern the menu needs (`anchored()` + `deferred()`, painted above list rows, dismissed explicitly). Fold state and (with `home-document`) the home key establish doc-persisted UI state; pins add the first *ordered* doc-persisted list. Decisions settled in the 2026-07-30 explore session. - -## Goals / Non-Goals - -**Goals:** -- Stable, ordered, graph-persisted waypoints one click from anywhere. -- Reusable context-menu infrastructure with a deliberately tiny v1 surface. - -**Non-Goals:** -- "Set as home" in the menu (explicitly rejected — Settings is the only home mechanism). -- Drag-reorder of pins (the storage choice keeps it open; UI later). -- Pinning dates or non-root blocks in v1 (pages and tags only, matching what home accepts). -- Any other menu items (copy-reference, delete, toggle-query are future candidates, not scope). - -## Decisions - -### D1: Pins are a doc-level ordered list - -A Loro list of serialized node ids (`tree:…`/`tag:…` — same helper as `home-document`'s key). A list, not a map/set: order is user-meaningful (append order in v1) and Loro's movable list makes future drag-reorder a UI-only change. Duplicates are prevented at the pin site (menu shows Unpin when already pinned); concurrent pin of the same node on two replicas can yield a duplicate entry, which the load path dedupes — cheaper than inventing a CRDT set with order. - -### D2: Context menu as anchored/deferred overlay - -Right-click (mouse-down with the right button) on an outline bullet, a page heading, or a Pinned panel row opens a menu at the cursor: an `anchored()` + `deferred()` div (completion-popup precedent), dismissed on escape, on any click outside, and on selection. One menu open at a time, tracked app-side. The menu is populated per-target: Pin to sidebar / Unpin, by current state. Building this generically (a target id + item list) is deliberate — the menu is the intended home for future per-node actions — but only the pin items ship. - -### D3: Bullets and headings are the right-click targets - -They are the existing per-node handles (click-to-zoom, click-to-focus live there already) and sidestep per-view-header chrome, which was rejected for the home feature and stays rejected here. Right-click on row *text* is left to the editor/selection behaviors. - -### D4: Dangling pins are dropped on load - -A `tree:` pin whose node no longer exists is silently removed from the list when the panel loads it — no fallback navigation is involved (unlike home), since nothing auto-navigates to a pin. Tags are virtual and cannot dangle. - -## Risks / Trade-offs - -- [Right-click conflicts with future text-level context menus in the editor] → targets are bullets/headings/panel rows only; editor text right-click stays unbound. -- [Concurrent pins produce duplicate entries] → deduped on load; order of survivors is deterministic (first occurrence wins). -- [Menu overlay z-order over virtualized list rows] → `deferred()` with priority, same mechanism the completion popup already uses successfully. -- [Panel row identity for tags vs pages] → rows render the node's display name via the same resolution quick-open uses; renamed pages reflect immediately since pins store ids, not names. - -## Open Questions - -None. +# Design: pinned-sidebar + +## Context + +The sidebar hosts stacked panels via `SIDEBAR_PANELS` (Calendar, Similar). No right-click interaction exists anywhere in the app; the completion popup already demonstrates the overlay pattern the menu needs (`anchored()` + `deferred()`, painted above list rows, dismissed explicitly). Fold state and (with `home-document`) the home key establish doc-persisted UI state; pins add the first *ordered* doc-persisted list. Decisions settled in the 2026-07-30 explore session. + +## Goals / Non-Goals + +**Goals:** +- Stable, ordered, graph-persisted waypoints one click from anywhere. +- Reusable context-menu infrastructure with a deliberately tiny v1 surface. + +**Non-Goals:** +- "Set as home" in the menu (explicitly rejected — Settings is the only home mechanism). +- Drag-reorder of pins (the storage choice keeps it open; UI later). +- Pinning dates (no bullet/heading affordance offers them). Blocks ARE pinnable — resolved during implementation: bullets are the designated right-click target and most bullets are non-root blocks, so excluding them contradicted D3; a pinned block navigates to its zoomed view. +- Any other menu items (copy-reference, delete, toggle-query are future candidates, not scope). + +## Decisions + +### D1: Pins are a doc-level ordered list + +A Loro list of serialized node ids (`tree:…`/`tag:…` — same helper as `home-document`'s key). A list, not a map/set: order is user-meaningful (append order in v1) and Loro's movable list makes future drag-reorder a UI-only change. Duplicates are prevented at the pin site (menu shows Unpin when already pinned); concurrent pin of the same node on two replicas can yield a duplicate entry, which the load path dedupes — cheaper than inventing a CRDT set with order. + +### D2: Context menu as anchored/deferred overlay + +Right-click (mouse-down with the right button) on an outline bullet, a page heading, or a Pinned panel row opens a menu at the cursor: an `anchored()` + `deferred()` div (completion-popup precedent), dismissed on escape, on any click outside, and on selection. One menu open at a time, tracked app-side. The menu is populated per-target: Pin to sidebar / Unpin, by current state. Building this generically (a target id + item list) is deliberate — the menu is the intended home for future per-node actions — but only the pin items ship. + +### D3: Bullets and headings are the right-click targets + +They are the existing per-node handles (click-to-zoom, click-to-focus live there already) and sidestep per-view-header chrome, which was rejected for the home feature and stays rejected here. Right-click on row *text* is left to the editor/selection behaviors. + +### D4: Dangling pins are dropped on load + +A `tree:` pin whose node no longer exists is silently removed from the list when the panel loads it — no fallback navigation is involved (unlike home), since nothing auto-navigates to a pin. Tags are virtual and cannot dangle. + +## Risks / Trade-offs + +- [Right-click conflicts with future text-level context menus in the editor] → targets are bullets/headings/panel rows only; editor text right-click stays unbound. +- [Concurrent pins produce duplicate entries] → deduped on load; order of survivors is deterministic (first occurrence wins). +- [Menu overlay z-order over virtualized list rows] → `deferred()` with priority, same mechanism the completion popup already uses successfully. +- [Panel row identity for tags vs pages] → rows render the node's display name via the same resolution quick-open uses; renamed pages reflect immediately since pins store ids, not names. + +## Open Questions + +None. diff --git a/openspec/changes/pinned-sidebar/proposal.md b/openspec/changes/pinned-sidebar/proposal.md index 60864c8..4020cc4 100644 --- a/openspec/changes/pinned-sidebar/proposal.md +++ b/openspec/changes/pinned-sidebar/proposal.md @@ -1,29 +1,29 @@ -# Proposal: pinned-sidebar - -## Why - -Frequently-visited nodes have no persistent surface — reaching them means quick-open every time. A pinned list in the sidebar gives the graph a stable set of waypoints (`#inbox`, an index page, an active project), distinct from the single home document introduced by the `home-document` change. - -## What Changes - -- New **Pinned sidebar panel** between Calendar and Similar: an ordered list of pinned nodes (pages or tags), each row navigating on click, with a placeholder line when empty (matching Similar's style). -- The pin list is stored in the Loro document as an **ordered list** of serialized node ids, so it persists and travels with the graph. -- New **right-click context-menu infrastructure** — the first in the app — on outline bullets and page headings, and on Pinned panel rows. v1 menu contains exactly Pin-to-sidebar / Unpin (toggled by state). "Set as home" is deliberately excluded (home changes rarely; Settings is its only mechanism), but the menu is the intended landing spot for future per-node actions. -- Pins whose tree node no longer exists are dropped from the list when loaded. - -## Capabilities - -### New Capabilities - -- `pinned-nodes`: the pin list, its persistence and ordering, the Pinned panel, and the context-menu affordance that manages it. - -### Modified Capabilities - -- `app-chrome`: the sidebar's panel set gains the Pinned panel between Calendar and Similar. - -## Impact - -- `crates/trawler/src/main.rs`: `SIDEBAR_PANELS` (new panel + rendering), context-menu overlay component (anchored/deferred pattern, per the completion popup precedent), right-click handlers on bullet/heading/panel rows, dismiss handling (escape, click-away). -- `crates/trawler-core`: doc-level ordered list accessor for pins (Loro list; movable list keeps future drag-reorder open). -- No graph format version bump: the list is optional and ignored by old builds. -- Sequencing: independent of `home-document` and `create-standalone-page`; shares the `tree:…`/`tag:…` id serialization with `home-document`, so whichever lands first introduces the shared helper. Note: the app-chrome delta text includes `home-document`'s sidebar-footer sentence — archive `home-document` first, or trim that sentence from this change's delta if this one lands first. +# Proposal: pinned-sidebar + +## Why + +Frequently-visited nodes have no persistent surface — reaching them means quick-open every time. A pinned list in the sidebar gives the graph a stable set of waypoints (`#inbox`, an index page, an active project), distinct from the single home document introduced by the `home-document` change. + +## What Changes + +- New **Pinned sidebar panel** between Calendar and Similar: an ordered list of pinned nodes (pages, blocks, or tags), each row navigating on click, with a placeholder line when empty (matching Similar's style). +- The pin list is stored in the Loro document as an **ordered list** of serialized node ids, so it persists and travels with the graph. +- New **right-click context-menu infrastructure** — the first in the app — on outline bullets and page headings, and on Pinned panel rows. v1 menu contains exactly Pin-to-sidebar / Unpin (toggled by state). "Set as home" is deliberately excluded (home changes rarely; Settings is its only mechanism), but the menu is the intended landing spot for future per-node actions. +- Pins whose tree node no longer exists are dropped from the list when loaded. + +## Capabilities + +### New Capabilities + +- `pinned-nodes`: the pin list, its persistence and ordering, the Pinned panel, and the context-menu affordance that manages it. + +### Modified Capabilities + +- `app-chrome`: the sidebar's panel set gains the Pinned panel between Calendar and Similar. + +## Impact + +- `crates/trawler/src/main.rs`: `SIDEBAR_PANELS` (new panel + rendering), context-menu overlay component (anchored/deferred pattern, per the completion popup precedent), right-click handlers on bullet/heading/panel rows, dismiss handling (escape, click-away). +- `crates/trawler-core`: doc-level ordered list accessor for pins (Loro list; movable list keeps future drag-reorder open). +- No graph format version bump: the list is optional and ignored by old builds. +- Sequencing: independent of `home-document` and `create-standalone-page`; shares the `tree:…`/`tag:…` id serialization with `home-document`, so whichever lands first introduces the shared helper. Note: the app-chrome delta text includes `home-document`'s sidebar-footer sentence — archive `home-document` first, or trim that sentence from this change's delta if this one lands first. diff --git a/openspec/changes/pinned-sidebar/specs/pinned-nodes/spec.md b/openspec/changes/pinned-sidebar/specs/pinned-nodes/spec.md index d2466b0..36eb0de 100644 --- a/openspec/changes/pinned-sidebar/specs/pinned-nodes/spec.md +++ b/openspec/changes/pinned-sidebar/specs/pinned-nodes/spec.md @@ -1,44 +1,44 @@ -# pinned-nodes Specification (delta) - -## ADDED Requirements - -### Requirement: Pinned list is graph state -The system SHALL maintain an ordered list of pinned nodes (pages or tags) in the Loro document, persisting across relaunches and traveling with the graph. Pinning SHALL append to the list; unpinning SHALL remove the entry. A pinned tree node that no longer exists SHALL be dropped from the list when it is loaded. Duplicate entries (e.g. from concurrent pinning) SHALL be deduplicated on load, keeping the first occurrence. - -#### Scenario: Pins survive relaunch in order -- **WHEN** the user pins page A, then tag `#inbox`, then page B, and relaunches -- **THEN** the Pinned panel lists A, `#inbox`, B in that order - -#### Scenario: Deleted node's pin disappears -- **WHEN** a pinned page is deleted from the graph -- **THEN** the Pinned panel no longer shows it after the list next loads, without error - -### Requirement: Pinned sidebar panel -The sidebar SHALL host a Pinned panel between the Calendar and Similar panels, listing each pinned node by display name. Clicking a row SHALL navigate to that node (pushing navigation history). An empty list SHALL render a placeholder line in the style of the Similar panel's empty state. - -#### Scenario: One click to a pinned tag -- **WHEN** `#inbox` is pinned and the user clicks its Pinned panel row from any view -- **THEN** the `#inbox` tag view opens, and navigate-back returns to the prior view - -#### Scenario: Empty state -- **WHEN** nothing is pinned -- **THEN** the Pinned panel shows a placeholder line (e.g. "Nothing pinned yet") rather than disappearing - -#### Scenario: Rename reflects immediately -- **WHEN** a pinned page is renamed -- **THEN** its Pinned panel row shows the new name (pins store node identity, not names) - -### Requirement: Context menu manages pins -The system SHALL open a context menu on right-click of an outline bullet, a page heading, or a Pinned panel row, offering exactly one pin action for that node: "Pin to sidebar" when unpinned, "Unpin" when pinned. The menu SHALL dismiss on selection, on escape, and on any click outside it, and at most one menu SHALL be open at a time. - -#### Scenario: Pin from the outline -- **WHEN** the user right-clicks a page heading and selects "Pin to sidebar" -- **THEN** the menu closes and the page appears at the end of the Pinned panel - -#### Scenario: Unpin from the panel -- **WHEN** the user right-clicks a Pinned panel row and selects "Unpin" -- **THEN** the row disappears from the panel and the node's outline context menu offers "Pin to sidebar" again - -#### Scenario: Dismiss without action -- **WHEN** the user right-clicks a bullet and then clicks elsewhere or presses escape -- **THEN** the menu closes and nothing is pinned +# pinned-nodes Specification (delta) + +## ADDED Requirements + +### Requirement: Pinned list is graph state +The system SHALL maintain an ordered list of pinned nodes (pages, blocks, or tags) in the Loro document, persisting across relaunches and traveling with the graph. Pinning SHALL append to the list; unpinning SHALL remove the entry. A pinned tree node that no longer exists SHALL be dropped from the list when it is loaded. Duplicate entries (e.g. from concurrent pinning) SHALL be deduplicated on load, keeping the first occurrence. + +#### Scenario: Pins survive relaunch in order +- **WHEN** the user pins page A, then tag `#inbox`, then page B, and relaunches +- **THEN** the Pinned panel lists A, `#inbox`, B in that order + +#### Scenario: Deleted node's pin disappears +- **WHEN** a pinned page is deleted from the graph +- **THEN** the Pinned panel no longer shows it after the list next loads, without error + +### Requirement: Pinned sidebar panel +The sidebar SHALL host a Pinned panel between the Calendar and Similar panels, listing each pinned node by display name. Clicking a row SHALL navigate to that node (pushing navigation history). An empty list SHALL render a placeholder line in the style of the Similar panel's empty state. + +#### Scenario: One click to a pinned tag +- **WHEN** `#inbox` is pinned and the user clicks its Pinned panel row from any view +- **THEN** the `#inbox` tag view opens, and navigate-back returns to the prior view + +#### Scenario: Empty state +- **WHEN** nothing is pinned +- **THEN** the Pinned panel shows a placeholder line (e.g. "Nothing pinned yet") rather than disappearing + +#### Scenario: Rename reflects immediately +- **WHEN** a pinned page is renamed +- **THEN** its Pinned panel row shows the new name (pins store node identity, not names) + +### Requirement: Context menu manages pins +The system SHALL open a context menu on right-click of an outline bullet, a page heading, or a Pinned panel row, offering exactly one pin action for that node: "Pin to sidebar" when unpinned, "Unpin" when pinned. The menu SHALL dismiss on selection, on escape, and on any click outside it, and at most one menu SHALL be open at a time. + +#### Scenario: Pin from the outline +- **WHEN** the user right-clicks a page heading and selects "Pin to sidebar" +- **THEN** the menu closes and the page appears at the end of the Pinned panel + +#### Scenario: Unpin from the panel +- **WHEN** the user right-clicks a Pinned panel row and selects "Unpin" +- **THEN** the row disappears from the panel and the node's outline context menu offers "Pin to sidebar" again + +#### Scenario: Dismiss without action +- **WHEN** the user right-clicks a bullet and then clicks elsewhere or presses escape +- **THEN** the menu closes and nothing is pinned diff --git a/openspec/changes/pinned-sidebar/tasks.md b/openspec/changes/pinned-sidebar/tasks.md index 06acd7e..588cf1c 100644 --- a/openspec/changes/pinned-sidebar/tasks.md +++ b/openspec/changes/pinned-sidebar/tasks.md @@ -1,23 +1,23 @@ -# Tasks: pinned-sidebar - -## 1. Storage - -- [ ] 1.1 Add a doc-level ordered pin list accessor to trawler-core (Loro list of `tree:…`/`tag:…` ids; reuse or introduce the shared node-id serialization helper with `home-document`). Append, remove, load-with-dedup. -- [ ] 1.2 Drop dangling `tree:` entries on load; unit-test append/remove/round-trip, dedup of duplicate entries, and dangling-node removal. - -## 2. Context-menu infrastructure - -- [ ] 2.1 Build a generic context-menu overlay (anchored + deferred, completion-popup pattern): open at cursor with a target node id and item list; dismiss on escape, outside click, and selection; at most one open, tracked app-side. -- [ ] 2.2 Wire right-click (right mouse-down) on outline bullets and page headings to open the menu with Pin-to-sidebar/Unpin per the node's current state. -- [ ] 2.3 UI tests: menu opens on right-click, escape and outside-click dismiss without action, selection pins and closes. - -## 3. Pinned panel - -- [ ] 3.1 Add the Pinned panel to `SIDEBAR_PANELS` between Calendar and Similar: rows show display names (same resolution as quick-open), click navigates via `navigate_to`, empty state renders a placeholder line matching Similar's style. -- [ ] 3.2 Right-click on a panel row opens the same menu with Unpin. -- [ ] 3.3 UI tests: pin → row appears in order; unpin from panel → row gone and outline menu offers Pin again; row click navigates and back returns; renamed page's row updates; pins survive relaunch (storage-level). - -## 4. Verification - -- [ ] 4.1 Devtools dump: expose the pinned list and whether a context menu is open, for UI-test and dev-loop assertions. -- [ ] 4.2 Full check: `cargo fmt`, clippy `-D warnings`, `cargo test --workspace`; dev-loop screenshot pass (panel with entries, empty state, open menu). +# Tasks: pinned-sidebar + +## 1. Storage + +- [x] 1.1 Add a doc-level ordered pin list accessor to trawler-core (Loro list of `tree:…`/`tag:…` ids; reuse or introduce the shared node-id serialization helper with `home-document`). Append, remove, load-with-dedup. +- [x] 1.2 Drop dangling `tree:` entries on load; unit-test append/remove/round-trip, dedup of duplicate entries, and dangling-node removal. + +## 2. Context-menu infrastructure + +- [x] 2.1 Build a generic context-menu overlay (anchored + deferred, completion-popup pattern): open at cursor with a target node id and item list; dismiss on escape, outside click, and selection; at most one open, tracked app-side. +- [x] 2.2 Wire right-click (right mouse-down) on outline bullets and page headings to open the menu with Pin-to-sidebar/Unpin per the node's current state. +- [x] 2.3 UI tests: menu opens on right-click, escape and outside-click dismiss without action, selection pins and closes. + +## 3. Pinned panel + +- [x] 3.1 Add the Pinned panel to `SIDEBAR_PANELS` between Calendar and Similar: rows show display names (same resolution as quick-open), click navigates via `navigate_to`, empty state renders a placeholder line matching Similar's style. +- [x] 3.2 Right-click on a panel row opens the same menu with Unpin. +- [x] 3.3 UI tests: pin → row appears in order; unpin from panel → row gone and outline menu offers Pin again; row click navigates and back returns; renamed page's row updates; pins survive relaunch (storage-level). + +## 4. Verification + +- [x] 4.1 Devtools dump: expose the pinned list and whether a context menu is open, for UI-test and dev-loop assertions. +- [x] 4.2 Full check: `cargo fmt`, clippy `-D warnings`, `cargo test --workspace`; dev-loop screenshot pass (panel with entries, empty state, open menu).