diff --git a/crates/trawler/src/devtools.rs b/crates/trawler/src/devtools.rs index 49009b3..ff876cd 100644 --- a/crates/trawler/src/devtools.rs +++ b/crates/trawler/src/devtools.rs @@ -308,11 +308,24 @@ struct Dump { focused: Option, quick_open: Option, search: Option, - /// First day of the month the calendar picker is showing, if open. + /// First day of the month the calendar picker is showing, if visible. + /// The calendar is a sidebar panel (openspec change ui-polish), so + /// this is populated exactly when the sidebar is open. calendar_month: Option, + /// The right-hand sidebar's state (openspec change ui-polish, + /// tasks 3.5 — additive field). + sidebar: SidebarDump, bounds: BoundsDump, } +#[derive(Serialize)] +struct SidebarDump { + open: bool, + width: f32, + /// Panel titles in display order. + panels: Vec, +} + #[derive(Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum ViewDump { @@ -433,9 +446,17 @@ fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context &'static str { + match self { + SidebarPanel::Calendar => "Calendar", + SidebarPanel::Similar => "Similar", + } + } +} + +const SIDEBAR_PANELS: &[SidebarPanel] = &[SidebarPanel::Calendar, SidebarPanel::Similar]; + struct TrawlerApp { graph_dir: PathBuf, /// Shared with the app-level quit hook (see `TrawlerApp::new`): on @@ -285,8 +314,20 @@ struct TrawlerApp { /// arbitrary day's journal page by clicking a date — quicker than /// hunting for a `[[date]]` reference to follow, and doubles as a /// manual way to reach today's page if the automatic rollover timer - /// ever misses it. - calendar_open: Option, + /// ever misses it. Lives permanently in the sidebar (openspec change + /// ui-polish, design D3) — its month state persists across sidebar + /// collapse/expand. + calendar: CalendarState, + /// Whether the right-hand sidebar (Calendar + Similar panels) is shown. + sidebar_open: bool, + /// Current sidebar width, user-resizable by dragging its left edge + /// within [`SIDEBAR_MIN_WIDTH`]..=[`SIDEBAR_MAX_WIDTH`]. + sidebar_width: f32, + /// True while a sidebar edge-drag is in progress: the root element's + /// mouse-move/up listeners consume motion into `sidebar_width` until + /// release, so the drag keeps working even when the cursor leaves the + /// 4px handle. + sidebar_resizing: bool, /// Blocks lexically similar to the currently focused block (spec: /// "Similar blocks (lexical)"), refreshed on every focus change. /// Shown as a dedicated panel rather than an inline footer on the @@ -397,7 +438,13 @@ impl TrawlerApp { search, search_open: None, search_focus: cx.focus_handle(), - calendar_open: None, + calendar: CalendarState { + month: NaiveDate::from_ymd_opt(today.year(), today.month(), 1) + .expect("today's year/month is always a valid date"), + }, + sidebar_open: true, + sidebar_width: SIDEBAR_DEFAULT_WIDTH, + sidebar_resizing: false, similar: Vec::new(), query_results: HashMap::new(), query_pending: HashSet::new(), @@ -871,34 +918,43 @@ impl TrawlerApp { self.navigate_to(View::Node(NodeId::tree(page)), Some(hit.block), window, cx); } - /// Ctrl+Shift+C / the "Calendar" button: open (or close, if already - /// open) the calendar picker, defaulting to the current month. + /// Ctrl+Shift+B / the titlebar toggle button: collapse or expand the + /// sidebar. Panel state (e.g. the calendar's displayed month) lives on + /// `self`, so collapsing never discards it (spec: app-chrome/ + /// "Collapsible right sidebar hosting panels"). + fn toggle_sidebar(&mut self, _: &ToggleSidebar, _window: &mut Window, cx: &mut Context) { + self.sidebar_open = !self.sidebar_open; + cx.notify(); + } + + /// Ctrl+Shift+C / the "Calendar" button: reveal the calendar — the + /// calendar lives in the sidebar now, so invoking it opens the sidebar + /// (resetting the view to the current month for the muscle-memory + /// "jump to today" use); if the sidebar is already open, this toggles + /// it closed, preserving the old open/close rhythm of the popup. fn toggle_calendar( &mut self, _: &ToggleCalendar, _window: &mut Window, cx: &mut Context, ) { - if self.calendar_open.is_some() { - self.calendar_open = None; + if self.sidebar_open { + self.sidebar_open = false; } else { let today = chrono::Local::now().date_naive(); - self.calendar_open = Some(CalendarState { - month: NaiveDate::from_ymd_opt(today.year(), today.month(), 1) - .expect("today's year/month is always a valid date"), - }); + self.calendar.month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1) + .expect("today's year/month is always a valid date"); + self.sidebar_open = true; } cx.notify(); } /// Move the calendar's displayed month by `delta` (±1). fn shift_calendar_month(&mut self, delta: i32, cx: &mut Context) { - let Some(state) = self.calendar_open.as_mut() else { - return; - }; - let total_months = state.month.year() * 12 + state.month.month0() as i32 + delta; + let month = self.calendar.month; + let total_months = month.year() * 12 + month.month0() as i32 + delta; let (year, month0) = (total_months.div_euclid(12), total_months.rem_euclid(12)); - state.month = NaiveDate::from_ymd_opt(year, month0 as u32 + 1, 1) + self.calendar.month = NaiveDate::from_ymd_opt(year, month0 as u32 + 1, 1) .expect("computed year/month is always in range"); cx.notify(); } @@ -915,7 +971,8 @@ impl TrawlerApp { // Rare event (a deliberate date jump, not per-keystroke) — a full // rebuild is simpler than precisely tracking what changed. let _ = self.search.rebuild(self.storage.doc()); - self.calendar_open = None; + // The sidebar stays open: the calendar is a persistent panel now, + // not a popup to dismiss after each use. self.navigate_to(View::Node(NodeId::tree(page)), None, window, cx); } @@ -1095,6 +1152,63 @@ impl TrawlerApp { .unwrap_or_default(); } + /// The Similar panel's body (sidebar-hosted — openspec change + /// ui-polish, design D3; previously a bottom dock). An explicit empty + /// state keeps the panel discoverable when nothing is focused or + /// nothing matches. + fn render_similar_panel(&self, cx: &mut Context) -> gpui::AnyElement { + if self.similar.is_empty() { + return div() + .px_2() + .py_1() + .text_size(px(12.0)) + .text_color(rgb(MUTED_COLOR)) + .child("Nothing similar to the focused block yet.") + .into_any_element(); + } + let entries = self.similar.iter().enumerate().map(|(ix, hit)| { + let snippet = render_snippet(&hit.snippet_html); + let hit = hit.clone(); + div() + .id(("similar", ix)) + .cursor_pointer() + .px_2() + .py_0p5() + .flex() + .flex_row() + .items_start() + .gap_2() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { + this.navigate_to_search_hit(hit.clone(), window, cx); + })) + // The same 6px dot the outline rows use, nudged down to sit + // on the smaller first text line's optical center. + .child( + div() + .flex_none() + .mt(px(6.0)) + .size(px(6.0)) + .rounded_full() + .bg(rgb(MUTED_COLOR)), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(px(12.0)) + .text_color(rgb(MUTED_COLOR)) + .child(snippet), + ) + }); + div() + .flex() + .flex_col() + .py_0p5() + .children(entries) + .into_any_element() + } + /// Ctrl+Shift+Q: flip whether the focused block is a query block (spec: /// steel-queries/"Query blocks live in the outline"). A no-op if /// nothing is focused. @@ -1769,14 +1883,6 @@ const OUTLINE_LEFT_PAD: f32 = 8.0; /// line box's geometric center. Tuned by eye; zero at the current line /// height. const BULLET_OPTICAL_NUDGE: f32 = 0.0; -/// Approximate height of the header bar (`.p_2()` padding plus one line of -/// text) — generous on purpose. Used to keep the calendar's click-away -/// backdrop out of the header entirely; see `calendar_backdrop`'s doc for -/// why. Erring high just shrinks the backdrop's top edge slightly (a -/// harmless sliver near the header boundary that won't dismiss on click); -/// erring low would let the backdrop reach back into the header and -/// reintroduce the bug this exists to avoid. -const HEADER_HEIGHT: f32 = 48.0; /// A query block's own row gets this background tint — without it a query /// block was indistinguishable from an ordinary one. Deliberately applied /// only to the row, not the result section below it too: tinting both @@ -2056,79 +2162,84 @@ fn render_calendar( let mut cells: Vec<_> = (0..leading_blanks).map(|_| day_cell(None)).collect(); cells.extend((1..=days_in_month as u32).map(|day| day_cell(Some(day)))); - // No explicit `.position(...)`: nested as a child of the "Calendar" - // button itself (see its `.children(...)` in the header), `anchored` - // then uses that button's own layout position — i.e. it renders right - // under the button that opened it — instead of a hardcoded point that - // has no relationship to where the button actually is. - deferred( - gpui::anchored().child( + // Explicit rows of seven, NOT a flex_wrap grid: percentage-width cells + // in a wrapping row can overflow the line by a sub-pixel rounding + // error at some parent widths, wrapping the seventh cell and reflowing + // the whole month to six misaligned columns (seen live while + // drag-resizing the sidebar). Fixed-size rows can't reflow. + let mut weeks = Vec::new(); + let mut cells = cells.into_iter(); + loop { + let week: Vec<_> = cells.by_ref().take(7).collect(); + if week.is_empty() { + break; + } + weeks.push(div().flex().flex_row().children(week)); + } + + // A sidebar panel body (openspec change ui-polish, design D3), not a + // popup: fills the sidebar's width, no anchoring or overlay chrome. + div() + .id("calendar") + .flex() + .flex_col() + .gap_1() + .p_2() + .child( div() - .id("calendar") - .w(px(280.0)) - .bg(rgb(0x252525)) - .border_1() - .border_color(rgb(0x3a3a3a)) - .rounded_md() - .shadow_lg() .flex() - .flex_col() - .gap_1() - .p_2() + .flex_row() + .items_center() + .justify_between() + // Extra breathing room below the month header specifically + // (stacks with the container's gap_1) — the week grid below + // keeps the tighter gap_1 rhythm. + .mb_1() .child( div() - .flex() - .flex_row() - .items_center() - .justify_between() - .child( - div() - .id("calendar-prev") - .cursor_pointer() - .px_1() - .py_0p5() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) - .child(assets::icon("icons/chevron-left.svg")) - .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { - this.shift_calendar_month(-1, cx); - })), - ) - .child( - div() - .font_weight(FontWeight::BOLD) - .child(month.format("%B %Y").to_string()), - ) - .child( - div() - .id("calendar-next") - .cursor_pointer() - .px_1() - .py_0p5() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) - .child(assets::icon("icons/chevron-right.svg")) - .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { - this.shift_calendar_month(1, cx); - })), - ), + .id("calendar-prev") + .cursor_pointer() + .px_1() + .py_0p5() + .rounded_sm() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .child(assets::icon("icons/chevron-left.svg")) + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { + this.shift_calendar_month(-1, cx); + })), ) - .child(div().flex().flex_row().children( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].iter().map(|d| { - div() - .w(relative(1.0 / 7.0)) - .flex() - .justify_center() - .text_size(px(11.0)) - .text_color(rgb(MUTED_COLOR)) - .child(*d) - }), - )) - .child(div().flex().flex_row().flex_wrap().children(cells)), - ), - ) - .with_priority(2) - .into_any_element() + .child( + div() + .font_weight(FontWeight::BOLD) + .child(month.format("%B %Y").to_string()), + ) + .child( + div() + .id("calendar-next") + .cursor_pointer() + .px_1() + .py_0p5() + .rounded_sm() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .child(assets::icon("icons/chevron-right.svg")) + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { + this.shift_calendar_month(1, cx); + })), + ), + ) + .child(div().flex().flex_row().children( + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].iter().map(|d| { + div() + .w(relative(1.0 / 7.0)) + .flex() + .justify_center() + .text_size(px(11.0)) + .text_color(rgb(MUTED_COLOR)) + .child(*d) + }), + )) + .children(weeks) + .into_any_element() } fn render_snippet(html: &str) -> gpui::AnyElement { @@ -2557,35 +2668,6 @@ impl Render for TrawlerApp { .children(entries) }); - let similar_panel = (!self.similar.is_empty()).then(|| { - let entries = self.similar.iter().enumerate().map(|(ix, hit)| { - let snippet = render_snippet(&hit.snippet_html); - let hit = hit.clone(); - div() - .id(("similar", ix)) - .cursor_pointer() - .px_2() - .py_1() - .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { - this.navigate_to_search_hit(hit.clone(), window, cx); - })) - .child(snippet) - }); - div() - .flex() - .flex_col() - .border_t_1() - .border_color(rgb(0x3a3a3a)) - .child( - div() - .px_2() - .py_1() - .font_weight(FontWeight::BOLD) - .child("Similar"), - ) - .children(entries) - }); - let quick_open_overlay = self.quick_open.as_ref().map(|state| { let items = state .results @@ -2698,48 +2780,61 @@ impl Render for TrawlerApp { .into_any_element() }); - let calendar_overlay = self - .calendar_open - .as_ref() - .map(|state| render_calendar(state, &Outline::new(self.storage.doc()), cx)); - - // An invisible click target covering everything *below* the - // header (lower deferred priority, so the calendar panel itself - // still receives clicks on its own days/prev/next) — without it, - // the calendar had no way to dismiss itself short of clicking the - // day you actually wanted. - // - // Deliberately starts at `HEADER_HEIGHT`, not the window's top - // edge: this backdrop is deferred, so it paints (and hit-tests) - // above the ordinary "Calendar" button underneath it. A - // full-window backdrop meant *every* click on the button while the - // calendar was open — including the one meant to close it — hit - // the backdrop instead of the button, so the button's own toggle - // handler never ran and the calendar could only ever be closed by - // the backdrop, never reopened by clicking the button again while - // already-closed-this-same-click. Excluding the header entirely - // leaves the button (and its `toggle_calendar` handler) as the - // sole authority over the calendar's open state; the backdrop - // only ever handles genuine clicks elsewhere. - let calendar_backdrop = self.calendar_open.is_some().then(|| { - let size = window.viewport_size(); - let header_height = px(HEADER_HEIGHT); - deferred( - gpui::anchored() - .position(point(px(0.0), header_height)) - .child( - div() - .id("calendar-backdrop") - .w(size.width) - .h(size.height - header_height) - .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { - this.calendar_open = None; - cx.notify(); - })), - ), - ) - .with_priority(1) - .into_any_element() + // Sidebar (openspec change ui-polish, design D3): a right-hand + // host for the registered panels, collapsible via the titlebar + // toggle / ctrl-shift-b, resizable by dragging its left edge. The + // handle only *starts* a resize; the root element's mouse-move/up + // listeners below carry it, so the drag survives the cursor + // leaving the 4px strip. + let sidebar_handle = self.sidebar_open.then(|| { + div() + .id("sidebar-resize-handle") + .w(px(4.0)) + .h_full() + .flex_none() + .cursor_col_resize() + .hover(|d| d.bg(rgb(0x3a3a5a))) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + this.sidebar_resizing = true; + cx.notify(); + }), + ) + }); + let sidebar_panel = self.sidebar_open.then(|| { + let outline = Outline::new(self.storage.doc()); + div() + .id("sidebar") + .w(px(self.sidebar_width)) + .h_full() + .flex_none() + .border_l_1() + .border_color(rgb(0x3a3a3a)) + .flex() + .flex_col() + .overflow_hidden() + .children(SIDEBAR_PANELS.iter().map(|panel| { + let body = match panel { + SidebarPanel::Calendar => render_calendar(&self.calendar, &outline, cx), + SidebarPanel::Similar => self.render_similar_panel(cx), + }; + div() + .flex() + .flex_col() + .border_b_1() + .border_color(rgb(0x3a3a3a)) + .child( + div() + .px_2() + .py_1() + .font_weight(FontWeight::BOLD) + .text_size(px(12.0)) + .text_color(rgb(MUTED_COLOR)) + .child(panel.title()), + ) + .child(body) + })) }); div() @@ -2754,10 +2849,31 @@ impl Render for TrawlerApp { .on_action(cx.listener(Self::toggle_search)) .on_action(cx.listener(Self::toggle_query_block)) .on_action(cx.listener(Self::toggle_calendar)) + .on_action(cx.listener(Self::toggle_sidebar)) .on_action(cx.listener(Self::create_page_for_view)) .on_action(cx.listener(Self::zoom_in)) .on_action(cx.listener(Self::zoom_out)) .on_action(cx.listener(Self::toggle_fold_focused)) + // Sidebar edge-drag: the handle only sets `sidebar_resizing`; + // motion and release are consumed here at the root so the drag + // tracks the cursor anywhere in the window. + .on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, window, cx| { + if this.sidebar_resizing { + let viewport_width = f32::from(window.viewport_size().width); + let width = viewport_width - f32::from(event.position.x); + this.sidebar_width = width.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH); + cx.notify(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _event, _window, cx| { + if this.sidebar_resizing { + this.sidebar_resizing = false; + cx.notify(); + } + }), + ) .bg(rgb(0x1e1e1e)) .text_color(rgb(BODY_TEXT_COLOR)) .line_height(relative(LINE_HEIGHT)) @@ -2811,38 +2927,21 @@ impl Render for TrawlerApp { })), ) .child( - // The popup is a *sibling* of the button, not a - // child nested inside its padding — nesting it - // inside meant its unset (`anchored`-default) - // static position was computed relative to the - // button's padded content box (i.e. relative to - // the "Calendar" text itself), landing it under - // the text rather than the button's outer edge. - // This unpadded wrapper's own edge is the - // button's outer edge, so the popup — the next - // thing after the button in this wrapper's flow — - // lines up with the button itself. div() - .child( - div() - .id("open-calendar") - .cursor_pointer() - .px_2() - .rounded_sm() - .hover(|d| d.bg(rgb(0x2a2a3a))) - .flex() - .flex_row() - .items_center() - .gap_1() - .child(assets::icon("icons/calendar.svg")) - .child("Calendar") - .on_click(cx.listener( - |this, _event: &ClickEvent, window, cx| { - this.toggle_calendar(&ToggleCalendar, window, cx); - }, - )), - ) - .children(calendar_overlay), + .id("open-calendar") + .cursor_pointer() + .px_2() + .rounded_sm() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .flex() + .flex_row() + .items_center() + .gap_1() + .child(assets::icon("icons/calendar.svg")) + .child("Calendar") + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { + this.toggle_calendar(&ToggleCalendar, window, cx); + })), ) .child( div() @@ -2859,6 +2958,26 @@ impl Render for TrawlerApp { row_count )), ) + .child( + // Sidebar toggle (spec: app-chrome/"Collapsible + // right sidebar"): the icon reflects current state — + // collapse variant while open, expand while closed. + div() + .id("sidebar-toggle") + .cursor_pointer() + .px_1() + .py_0p5() + .rounded_sm() + .hover(|d| d.bg(rgb(0x2a2a3a))) + .child(assets::icon(if self.sidebar_open { + "icons/layout-sidebar-right-collapse.svg" + } else { + "icons/layout-sidebar-right-expand.svg" + })) + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { + this.toggle_sidebar(&ToggleSidebar, window, cx); + })), + ) .child( caption_button("caption-min", "icons/minus.svg", WindowControlArea::Min) .hover(|d| d.bg(rgb(0x2a2a3a))) @@ -2894,8 +3013,23 @@ impl Render for TrawlerApp { .children(view_header) .children(breadcrumb_bar) .child( - div().flex_1().child( - list( + // Main content row: the outline column (with its bottom + // backlinks dock) fills the remaining width; the sidebar, + // when open, sits to its right behind a drag handle. + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .child( + div().flex_1().min_h_0().child( + list( self.list_state.clone(), cx.processor(move |this, ix: usize, window, cx| { // One text line at the inherited style — the @@ -3109,15 +3243,17 @@ impl Render for TrawlerApp { ) .into_any_element() }), + ) + .h_full(), + ), + ) + .children(backlinks_panel), ) - .h_full(), - ), + .children(sidebar_handle) + .children(sidebar_panel), ) - .children(backlinks_panel) - .children(similar_panel) .children(quick_open_overlay) .children(search_overlay) - .children(calendar_backdrop) } } diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index 9c71699..31e2c2e 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; +use chrono::Datelike; use gpui::{Entity, TestAppContext, VisualTestContext}; use loro::TreeID; use trawler_core::outline::Outline; @@ -419,3 +420,98 @@ fn hash_opens_tag_completion(cx: &mut TestAppContext) { "fixture tag should be offered: {candidates:?}" ); } + +// --- sidebar (openspec change ui-polish, capability app-chrome) ----------- + +/// spec scenario: "Toggle from the keyboard" — collapsing and reopening +/// preserves panel state (the calendar's shifted month) and editor focus. +#[gpui::test] +async fn sidebar_toggle_preserves_panel_state_and_focus(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("sidebar-toggle", cx); + cx.run_until_parked(); + + let before_focus = focused_block(&app, cx); + let shifted = app.update(cx, |app, cx| { + assert!(app.sidebar_open, "sidebar starts open"); + app.shift_calendar_month(1, cx); + app.calendar.month + }); + + cx.simulate_keystrokes("ctrl-shift-b"); + app.update(cx, |app, _| { + assert!(!app.sidebar_open, "first toggle collapses") + }); + cx.simulate_keystrokes("ctrl-shift-b"); + app.update(cx, |app, _| { + assert!(app.sidebar_open, "second toggle reopens"); + assert_eq!( + app.calendar.month, shifted, + "panel state must survive collapse/expand" + ); + }); + assert_eq!( + focused_block(&app, cx), + before_focus, + "toggling the sidebar must not move editor focus" + ); +} + +/// spec scenario: "Calendar invocation targets the sidebar" — with the +/// sidebar collapsed, ctrl-shift-c opens it (revealing the calendar at the +/// current month); with it open, the old popup toggle rhythm closes it. +#[gpui::test] +async fn calendar_invocation_reveals_sidebar(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("calendar-reveals-sidebar", cx); + cx.run_until_parked(); + + cx.simulate_keystrokes("ctrl-shift-b"); + app.update(cx, |app, cx| { + assert!(!app.sidebar_open); + // Wander the calendar off the current month while hidden. + app.shift_calendar_month(-3, cx); + }); + + cx.simulate_keystrokes("ctrl-shift-c"); + let today = chrono::Local::now().date_naive(); + app.update(cx, |app, _| { + assert!(app.sidebar_open, "calendar invocation opens the sidebar"); + assert_eq!( + (app.calendar.month.year(), app.calendar.month.month()), + (today.year(), today.month()), + "invoking the calendar resets it to the current month" + ); + }); + + cx.simulate_keystrokes("ctrl-shift-c"); + app.update(cx, |app, _| { + assert!(!app.sidebar_open, "second invocation toggles closed"); + }); +} + +/// The registered panels render in the sidebar in declared order, and the +/// Similar panel's data drives its body (state-level assertion — rendered +/// pixels are checked via dev-loop screenshots, not here). +#[gpui::test] +async fn sidebar_hosts_calendar_and_similar_panels(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("sidebar-panels", cx); + cx.run_until_parked(); + + assert_eq!( + crate::SIDEBAR_PANELS + .iter() + .map(|p| p.title()) + .collect::>(), + vec!["Calendar", "Similar"], + ); + + // Focusing a block with distinctive shared vocabulary populates the + // Similar panel's backing data. + let subject = block_by_content(&app, cx, "Outlined the fixture graph #project"); + focus_block(&app, cx, subject); + app.update(cx, |app, _| { + assert!( + !app.similar.is_empty(), + "focusing a fixture block with shared terms yields similar hits" + ); + }); +} diff --git a/openspec/changes/ui-polish/specs/dev-automation-server/spec.md b/openspec/changes/ui-polish/specs/dev-automation-server/spec.md new file mode 100644 index 0000000..aecdae9 --- /dev/null +++ b/openspec/changes/ui-polish/specs/dev-automation-server/spec.md @@ -0,0 +1,18 @@ +# dev-automation-server Delta: ui-polish + +## MODIFIED Requirements + +### Requirement: Structured state dump +A `dump` command SHALL return a versioned JSON document (top-level `"v":1`) of semantic UI state derived from entity state, including at minimum: the current view (journal date, page name, or search), the visible outline as a nested block list (id, content, depth, collapsed), the focused block id, cursor offset and selection, any open popup with its contents (completion candidates, quick-open items), the sidebar's state (open flag, width, panel titles in display order, and the calendar's displayed month when the sidebar is open), and the window bounds with scale factor. + +#### Scenario: Dump reflects a completion popup +- **WHEN** reference completion is open with candidates and a client sends `{"cmd":"dump"}` +- **THEN** the response includes the open popup type and its candidate list, the focused block id, and the cursor position within it + +#### Scenario: Dump reflects sidebar state +- **WHEN** the sidebar is open and a client sends `{"cmd":"dump"}` +- **THEN** the response includes the sidebar's open flag, width, panel titles, and the calendar's displayed month; collapsing the sidebar and dumping again reflects the closed state + +#### Scenario: Bounds available without a screenshot +- **WHEN** a client sends `{"cmd":"bounds"}` (or reads bounds from a `dump`) +- **THEN** the response contains the window's current position, size, and scale factor as reported by gpui diff --git a/openspec/changes/ui-polish/tasks.md b/openspec/changes/ui-polish/tasks.md index e379302..c111cec 100644 --- a/openspec/changes/ui-polish/tasks.md +++ b/openspec/changes/ui-polish/tasks.md @@ -10,12 +10,12 @@ ## 3. Sidebar (design D3) -- [ ] 3.1 Sidebar host: collapsed flag, clamped drag-resize width, ordered panel registrations (title + render fn); session-persistent state; collapsing never discards panel state -- [ ] 3.2 Move the calendar picker into a sidebar panel; calendar invocation (shortcut/button) opens the sidebar and reveals it; day-click behavior unchanged -- [ ] 3.3 Move the Similar panel from the bottom dock into the sidebar; remove the bottom-dock rendering; Backlinks stays put -- [ ] 3.4 Dedicated sidebar-toggle Tabler icon button in the titlebar/header row, icon swapping between collapse/expand variants to reflect current state, plus the toggle keybinding (check keymap for conflicts; design suggests Ctrl+Shift+B) -- [ ] 3.5 Extend the devtools `dump` with sidebar state (open/width/panels) — additive field -- [ ] 3.6 UI tests: toggle preserves panel state and editor focus; toggle-button icon state matches sidebar state; calendar invocation reveals panel; similar panel renders in sidebar +- [x] 3.1 Sidebar host: collapsed flag, clamped drag-resize width (200–480px; handle starts the drag, root mouse-move/up listeners carry it so it survives leaving the 4px strip); ordered panels via the `SIDEBAR_PANELS` enum registry (enum dispatch instead of stored render closures — borrow-checker-idiomatic equivalent of design D3's title+render-fn registrations); collapse never discards panel state (it lives on the entity) +- [x] 3.2 Calendar moved into a sidebar panel (popup `deferred`/`anchored`/backdrop machinery deleted); ctrl-shift-c opens the sidebar resetting to the current month, toggles closed when open; day-click navigation unchanged, sidebar stays open. Fixed in review: day grid rebuilt as explicit seven-cell week rows — the `flex_wrap` + `relative(1/7)` grid reflowed to six misaligned columns at some drag widths (sub-pixel rounding); extra breathing room added below the month header (`mb_1`) +- [x] 3.3 Similar panel moved from the bottom dock into the sidebar (dock removed, Backlinks stays put); restyled in review — outline-style 6px muted bullets, 12px dimmed text, empty state — which also resolved snippet clipping at panel width +- [x] 3.4 Sidebar-toggle icon button in the titlebar (collapse/expand Tabler variants reflect state) + ctrl-shift-b binding (no keymap conflicts) +- [x] 3.5 Devtools `dump` gained `sidebar: {open, width, panels}`; `calendar_month` now populated when the sidebar is open (spec delta for dev-automation-server records the popup→sidebar move) +- [x] 3.6 UI tests: toggle preserves calendar month + editor focus; calendar invocation reveals sidebar (and resets month, toggles closed); panel registry order + Similar data populates on focus (icon state is covered by the sidebar_open assertions the icon derives from; pixels verified via dev-loop screenshots) ## 4. Page title as heading (design D6)