Personal outliner built with Rust.
4

Configure Feed

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

ui-polish group 3: collapsible resizable right sidebar hosting Calendar and Similar panels — calendar popup machinery removed, day grid rebuilt as fixed week rows (flex_wrap reflow fix), Similar restyled with outline-style bullets and dim small text, titlebar toggle with state-reflecting icon, ctrl-shift-b, dump sidebar field, three UI tests

graham.systems (Jul 15, 2026, 4:23 PM -0700) c6da35c3 bb3814ad

+485 -213
+26 -4
crates/trawler/src/devtools.rs
··· 308 308 focused: Option<FocusedDump>, 309 309 quick_open: Option<QuickOpenDump>, 310 310 search: Option<SearchDump>, 311 - /// First day of the month the calendar picker is showing, if open. 311 + /// First day of the month the calendar picker is showing, if visible. 312 + /// The calendar is a sidebar panel (openspec change ui-polish), so 313 + /// this is populated exactly when the sidebar is open. 312 314 calendar_month: Option<String>, 315 + /// The right-hand sidebar's state (openspec change ui-polish, 316 + /// tasks 3.5 — additive field). 317 + sidebar: SidebarDump, 313 318 bounds: BoundsDump, 319 + } 320 + 321 + #[derive(Serialize)] 322 + struct SidebarDump { 323 + open: bool, 324 + width: f32, 325 + /// Panel titles in display order. 326 + panels: Vec<String>, 314 327 } 315 328 316 329 #[derive(Serialize)] ··· 433 446 }); 434 447 435 448 let calendar_month = app 436 - .calendar_open 437 - .as_ref() 438 - .map(|state| state.month.format("%Y-%m-%d").to_string()); 449 + .sidebar_open 450 + .then(|| app.calendar.month.format("%Y-%m-%d").to_string()); 451 + 452 + let sidebar = SidebarDump { 453 + open: app.sidebar_open, 454 + width: app.sidebar_width, 455 + panels: crate::SIDEBAR_PANELS 456 + .iter() 457 + .map(|p| p.title().to_string()) 458 + .collect(), 459 + }; 439 460 440 461 Dump { 441 462 v: DUMP_VERSION, ··· 445 466 quick_open, 446 467 search, 447 468 calendar_month, 469 + sidebar, 448 470 bounds: bounds_dump(window), 449 471 } 450 472 }
+339 -203
crates/trawler/src/main.rs
··· 75 75 SearchOpen, 76 76 ToggleQueryBlock, 77 77 ToggleCalendar, 78 - ToggleFold 78 + ToggleFold, 79 + ToggleSidebar 79 80 ] 80 81 ); 81 82 ··· 145 146 // toggling it doesn't belong in the editor's own key table. 146 147 KeyBinding::new("ctrl-shift-q", ToggleQueryBlock, Some(APP_CONTEXT)), 147 148 KeyBinding::new("ctrl-shift-c", ToggleCalendar, Some(APP_CONTEXT)), 149 + KeyBinding::new("ctrl-shift-b", ToggleSidebar, Some(APP_CONTEXT)), 148 150 ]); 149 151 } 150 152 ··· 222 224 month: NaiveDate, 223 225 } 224 226 227 + /// Sidebar layout knobs (openspec change ui-polish, design D3). 228 + const SIDEBAR_DEFAULT_WIDTH: f32 = 280.0; 229 + const SIDEBAR_MIN_WIDTH: f32 = 200.0; 230 + const SIDEBAR_MAX_WIDTH: f32 = 480.0; 231 + 232 + /// The registered sidebar panels, in display order. Adding a panel is a 233 + /// variant here plus a render arm in the sidebar construction — the 234 + /// data-driven equivalent of design D3's "ordered panel registrations" 235 + /// (stored render closures over `&mut TrawlerApp` don't survive the borrow 236 + /// checker; enum dispatch is the Rust-idiomatic same thing). 237 + #[derive(Clone, Copy, PartialEq)] 238 + enum SidebarPanel { 239 + Calendar, 240 + Similar, 241 + } 242 + 243 + impl SidebarPanel { 244 + fn title(self) -> &'static str { 245 + match self { 246 + SidebarPanel::Calendar => "Calendar", 247 + SidebarPanel::Similar => "Similar", 248 + } 249 + } 250 + } 251 + 252 + const SIDEBAR_PANELS: &[SidebarPanel] = &[SidebarPanel::Calendar, SidebarPanel::Similar]; 253 + 225 254 struct TrawlerApp { 226 255 graph_dir: PathBuf, 227 256 /// Shared with the app-level quit hook (see `TrawlerApp::new`): on ··· 285 314 /// arbitrary day's journal page by clicking a date — quicker than 286 315 /// hunting for a `[[date]]` reference to follow, and doubles as a 287 316 /// manual way to reach today's page if the automatic rollover timer 288 - /// ever misses it. 289 - calendar_open: Option<CalendarState>, 317 + /// ever misses it. Lives permanently in the sidebar (openspec change 318 + /// ui-polish, design D3) — its month state persists across sidebar 319 + /// collapse/expand. 320 + calendar: CalendarState, 321 + /// Whether the right-hand sidebar (Calendar + Similar panels) is shown. 322 + sidebar_open: bool, 323 + /// Current sidebar width, user-resizable by dragging its left edge 324 + /// within [`SIDEBAR_MIN_WIDTH`]..=[`SIDEBAR_MAX_WIDTH`]. 325 + sidebar_width: f32, 326 + /// True while a sidebar edge-drag is in progress: the root element's 327 + /// mouse-move/up listeners consume motion into `sidebar_width` until 328 + /// release, so the drag keeps working even when the cursor leaves the 329 + /// 4px handle. 330 + sidebar_resizing: bool, 290 331 /// Blocks lexically similar to the currently focused block (spec: 291 332 /// "Similar blocks (lexical)"), refreshed on every focus change. 292 333 /// Shown as a dedicated panel rather than an inline footer on the ··· 397 438 search, 398 439 search_open: None, 399 440 search_focus: cx.focus_handle(), 400 - calendar_open: None, 441 + calendar: CalendarState { 442 + month: NaiveDate::from_ymd_opt(today.year(), today.month(), 1) 443 + .expect("today's year/month is always a valid date"), 444 + }, 445 + sidebar_open: true, 446 + sidebar_width: SIDEBAR_DEFAULT_WIDTH, 447 + sidebar_resizing: false, 401 448 similar: Vec::new(), 402 449 query_results: HashMap::new(), 403 450 query_pending: HashSet::new(), ··· 871 918 self.navigate_to(View::Node(NodeId::tree(page)), Some(hit.block), window, cx); 872 919 } 873 920 874 - /// Ctrl+Shift+C / the "Calendar" button: open (or close, if already 875 - /// open) the calendar picker, defaulting to the current month. 921 + /// Ctrl+Shift+B / the titlebar toggle button: collapse or expand the 922 + /// sidebar. Panel state (e.g. the calendar's displayed month) lives on 923 + /// `self`, so collapsing never discards it (spec: app-chrome/ 924 + /// "Collapsible right sidebar hosting panels"). 925 + fn toggle_sidebar(&mut self, _: &ToggleSidebar, _window: &mut Window, cx: &mut Context<Self>) { 926 + self.sidebar_open = !self.sidebar_open; 927 + cx.notify(); 928 + } 929 + 930 + /// Ctrl+Shift+C / the "Calendar" button: reveal the calendar — the 931 + /// calendar lives in the sidebar now, so invoking it opens the sidebar 932 + /// (resetting the view to the current month for the muscle-memory 933 + /// "jump to today" use); if the sidebar is already open, this toggles 934 + /// it closed, preserving the old open/close rhythm of the popup. 876 935 fn toggle_calendar( 877 936 &mut self, 878 937 _: &ToggleCalendar, 879 938 _window: &mut Window, 880 939 cx: &mut Context<Self>, 881 940 ) { 882 - if self.calendar_open.is_some() { 883 - self.calendar_open = None; 941 + if self.sidebar_open { 942 + self.sidebar_open = false; 884 943 } else { 885 944 let today = chrono::Local::now().date_naive(); 886 - self.calendar_open = Some(CalendarState { 887 - month: NaiveDate::from_ymd_opt(today.year(), today.month(), 1) 888 - .expect("today's year/month is always a valid date"), 889 - }); 945 + self.calendar.month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1) 946 + .expect("today's year/month is always a valid date"); 947 + self.sidebar_open = true; 890 948 } 891 949 cx.notify(); 892 950 } 893 951 894 952 /// Move the calendar's displayed month by `delta` (±1). 895 953 fn shift_calendar_month(&mut self, delta: i32, cx: &mut Context<Self>) { 896 - let Some(state) = self.calendar_open.as_mut() else { 897 - return; 898 - }; 899 - let total_months = state.month.year() * 12 + state.month.month0() as i32 + delta; 954 + let month = self.calendar.month; 955 + let total_months = month.year() * 12 + month.month0() as i32 + delta; 900 956 let (year, month0) = (total_months.div_euclid(12), total_months.rem_euclid(12)); 901 - state.month = NaiveDate::from_ymd_opt(year, month0 as u32 + 1, 1) 957 + self.calendar.month = NaiveDate::from_ymd_opt(year, month0 as u32 + 1, 1) 902 958 .expect("computed year/month is always in range"); 903 959 cx.notify(); 904 960 } ··· 915 971 // Rare event (a deliberate date jump, not per-keystroke) — a full 916 972 // rebuild is simpler than precisely tracking what changed. 917 973 let _ = self.search.rebuild(self.storage.doc()); 918 - self.calendar_open = None; 974 + // The sidebar stays open: the calendar is a persistent panel now, 975 + // not a popup to dismiss after each use. 919 976 self.navigate_to(View::Node(NodeId::tree(page)), None, window, cx); 920 977 } 921 978 ··· 1095 1152 .unwrap_or_default(); 1096 1153 } 1097 1154 1155 + /// The Similar panel's body (sidebar-hosted — openspec change 1156 + /// ui-polish, design D3; previously a bottom dock). An explicit empty 1157 + /// state keeps the panel discoverable when nothing is focused or 1158 + /// nothing matches. 1159 + fn render_similar_panel(&self, cx: &mut Context<Self>) -> gpui::AnyElement { 1160 + if self.similar.is_empty() { 1161 + return div() 1162 + .px_2() 1163 + .py_1() 1164 + .text_size(px(12.0)) 1165 + .text_color(rgb(MUTED_COLOR)) 1166 + .child("Nothing similar to the focused block yet.") 1167 + .into_any_element(); 1168 + } 1169 + let entries = self.similar.iter().enumerate().map(|(ix, hit)| { 1170 + let snippet = render_snippet(&hit.snippet_html); 1171 + let hit = hit.clone(); 1172 + div() 1173 + .id(("similar", ix)) 1174 + .cursor_pointer() 1175 + .px_2() 1176 + .py_0p5() 1177 + .flex() 1178 + .flex_row() 1179 + .items_start() 1180 + .gap_2() 1181 + .hover(|d| d.bg(rgb(0x2a2a3a))) 1182 + .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 1183 + this.navigate_to_search_hit(hit.clone(), window, cx); 1184 + })) 1185 + // The same 6px dot the outline rows use, nudged down to sit 1186 + // on the smaller first text line's optical center. 1187 + .child( 1188 + div() 1189 + .flex_none() 1190 + .mt(px(6.0)) 1191 + .size(px(6.0)) 1192 + .rounded_full() 1193 + .bg(rgb(MUTED_COLOR)), 1194 + ) 1195 + .child( 1196 + div() 1197 + .flex_1() 1198 + .min_w_0() 1199 + .text_size(px(12.0)) 1200 + .text_color(rgb(MUTED_COLOR)) 1201 + .child(snippet), 1202 + ) 1203 + }); 1204 + div() 1205 + .flex() 1206 + .flex_col() 1207 + .py_0p5() 1208 + .children(entries) 1209 + .into_any_element() 1210 + } 1211 + 1098 1212 /// Ctrl+Shift+Q: flip whether the focused block is a query block (spec: 1099 1213 /// steel-queries/"Query blocks live in the outline"). A no-op if 1100 1214 /// nothing is focused. ··· 1769 1883 /// line box's geometric center. Tuned by eye; zero at the current line 1770 1884 /// height. 1771 1885 const BULLET_OPTICAL_NUDGE: f32 = 0.0; 1772 - /// Approximate height of the header bar (`.p_2()` padding plus one line of 1773 - /// text) — generous on purpose. Used to keep the calendar's click-away 1774 - /// backdrop out of the header entirely; see `calendar_backdrop`'s doc for 1775 - /// why. Erring high just shrinks the backdrop's top edge slightly (a 1776 - /// harmless sliver near the header boundary that won't dismiss on click); 1777 - /// erring low would let the backdrop reach back into the header and 1778 - /// reintroduce the bug this exists to avoid. 1779 - const HEADER_HEIGHT: f32 = 48.0; 1780 1886 /// A query block's own row gets this background tint — without it a query 1781 1887 /// block was indistinguishable from an ordinary one. Deliberately applied 1782 1888 /// only to the row, not the result section below it too: tinting both ··· 2056 2162 let mut cells: Vec<_> = (0..leading_blanks).map(|_| day_cell(None)).collect(); 2057 2163 cells.extend((1..=days_in_month as u32).map(|day| day_cell(Some(day)))); 2058 2164 2059 - // No explicit `.position(...)`: nested as a child of the "Calendar" 2060 - // button itself (see its `.children(...)` in the header), `anchored` 2061 - // then uses that button's own layout position — i.e. it renders right 2062 - // under the button that opened it — instead of a hardcoded point that 2063 - // has no relationship to where the button actually is. 2064 - deferred( 2065 - gpui::anchored().child( 2165 + // Explicit rows of seven, NOT a flex_wrap grid: percentage-width cells 2166 + // in a wrapping row can overflow the line by a sub-pixel rounding 2167 + // error at some parent widths, wrapping the seventh cell and reflowing 2168 + // the whole month to six misaligned columns (seen live while 2169 + // drag-resizing the sidebar). Fixed-size rows can't reflow. 2170 + let mut weeks = Vec::new(); 2171 + let mut cells = cells.into_iter(); 2172 + loop { 2173 + let week: Vec<_> = cells.by_ref().take(7).collect(); 2174 + if week.is_empty() { 2175 + break; 2176 + } 2177 + weeks.push(div().flex().flex_row().children(week)); 2178 + } 2179 + 2180 + // A sidebar panel body (openspec change ui-polish, design D3), not a 2181 + // popup: fills the sidebar's width, no anchoring or overlay chrome. 2182 + div() 2183 + .id("calendar") 2184 + .flex() 2185 + .flex_col() 2186 + .gap_1() 2187 + .p_2() 2188 + .child( 2066 2189 div() 2067 - .id("calendar") 2068 - .w(px(280.0)) 2069 - .bg(rgb(0x252525)) 2070 - .border_1() 2071 - .border_color(rgb(0x3a3a3a)) 2072 - .rounded_md() 2073 - .shadow_lg() 2074 2190 .flex() 2075 - .flex_col() 2076 - .gap_1() 2077 - .p_2() 2191 + .flex_row() 2192 + .items_center() 2193 + .justify_between() 2194 + // Extra breathing room below the month header specifically 2195 + // (stacks with the container's gap_1) — the week grid below 2196 + // keeps the tighter gap_1 rhythm. 2197 + .mb_1() 2078 2198 .child( 2079 2199 div() 2080 - .flex() 2081 - .flex_row() 2082 - .items_center() 2083 - .justify_between() 2084 - .child( 2085 - div() 2086 - .id("calendar-prev") 2087 - .cursor_pointer() 2088 - .px_1() 2089 - .py_0p5() 2090 - .rounded_sm() 2091 - .hover(|d| d.bg(rgb(0x2a2a3a))) 2092 - .child(assets::icon("icons/chevron-left.svg")) 2093 - .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 2094 - this.shift_calendar_month(-1, cx); 2095 - })), 2096 - ) 2097 - .child( 2098 - div() 2099 - .font_weight(FontWeight::BOLD) 2100 - .child(month.format("%B %Y").to_string()), 2101 - ) 2102 - .child( 2103 - div() 2104 - .id("calendar-next") 2105 - .cursor_pointer() 2106 - .px_1() 2107 - .py_0p5() 2108 - .rounded_sm() 2109 - .hover(|d| d.bg(rgb(0x2a2a3a))) 2110 - .child(assets::icon("icons/chevron-right.svg")) 2111 - .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 2112 - this.shift_calendar_month(1, cx); 2113 - })), 2114 - ), 2200 + .id("calendar-prev") 2201 + .cursor_pointer() 2202 + .px_1() 2203 + .py_0p5() 2204 + .rounded_sm() 2205 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2206 + .child(assets::icon("icons/chevron-left.svg")) 2207 + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 2208 + this.shift_calendar_month(-1, cx); 2209 + })), 2115 2210 ) 2116 - .child(div().flex().flex_row().children( 2117 - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].iter().map(|d| { 2118 - div() 2119 - .w(relative(1.0 / 7.0)) 2120 - .flex() 2121 - .justify_center() 2122 - .text_size(px(11.0)) 2123 - .text_color(rgb(MUTED_COLOR)) 2124 - .child(*d) 2125 - }), 2126 - )) 2127 - .child(div().flex().flex_row().flex_wrap().children(cells)), 2128 - ), 2129 - ) 2130 - .with_priority(2) 2131 - .into_any_element() 2211 + .child( 2212 + div() 2213 + .font_weight(FontWeight::BOLD) 2214 + .child(month.format("%B %Y").to_string()), 2215 + ) 2216 + .child( 2217 + div() 2218 + .id("calendar-next") 2219 + .cursor_pointer() 2220 + .px_1() 2221 + .py_0p5() 2222 + .rounded_sm() 2223 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2224 + .child(assets::icon("icons/chevron-right.svg")) 2225 + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 2226 + this.shift_calendar_month(1, cx); 2227 + })), 2228 + ), 2229 + ) 2230 + .child(div().flex().flex_row().children( 2231 + ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].iter().map(|d| { 2232 + div() 2233 + .w(relative(1.0 / 7.0)) 2234 + .flex() 2235 + .justify_center() 2236 + .text_size(px(11.0)) 2237 + .text_color(rgb(MUTED_COLOR)) 2238 + .child(*d) 2239 + }), 2240 + )) 2241 + .children(weeks) 2242 + .into_any_element() 2132 2243 } 2133 2244 2134 2245 fn render_snippet(html: &str) -> gpui::AnyElement { ··· 2557 2668 .children(entries) 2558 2669 }); 2559 2670 2560 - let similar_panel = (!self.similar.is_empty()).then(|| { 2561 - let entries = self.similar.iter().enumerate().map(|(ix, hit)| { 2562 - let snippet = render_snippet(&hit.snippet_html); 2563 - let hit = hit.clone(); 2564 - div() 2565 - .id(("similar", ix)) 2566 - .cursor_pointer() 2567 - .px_2() 2568 - .py_1() 2569 - .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { 2570 - this.navigate_to_search_hit(hit.clone(), window, cx); 2571 - })) 2572 - .child(snippet) 2573 - }); 2574 - div() 2575 - .flex() 2576 - .flex_col() 2577 - .border_t_1() 2578 - .border_color(rgb(0x3a3a3a)) 2579 - .child( 2580 - div() 2581 - .px_2() 2582 - .py_1() 2583 - .font_weight(FontWeight::BOLD) 2584 - .child("Similar"), 2585 - ) 2586 - .children(entries) 2587 - }); 2588 - 2589 2671 let quick_open_overlay = self.quick_open.as_ref().map(|state| { 2590 2672 let items = state 2591 2673 .results ··· 2698 2780 .into_any_element() 2699 2781 }); 2700 2782 2701 - let calendar_overlay = self 2702 - .calendar_open 2703 - .as_ref() 2704 - .map(|state| render_calendar(state, &Outline::new(self.storage.doc()), cx)); 2705 - 2706 - // An invisible click target covering everything *below* the 2707 - // header (lower deferred priority, so the calendar panel itself 2708 - // still receives clicks on its own days/prev/next) — without it, 2709 - // the calendar had no way to dismiss itself short of clicking the 2710 - // day you actually wanted. 2711 - // 2712 - // Deliberately starts at `HEADER_HEIGHT`, not the window's top 2713 - // edge: this backdrop is deferred, so it paints (and hit-tests) 2714 - // above the ordinary "Calendar" button underneath it. A 2715 - // full-window backdrop meant *every* click on the button while the 2716 - // calendar was open — including the one meant to close it — hit 2717 - // the backdrop instead of the button, so the button's own toggle 2718 - // handler never ran and the calendar could only ever be closed by 2719 - // the backdrop, never reopened by clicking the button again while 2720 - // already-closed-this-same-click. Excluding the header entirely 2721 - // leaves the button (and its `toggle_calendar` handler) as the 2722 - // sole authority over the calendar's open state; the backdrop 2723 - // only ever handles genuine clicks elsewhere. 2724 - let calendar_backdrop = self.calendar_open.is_some().then(|| { 2725 - let size = window.viewport_size(); 2726 - let header_height = px(HEADER_HEIGHT); 2727 - deferred( 2728 - gpui::anchored() 2729 - .position(point(px(0.0), header_height)) 2730 - .child( 2731 - div() 2732 - .id("calendar-backdrop") 2733 - .w(size.width) 2734 - .h(size.height - header_height) 2735 - .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { 2736 - this.calendar_open = None; 2737 - cx.notify(); 2738 - })), 2739 - ), 2740 - ) 2741 - .with_priority(1) 2742 - .into_any_element() 2783 + // Sidebar (openspec change ui-polish, design D3): a right-hand 2784 + // host for the registered panels, collapsible via the titlebar 2785 + // toggle / ctrl-shift-b, resizable by dragging its left edge. The 2786 + // handle only *starts* a resize; the root element's mouse-move/up 2787 + // listeners below carry it, so the drag survives the cursor 2788 + // leaving the 4px strip. 2789 + let sidebar_handle = self.sidebar_open.then(|| { 2790 + div() 2791 + .id("sidebar-resize-handle") 2792 + .w(px(4.0)) 2793 + .h_full() 2794 + .flex_none() 2795 + .cursor_col_resize() 2796 + .hover(|d| d.bg(rgb(0x3a3a5a))) 2797 + .on_mouse_down( 2798 + MouseButton::Left, 2799 + cx.listener(|this, _event, _window, cx| { 2800 + this.sidebar_resizing = true; 2801 + cx.notify(); 2802 + }), 2803 + ) 2804 + }); 2805 + let sidebar_panel = self.sidebar_open.then(|| { 2806 + let outline = Outline::new(self.storage.doc()); 2807 + div() 2808 + .id("sidebar") 2809 + .w(px(self.sidebar_width)) 2810 + .h_full() 2811 + .flex_none() 2812 + .border_l_1() 2813 + .border_color(rgb(0x3a3a3a)) 2814 + .flex() 2815 + .flex_col() 2816 + .overflow_hidden() 2817 + .children(SIDEBAR_PANELS.iter().map(|panel| { 2818 + let body = match panel { 2819 + SidebarPanel::Calendar => render_calendar(&self.calendar, &outline, cx), 2820 + SidebarPanel::Similar => self.render_similar_panel(cx), 2821 + }; 2822 + div() 2823 + .flex() 2824 + .flex_col() 2825 + .border_b_1() 2826 + .border_color(rgb(0x3a3a3a)) 2827 + .child( 2828 + div() 2829 + .px_2() 2830 + .py_1() 2831 + .font_weight(FontWeight::BOLD) 2832 + .text_size(px(12.0)) 2833 + .text_color(rgb(MUTED_COLOR)) 2834 + .child(panel.title()), 2835 + ) 2836 + .child(body) 2837 + })) 2743 2838 }); 2744 2839 2745 2840 div() ··· 2754 2849 .on_action(cx.listener(Self::toggle_search)) 2755 2850 .on_action(cx.listener(Self::toggle_query_block)) 2756 2851 .on_action(cx.listener(Self::toggle_calendar)) 2852 + .on_action(cx.listener(Self::toggle_sidebar)) 2757 2853 .on_action(cx.listener(Self::create_page_for_view)) 2758 2854 .on_action(cx.listener(Self::zoom_in)) 2759 2855 .on_action(cx.listener(Self::zoom_out)) 2760 2856 .on_action(cx.listener(Self::toggle_fold_focused)) 2857 + // Sidebar edge-drag: the handle only sets `sidebar_resizing`; 2858 + // motion and release are consumed here at the root so the drag 2859 + // tracks the cursor anywhere in the window. 2860 + .on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, window, cx| { 2861 + if this.sidebar_resizing { 2862 + let viewport_width = f32::from(window.viewport_size().width); 2863 + let width = viewport_width - f32::from(event.position.x); 2864 + this.sidebar_width = width.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH); 2865 + cx.notify(); 2866 + } 2867 + })) 2868 + .on_mouse_up( 2869 + MouseButton::Left, 2870 + cx.listener(|this, _event, _window, cx| { 2871 + if this.sidebar_resizing { 2872 + this.sidebar_resizing = false; 2873 + cx.notify(); 2874 + } 2875 + }), 2876 + ) 2761 2877 .bg(rgb(0x1e1e1e)) 2762 2878 .text_color(rgb(BODY_TEXT_COLOR)) 2763 2879 .line_height(relative(LINE_HEIGHT)) ··· 2811 2927 })), 2812 2928 ) 2813 2929 .child( 2814 - // The popup is a *sibling* of the button, not a 2815 - // child nested inside its padding — nesting it 2816 - // inside meant its unset (`anchored`-default) 2817 - // static position was computed relative to the 2818 - // button's padded content box (i.e. relative to 2819 - // the "Calendar" text itself), landing it under 2820 - // the text rather than the button's outer edge. 2821 - // This unpadded wrapper's own edge is the 2822 - // button's outer edge, so the popup — the next 2823 - // thing after the button in this wrapper's flow — 2824 - // lines up with the button itself. 2825 2930 div() 2826 - .child( 2827 - div() 2828 - .id("open-calendar") 2829 - .cursor_pointer() 2830 - .px_2() 2831 - .rounded_sm() 2832 - .hover(|d| d.bg(rgb(0x2a2a3a))) 2833 - .flex() 2834 - .flex_row() 2835 - .items_center() 2836 - .gap_1() 2837 - .child(assets::icon("icons/calendar.svg")) 2838 - .child("Calendar") 2839 - .on_click(cx.listener( 2840 - |this, _event: &ClickEvent, window, cx| { 2841 - this.toggle_calendar(&ToggleCalendar, window, cx); 2842 - }, 2843 - )), 2844 - ) 2845 - .children(calendar_overlay), 2931 + .id("open-calendar") 2932 + .cursor_pointer() 2933 + .px_2() 2934 + .rounded_sm() 2935 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2936 + .flex() 2937 + .flex_row() 2938 + .items_center() 2939 + .gap_1() 2940 + .child(assets::icon("icons/calendar.svg")) 2941 + .child("Calendar") 2942 + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { 2943 + this.toggle_calendar(&ToggleCalendar, window, cx); 2944 + })), 2846 2945 ) 2847 2946 .child( 2848 2947 div() ··· 2860 2959 )), 2861 2960 ) 2862 2961 .child( 2962 + // Sidebar toggle (spec: app-chrome/"Collapsible 2963 + // right sidebar"): the icon reflects current state — 2964 + // collapse variant while open, expand while closed. 2965 + div() 2966 + .id("sidebar-toggle") 2967 + .cursor_pointer() 2968 + .px_1() 2969 + .py_0p5() 2970 + .rounded_sm() 2971 + .hover(|d| d.bg(rgb(0x2a2a3a))) 2972 + .child(assets::icon(if self.sidebar_open { 2973 + "icons/layout-sidebar-right-collapse.svg" 2974 + } else { 2975 + "icons/layout-sidebar-right-expand.svg" 2976 + })) 2977 + .on_click(cx.listener(|this, _event: &ClickEvent, window, cx| { 2978 + this.toggle_sidebar(&ToggleSidebar, window, cx); 2979 + })), 2980 + ) 2981 + .child( 2863 2982 caption_button("caption-min", "icons/minus.svg", WindowControlArea::Min) 2864 2983 .hover(|d| d.bg(rgb(0x2a2a3a))) 2865 2984 .on_click(cx.listener(|_, _: &ClickEvent, window, _| { ··· 2894 3013 .children(view_header) 2895 3014 .children(breadcrumb_bar) 2896 3015 .child( 2897 - div().flex_1().child( 2898 - list( 3016 + // Main content row: the outline column (with its bottom 3017 + // backlinks dock) fills the remaining width; the sidebar, 3018 + // when open, sits to its right behind a drag handle. 3019 + div() 3020 + .flex() 3021 + .flex_row() 3022 + .flex_1() 3023 + .min_h_0() 3024 + .child( 3025 + div() 3026 + .flex() 3027 + .flex_col() 3028 + .flex_1() 3029 + .min_w_0() 3030 + .child( 3031 + div().flex_1().min_h_0().child( 3032 + list( 2899 3033 self.list_state.clone(), 2900 3034 cx.processor(move |this, ix: usize, window, cx| { 2901 3035 // One text line at the inherited style — the ··· 3109 3243 ) 3110 3244 .into_any_element() 3111 3245 }), 3246 + ) 3247 + .h_full(), 3248 + ), 3249 + ) 3250 + .children(backlinks_panel), 3112 3251 ) 3113 - .h_full(), 3114 - ), 3252 + .children(sidebar_handle) 3253 + .children(sidebar_panel), 3115 3254 ) 3116 - .children(backlinks_panel) 3117 - .children(similar_panel) 3118 3255 .children(quick_open_overlay) 3119 3256 .children(search_overlay) 3120 - .children(calendar_backdrop) 3121 3257 } 3122 3258 } 3123 3259
+96
crates/trawler/src/ui_tests.rs
··· 9 9 10 10 use std::path::PathBuf; 11 11 12 + use chrono::Datelike; 12 13 use gpui::{Entity, TestAppContext, VisualTestContext}; 13 14 use loro::TreeID; 14 15 use trawler_core::outline::Outline; ··· 419 420 "fixture tag should be offered: {candidates:?}" 420 421 ); 421 422 } 423 + 424 + // --- sidebar (openspec change ui-polish, capability app-chrome) ----------- 425 + 426 + /// spec scenario: "Toggle from the keyboard" — collapsing and reopening 427 + /// preserves panel state (the calendar's shifted month) and editor focus. 428 + #[gpui::test] 429 + async fn sidebar_toggle_preserves_panel_state_and_focus(cx: &mut gpui::TestAppContext) { 430 + let (app, cx) = open_app("sidebar-toggle", cx); 431 + cx.run_until_parked(); 432 + 433 + let before_focus = focused_block(&app, cx); 434 + let shifted = app.update(cx, |app, cx| { 435 + assert!(app.sidebar_open, "sidebar starts open"); 436 + app.shift_calendar_month(1, cx); 437 + app.calendar.month 438 + }); 439 + 440 + cx.simulate_keystrokes("ctrl-shift-b"); 441 + app.update(cx, |app, _| { 442 + assert!(!app.sidebar_open, "first toggle collapses") 443 + }); 444 + cx.simulate_keystrokes("ctrl-shift-b"); 445 + app.update(cx, |app, _| { 446 + assert!(app.sidebar_open, "second toggle reopens"); 447 + assert_eq!( 448 + app.calendar.month, shifted, 449 + "panel state must survive collapse/expand" 450 + ); 451 + }); 452 + assert_eq!( 453 + focused_block(&app, cx), 454 + before_focus, 455 + "toggling the sidebar must not move editor focus" 456 + ); 457 + } 458 + 459 + /// spec scenario: "Calendar invocation targets the sidebar" — with the 460 + /// sidebar collapsed, ctrl-shift-c opens it (revealing the calendar at the 461 + /// current month); with it open, the old popup toggle rhythm closes it. 462 + #[gpui::test] 463 + async fn calendar_invocation_reveals_sidebar(cx: &mut gpui::TestAppContext) { 464 + let (app, cx) = open_app("calendar-reveals-sidebar", cx); 465 + cx.run_until_parked(); 466 + 467 + cx.simulate_keystrokes("ctrl-shift-b"); 468 + app.update(cx, |app, cx| { 469 + assert!(!app.sidebar_open); 470 + // Wander the calendar off the current month while hidden. 471 + app.shift_calendar_month(-3, cx); 472 + }); 473 + 474 + cx.simulate_keystrokes("ctrl-shift-c"); 475 + let today = chrono::Local::now().date_naive(); 476 + app.update(cx, |app, _| { 477 + assert!(app.sidebar_open, "calendar invocation opens the sidebar"); 478 + assert_eq!( 479 + (app.calendar.month.year(), app.calendar.month.month()), 480 + (today.year(), today.month()), 481 + "invoking the calendar resets it to the current month" 482 + ); 483 + }); 484 + 485 + cx.simulate_keystrokes("ctrl-shift-c"); 486 + app.update(cx, |app, _| { 487 + assert!(!app.sidebar_open, "second invocation toggles closed"); 488 + }); 489 + } 490 + 491 + /// The registered panels render in the sidebar in declared order, and the 492 + /// Similar panel's data drives its body (state-level assertion — rendered 493 + /// pixels are checked via dev-loop screenshots, not here). 494 + #[gpui::test] 495 + async fn sidebar_hosts_calendar_and_similar_panels(cx: &mut gpui::TestAppContext) { 496 + let (app, cx) = open_app("sidebar-panels", cx); 497 + cx.run_until_parked(); 498 + 499 + assert_eq!( 500 + crate::SIDEBAR_PANELS 501 + .iter() 502 + .map(|p| p.title()) 503 + .collect::<Vec<_>>(), 504 + vec!["Calendar", "Similar"], 505 + ); 506 + 507 + // Focusing a block with distinctive shared vocabulary populates the 508 + // Similar panel's backing data. 509 + let subject = block_by_content(&app, cx, "Outlined the fixture graph #project"); 510 + focus_block(&app, cx, subject); 511 + app.update(cx, |app, _| { 512 + assert!( 513 + !app.similar.is_empty(), 514 + "focusing a fixture block with shared terms yields similar hits" 515 + ); 516 + }); 517 + }
+18
openspec/changes/ui-polish/specs/dev-automation-server/spec.md
··· 1 + # dev-automation-server Delta: ui-polish 2 + 3 + ## MODIFIED Requirements 4 + 5 + ### Requirement: Structured state dump 6 + 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. 7 + 8 + #### Scenario: Dump reflects a completion popup 9 + - **WHEN** reference completion is open with candidates and a client sends `{"cmd":"dump"}` 10 + - **THEN** the response includes the open popup type and its candidate list, the focused block id, and the cursor position within it 11 + 12 + #### Scenario: Dump reflects sidebar state 13 + - **WHEN** the sidebar is open and a client sends `{"cmd":"dump"}` 14 + - **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 15 + 16 + #### Scenario: Bounds available without a screenshot 17 + - **WHEN** a client sends `{"cmd":"bounds"}` (or reads bounds from a `dump`) 18 + - **THEN** the response contains the window's current position, size, and scale factor as reported by gpui
+6 -6
openspec/changes/ui-polish/tasks.md
··· 10 10 11 11 ## 3. Sidebar (design D3) 12 12 13 - - [ ] 3.1 Sidebar host: collapsed flag, clamped drag-resize width, ordered panel registrations (title + render fn); session-persistent state; collapsing never discards panel state 14 - - [ ] 3.2 Move the calendar picker into a sidebar panel; calendar invocation (shortcut/button) opens the sidebar and reveals it; day-click behavior unchanged 15 - - [ ] 3.3 Move the Similar panel from the bottom dock into the sidebar; remove the bottom-dock rendering; Backlinks stays put 16 - - [ ] 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) 17 - - [ ] 3.5 Extend the devtools `dump` with sidebar state (open/width/panels) — additive field 18 - - [ ] 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 13 + - [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) 14 + - [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`) 15 + - [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 16 + - [x] 3.4 Sidebar-toggle icon button in the titlebar (collapse/expand Tabler variants reflect state) + ctrl-shift-b binding (no keymap conflicts) 17 + - [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) 18 + - [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) 19 19 20 20 ## 4. Page title as heading (design D6) 21 21