gpui_ai/sidebar_nav.rs
1//! Stable-ID, filterable navigation composed over gpui-component's Sidebar primitives.
2//!
3//! The entity's only rendering input is one flattened snapshot of visible
4//! rows. Sections, items, filtering, and expansion resolve into a single
5//! `[VisibleRow]` recomputed when the state it derives from changes and never
6//! from `Render`, so virtualization spans rows rather than section roots: a
7//! section holding ten thousand expanded descendants constructs the same
8//! bounded window as a section holding ten.
9//!
10//! Flattening costs the tree relationships that element nesting used to
11//! carry, so every row states them: level, parent, and position within its
12//! visible sibling set. Those fields are what the ARIA tree keyboard model
13//! walks and what the AccessKit nodes report, which is the sanctioned way to
14//! describe a tree whose rows cannot contain one another.
15
16mod render;
17mod rows;
18#[cfg(test)]
19mod tests;
20
21use gpui_base::StyledExt as _;
22use std::{cell::Cell, collections::HashSet, rc::Rc, sync::Arc};
23
24use gpui::{
25 AnyElement, App, AppContext as _, Context, Div, ElementId, Entity, EventEmitter, FocusHandle,
26 Focusable as _, InteractiveElement as _, IntoElement, ListAlignment, ListState,
27 ParentElement as _, Pixels, Render, Role, SharedString, Stateful,
28 StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, list,
29 prelude::FluentBuilder as _,
30};
31use gpui_component::{
32 ActiveTheme as _, IconName, h_flex,
33 input::{Input, InputEvent, InputState},
34 v_flex,
35};
36
37use crate::scrolling::PolicyScrollbarExt as _;
38use crate::{
39 motion::disclosure_progress, resolved_layout::ResolvedLayoutKey, scrolling::list_scroll_mask,
40};
41
42use render::{nav_control, render_row, sidebar_tree_container};
43use rows::{VisibleRow, collect_parent_ids, snapshot_ids_are_unique};
44
45/// One application-owned recursive sidebar item.
46///
47/// IDs must be stable and globally unique inside a sidebar snapshot. Labels
48/// may be duplicated because every interaction is routed by `id`.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct SidebarNavItem {
51 id: SharedString,
52 label: SharedString,
53 icon: Option<SharedString>,
54 badge: Option<SharedString>,
55 disabled: bool,
56 children: Arc<[SidebarNavItem]>,
57}
58
59impl SidebarNavItem {
60 /// Create an enabled item with stable application identity.
61 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
62 Self {
63 id: id.into(),
64 label: label.into(),
65 icon: None,
66 badge: None,
67 disabled: false,
68 children: Arc::from([]),
69 }
70 }
71
72 /// Set the optional leading icon.
73 pub fn icon(mut self, icon: impl gpui_component::IconNamed) -> Self {
74 self.icon = Some(icon.path());
75 self
76 }
77
78 /// Set compact trailing badge text.
79 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
80 self.badge = Some(badge.into());
81 self
82 }
83
84 /// Set whether every activation path is unavailable.
85 pub fn disabled(mut self, disabled: bool) -> Self {
86 self.disabled = disabled;
87 self
88 }
89
90 /// Replace the recursive child snapshot.
91 pub fn children<I>(mut self, children: I) -> Self
92 where
93 I: IntoIterator<Item = SidebarNavItem>,
94 {
95 self.children = children.into_iter().collect();
96 self
97 }
98
99 /// Return the stable application identity.
100 pub fn id(&self) -> &SharedString {
101 &self.id
102 }
103
104 /// Return the visible label.
105 pub fn label(&self) -> &SharedString {
106 &self.label
107 }
108
109 /// Return the optional leading icon.
110 pub fn icon_path(&self) -> Option<&SharedString> {
111 self.icon.as_ref()
112 }
113
114 /// Return optional trailing badge text.
115 pub fn badge_text(&self) -> Option<&SharedString> {
116 self.badge.as_ref()
117 }
118
119 /// Return whether every activation path is unavailable.
120 pub fn is_disabled(&self) -> bool {
121 self.disabled
122 }
123
124 /// Return the recursive child snapshot.
125 pub fn child_items(&self) -> &[SidebarNavItem] {
126 &self.children
127 }
128}
129
130/// One application-owned labeled section of sidebar items.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub struct SidebarSection {
133 id: SharedString,
134 label: SharedString,
135 items: Arc<[SidebarNavItem]>,
136}
137
138impl SidebarSection {
139 /// Create an empty section with stable identity.
140 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
141 Self {
142 id: id.into(),
143 label: label.into(),
144 items: Arc::from([]),
145 }
146 }
147
148 /// Replace the section's recursive item snapshot.
149 pub fn items<I>(mut self, items: I) -> Self
150 where
151 I: IntoIterator<Item = SidebarNavItem>,
152 {
153 self.items = items.into_iter().collect();
154 self
155 }
156
157 /// Return the stable section identity.
158 pub fn id(&self) -> &SharedString {
159 &self.id
160 }
161
162 /// Return the visible section label.
163 pub fn label(&self) -> &SharedString {
164 &self.label
165 }
166
167 /// Return the recursive item snapshot.
168 pub fn nav_items(&self) -> &[SidebarNavItem] {
169 &self.items
170 }
171}
172
173/// A typed application intent emitted by [`SidebarNav`].
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub enum SidebarNavEvent {
176 /// An enabled item was activated by pointer, keyboard, or accessibility.
177 ///
178 /// Activating a parent intentionally toggles its transient expansion and
179 /// emits `Selected` in the same interaction so applications may navigate
180 /// to parent routes as well as expose their children.
181 Selected {
182 /// Sidebar component identity.
183 id: SharedString,
184 /// Stable application item identity.
185 item_id: SharedString,
186 },
187 /// The sidebar's owned collapsed state changed.
188 CollapsedChanged {
189 /// Sidebar component identity.
190 id: SharedString,
191 /// Latest collapsed state.
192 collapsed: bool,
193 },
194 /// The named new-task control was activated.
195 NewTaskRequested {
196 /// Sidebar component identity.
197 id: SharedString,
198 },
199 /// The native quick-filter query changed.
200 QueryChanged {
201 /// Sidebar component identity.
202 id: SharedString,
203 /// Latest query text.
204 query: SharedString,
205 },
206}
207
208/// How the navigation draws its outer shell.
209///
210/// The shell is all this changes. Header, rows, filtering, the keyboard model,
211/// and every accessibility identity are the same in both modes, so a docked
212/// navigation differs from a standalone one by its box alone.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
214pub enum SidebarNavPresentation {
215 /// A standalone rail that owns its own size: a fixed collapsed or expanded
216 /// width, no flex growth, and a border along its trailing edge.
217 #[default]
218 Standalone,
219 /// A shell that fills whatever box its host gives it, with no forced
220 /// width, no rail sizing, and no edge border.
221 ///
222 /// For hosts that already own placement and size — a dock panel, a split,
223 /// a resizable pane. Placement stays with the host and is deliberately not
224 /// described here: a navigation docked along the bottom fills that
225 /// region's width exactly as one docked left fills its column, because the
226 /// shell contributes no width of its own either way.
227 Embedded,
228}
229
230/// A hybrid-controlled, filterable application sidebar.
231///
232/// Applications own the immutable section/item snapshot and active item ID.
233/// This entity retains one native [`InputState`] plus collapsed, query,
234/// expansion, focus, and scroll interaction state. Selection is emitted as a
235/// typed stable ID and never changes the consumer-controlled active item.
236///
237/// ```no_run
238/// # use gpui_ai::prelude::*;
239/// # use gpui::AppContext;
240/// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
241/// let nav = cx.new(|cx| SidebarNav::new("workspace-nav", window, cx));
242/// nav.update(cx, |nav, cx| {
243/// nav.set_sections([
244/// SidebarSection::new("main", "Workspace").items([
245/// SidebarNavItem::new("orders", "Orders")
246/// .icon(gpui_component::IconName::LayoutDashboard),
247/// ]),
248/// ], cx);
249/// nav.set_active_item("orders", cx);
250/// });
251/// # }
252/// ```
253pub struct SidebarNav {
254 /// Styles the caller put on this component, applied to its own frame.
255 ///
256 /// Last, so a caller outranks the component's defaults - the same rule the
257 /// builder components follow. A wrapper `div` cannot stand in for this:
258 /// a background, a border, or an ink set on a wrapper paints around the
259 /// component rather than on it.
260 style: gpui::StyleRefinement,
261 id: SharedString,
262 sections: Arc<[SidebarSection]>,
263 active_item: Option<SharedString>,
264 collapsed: bool,
265 /// One highlight glides between rows instead of per-row hover fills.
266 /// Default on; [`Self::set_hover_glide`] restores a local hover fill.
267 hover_glide: bool,
268 query: SharedString,
269 input: Entity<InputState>,
270 expanded: HashSet<SharedString>,
271 /// Flattened visible rows, recomputed only where state changes.
272 rows: Arc<[VisibleRow]>,
273 /// Stable ID of the tree's roving row, retained across every projection.
274 focused_row: Option<SharedString>,
275 /// The tree's single focus handle; rows are active descendants of it.
276 tree_focus: FocusHandle,
277 /// Virtualized row list and the nav body's sole scroll owner.
278 row_list: ListState,
279 /// Rem size the retained row heights were measured against.
280 resolved_layout: ResolvedLayoutKey,
281 /// Rows the virtual list has constructed, which is what bounded
282 /// construction is asserted against.
283 constructed_rows: Rc<Cell<usize>>,
284 /// How the shell is drawn. Read only by `render_shell`.
285 presentation: SidebarNavPresentation,
286 _input_subscription: Subscription,
287}
288
289impl SidebarNav {
290 /// Create an empty navigation entity with one retained native filter input.
291 pub fn new(id: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
292 let input = cx.new(|cx| InputState::new(window, cx).placeholder("Filter navigation"));
293 let subscription =
294 cx.subscribe_in(&input, window, |this, input, event: &InputEvent, _, cx| {
295 if let InputEvent::Change = event {
296 let query: SharedString = input.read(cx).value().to_string().into();
297 this.update_query(query, cx);
298 }
299 });
300 Self {
301 style: gpui::StyleRefinement::default(),
302 id: id.into(),
303 sections: Arc::from([]),
304 active_item: None,
305 collapsed: false,
306 hover_glide: true,
307 query: "".into(),
308 input,
309 expanded: HashSet::new(),
310 rows: Arc::from([]),
311 focused_row: None,
312 tree_focus: cx.focus_handle(),
313 row_list: ListState::new(0, ListAlignment::Top, Pixels::ZERO),
314 resolved_layout: ResolvedLayoutKey::default(),
315 constructed_rows: Rc::new(Cell::new(0)),
316 presentation: SidebarNavPresentation::default(),
317 _input_subscription: subscription,
318 }
319 }
320
321 /// Set how the shell is drawn (default:
322 /// [`SidebarNavPresentation::Standalone`]).
323 ///
324 /// ```no_run
325 /// # use gpui_ai::prelude::*;
326 /// # use gpui::AppContext;
327 /// # fn example(window: &mut gpui::Window, cx: &mut gpui::App) {
328 /// let nav = cx.new(|cx| {
329 /// SidebarNav::new("docked-nav", window, cx)
330 /// .with_presentation(SidebarNavPresentation::Embedded)
331 /// });
332 /// # }
333 /// ```
334 pub fn with_presentation(mut self, presentation: SidebarNavPresentation) -> Self {
335 self.presentation = presentation;
336 self
337 }
338
339 /// Replace the controlled section snapshot.
340 ///
341 /// Malformed snapshots containing duplicate section or item IDs are
342 /// ignored so focus, events, and accessibility identities cannot alias.
343 pub fn set_sections(
344 &mut self,
345 sections: impl IntoIterator<Item = SidebarSection>,
346 cx: &mut Context<Self>,
347 ) {
348 let sections: Arc<[SidebarSection]> = sections.into_iter().collect();
349 if self.sections.as_ref() == sections.as_ref() || !snapshot_ids_are_unique(§ions) {
350 return;
351 }
352
353 let mut old_parents = HashSet::new();
354 let mut new_parents = HashSet::new();
355 for section in self.sections.iter() {
356 collect_parent_ids(§ion.items, &mut old_parents);
357 }
358 for section in sections.iter() {
359 collect_parent_ids(§ion.items, &mut new_parents);
360 }
361 self.expanded.retain(|id| new_parents.contains(id));
362 self.expanded
363 .extend(new_parents.difference(&old_parents).cloned());
364
365 self.sections = sections;
366 self.rebuild_rows(cx);
367 }
368
369 /// Replace the consumer-controlled active item ID.
370 pub fn set_active_item(&mut self, item_id: impl Into<SharedString>, cx: &mut Context<Self>) {
371 let item_id = item_id.into();
372 if self.active_item.as_ref() != Some(&item_id) {
373 self.active_item = Some(item_id);
374 self.rebuild_rows(cx);
375 }
376 }
377
378 /// Replace the owned collapsed state and emit its stable component identity.
379 pub fn set_collapsed(&mut self, collapsed: bool, cx: &mut Context<Self>) {
380 if self.collapsed == collapsed {
381 return;
382 }
383 self.collapsed = collapsed;
384 self.rebuild_rows(cx);
385 cx.emit(SidebarNavEvent::CollapsedChanged {
386 id: self.id.clone(),
387 collapsed,
388 });
389 }
390
391 /// Replace the native query and emit a change only when its text differs.
392 pub fn set_query(
393 &mut self,
394 query: impl Into<SharedString>,
395 window: &mut Window,
396 cx: &mut Context<Self>,
397 ) {
398 let query = query.into();
399 if self.query == query {
400 return;
401 }
402 self.query = query.clone();
403 self.input.update(cx, |input, cx| {
404 input.set_value(query.to_string(), window, cx)
405 });
406 // InputState intentionally suppresses Change for programmatic values,
407 // and it may be unmounted while collapsed, so the owner must rebuild
408 // its own projection here.
409 self.rebuild_rows(cx);
410 self.emit_query_changed(query, cx);
411 }
412
413 /// Return whether the sidebar is currently collapsed.
414 pub fn is_collapsed(&self) -> bool {
415 self.collapsed
416 }
417
418 /// Choose between the gliding hover highlight (the default) and a
419 /// local hover fill per row.
420 pub fn set_hover_glide(&mut self, enabled: bool, cx: &mut Context<Self>) {
421 if self.hover_glide != enabled {
422 self.hover_glide = enabled;
423 cx.notify();
424 }
425 }
426
427 /// Return the latest quick-filter query.
428 pub fn query(&self) -> &SharedString {
429 &self.query
430 }
431
432 /// Return how the shell is drawn.
433 pub fn presentation(&self) -> SidebarNavPresentation {
434 self.presentation
435 }
436
437 /// Move focus into the retained native filter input when it is mounted.
438 ///
439 /// Collapsed navigation keeps focus on its visible compact control instead
440 /// of moving it into an unmounted input.
441 pub fn focus_filter(&self, window: &mut Window, cx: &mut App) {
442 if self.collapsed {
443 return;
444 }
445 self.input.read(cx).focus_handle(cx).focus(window, cx);
446 }
447
448 fn update_query(&mut self, query: SharedString, cx: &mut Context<Self>) {
449 if self.query == query {
450 return;
451 }
452 self.query = query.clone();
453 self.rebuild_rows(cx);
454 self.emit_query_changed(query, cx);
455 }
456
457 fn emit_query_changed(&self, query: SharedString, cx: &mut Context<Self>) {
458 cx.emit(SidebarNavEvent::QueryChanged {
459 id: self.id.clone(),
460 query,
461 });
462 }
463}
464
465impl EventEmitter<SidebarNavEvent> for SidebarNav {}
466
467impl gpui::Styled for SidebarNav {
468 fn style(&mut self) -> &mut gpui::StyleRefinement {
469 &mut self.style
470 }
471}
472
473impl Render for SidebarNav {
474 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
475 // Measured row heights are only valid for the rem they were laid out
476 // at. Reading it here mutates nothing; the reaction is deferred so
477 // that render never notifies.
478 let rem_size = window.rem_size();
479 if !self.resolved_layout.matches(rem_size) {
480 cx.defer_in(window, move |nav, _, cx| {
481 nav.resolve_layout(rem_size, cx);
482 });
483 }
484
485 let content = self.render_content(window, cx);
486
487 // Collapse restructures the tree — descendants unmount, levels
488 // flatten — so the rail cannot cross-fade rows one by one. Instead
489 // the standalone shell glides its width between the poles while the
490 // incoming structure fades in as the width settles, which keeps
491 // icons and focus at their settled positions rather than dragging
492 // them through the travel. Reduced motion snaps both.
493 let expansion = disclosure_progress(
494 (ElementId::from(self.id.clone()), "expanse"),
495 !self.collapsed,
496 window,
497 cx,
498 );
499 let expansion_fade = crate::motion::disclosure_fade(
500 (ElementId::from(self.id.clone()), "expanse"),
501 !self.collapsed,
502 window,
503 cx,
504 );
505 let settle = if self.collapsed {
506 1.0 - expansion_fade
507 } else {
508 expansion_fade
509 };
510 self.render_shell(expansion, cx)
511 .child(
512 div()
513 .flex()
514 .flex_col()
515 .h_full()
516 .min_h_0()
517 .w_full()
518 .opacity(settle)
519 .children(content),
520 )
521 .refine_style(&self.style)
522 }
523}
524
525impl SidebarNav {
526 /// The outer shell: stable identity, navigation semantics, and the box the
527 /// host sees.
528 ///
529 /// This is the only rendering [`SidebarNavPresentation`] reaches, which is
530 /// what keeps the setting additive: the content below draws the same rows
531 /// either way.
532 fn render_shell(&self, expansion: f32, cx: &mut App) -> Stateful<Div> {
533 let tokens = cx.theme().semantic_tokens();
534 div()
535 .id((ElementId::from(self.id.clone()), "frame"))
536 .debug_selector({
537 let id = self.id.clone();
538 move || format!("sidebar-nav-{id}")
539 })
540 .accessibility_id(format!("sidebar-nav.{}", self.id))
541 .role(Role::Navigation)
542 .aria_label("Workspace navigation")
543 .h_full()
544 .min_h_0()
545 .flex()
546 .flex_col()
547 .overflow_hidden()
548 .bg(cx.theme().sidebar)
549 .text_color(cx.theme().sidebar_foreground)
550 .map(|shell| match self.presentation {
551 // A standalone rail sizes itself and draws the edge between
552 // itself and whatever sits beside it.
553 SidebarNavPresentation::Standalone => {
554 let narrow = tokens.spacing.xxl * 1.5;
555 let wide = tokens.spacing.xxl * 8.;
556 shell
557 .w(narrow + (wide - narrow) * expansion)
558 .flex_none()
559 .border_r_1()
560 .border_color(cx.theme().sidebar_border)
561 }
562 // The host owns placement and size, so the shell contributes
563 // neither a width nor an edge: docked along the bottom it
564 // fills that region's width instead of keeping a rail's, and
565 // the host's own divider is the only border drawn.
566 SidebarNavPresentation::Embedded => shell.w_full().min_w_0(),
567 })
568 }
569
570 /// The shell's children, in order: header, virtualized row tree, and the
571 /// empty status.
572 ///
573 /// Nothing here reads [`SidebarNavPresentation`].
574 fn render_content(&self, window: &mut Window, cx: &mut Context<Self>) -> Vec<AnyElement> {
575 let tokens = cx.theme().semantic_tokens();
576 // The rows' own accessibility overlay owns the pointer, so hover is
577 // the crate's to draw: one highlight gliding between rows, exactly
578 // as the thread list and the model picker draw it.
579 let glide = self.hover_glide.then(|| {
580 crate::glide::glide_hover_state(
581 (ElementId::from(self.id.clone()), "row-glide").into(),
582 window,
583 cx,
584 )
585 });
586 let new_task_owner = cx.weak_entity();
587 let collapse_owner = cx.weak_entity();
588 let row_owner = cx.weak_entity();
589 let key_owner = cx.weak_entity();
590 let collapsed = self.collapsed;
591 let input = self.input.clone();
592 let id = self.id.clone();
593 let rows = self.rows.clone();
594 let row_component_id = self.id.clone();
595 let constructed_rows = self.constructed_rows.clone();
596 let roving_row = self.roving_row_id();
597 let tree_focused = self.tree_focus.is_focused(window);
598 let empty_message: SharedString = if self.sections.is_empty() {
599 "No navigation items".into()
600 } else {
601 "No matching navigation items".into()
602 };
603 let empty_selector = self.sections.is_empty();
604
605 let header_actions = if collapsed {
606 h_flex().w_full().child(
607 nav_control(
608 (ElementId::from(id.clone()), "collapse"),
609 "Expand navigation".into(),
610 IconName::PanelLeftOpen,
611 false,
612 cx,
613 )
614 .w_full()
615 .debug_selector(|| "sidebar-nav-collapse".to_owned())
616 .on_click(move |_, _, cx| {
617 _ = collapse_owner.update(cx, |nav, cx| nav.set_collapsed(false, cx));
618 }),
619 )
620 } else {
621 h_flex()
622 .w_full()
623 .gap(tokens.spacing.xs)
624 .child(
625 nav_control(
626 (ElementId::from(id.clone()), "new-task"),
627 "New task".into(),
628 IconName::Plus,
629 true,
630 cx,
631 )
632 .debug_selector(|| "sidebar-nav-new-task".to_owned())
633 .on_click(move |_, _, cx| {
634 _ = new_task_owner.update(cx, |nav, cx| {
635 cx.emit(SidebarNavEvent::NewTaskRequested { id: nav.id.clone() });
636 });
637 }),
638 )
639 .child(
640 nav_control(
641 (ElementId::from(id.clone()), "collapse"),
642 "Collapse navigation".into(),
643 IconName::PanelLeftClose,
644 false,
645 cx,
646 )
647 .debug_selector(|| "sidebar-nav-collapse".to_owned())
648 .on_click(move |_, _, cx| {
649 _ = collapse_owner.update(cx, |nav, cx| nav.set_collapsed(true, cx));
650 }),
651 )
652 };
653
654 let header = v_flex()
655 .w_full()
656 .gap(tokens.spacing.sm)
657 .child(header_actions)
658 .when(!collapsed, |this| {
659 this.child(
660 div()
661 .id((ElementId::from(id.clone()), "filter"))
662 .debug_selector(|| "sidebar-nav-filter".to_owned())
663 .w_full()
664 .child(
665 Input::new(&input)
666 .accessibility_id(format!("sidebar-nav.{id}.filter"))
667 .aria_label("Filter navigation")
668 .prefix(IconName::Search)
669 .cleanable(true)
670 .w_full(),
671 ),
672 )
673 });
674 let row_list = self.row_list.clone();
675 let row_glide = glide.clone();
676 let rows_are_empty = self.rows.is_empty();
677
678 let mut content = vec![
679 div()
680 .w_full()
681 .flex_none()
682 .p(tokens.spacing.sm)
683 .child(header)
684 .into_any_element(),
685 {
686 let frame = div().relative().w_full().flex_1().min_h_0();
687 match &glide {
688 Some(state) => crate::glide::glide_frame(frame, state).child(
689 crate::glide::glide_highlight(
690 (ElementId::from(self.id.clone()), "row-glide").into(),
691 state,
692 cx.theme().radius,
693 "sidebar-nav-glide",
694 window,
695 cx,
696 ),
697 ),
698 None => frame,
699 }
700 }
701 .child(
702 sidebar_tree_container(&self.id)
703 // The tree is the single tab stop and holds focus
704 // for every row, so a row that unmounts while
705 // scrolled away cannot take the reader's focus
706 // out of the navigation with it.
707 .track_focus(&self.tree_focus.clone().tab_index(0).tab_stop(true))
708 .size_full()
709 .flex()
710 .flex_col()
711 .px(tokens.spacing.sm)
712 .gap(tokens.spacing.xxs)
713 .child(
714 list(row_list.clone(), move |index, window, cx| {
715 constructed_rows.set(constructed_rows.get() + 1);
716 rows.get(index)
717 .map(|row| {
718 render_row(
719 row,
720 &render::RowContext {
721 component_id: &row_component_id,
722 collapsed,
723 focused: tree_focused
724 && roving_row.as_ref() == Some(&row.id),
725 owner: &row_owner,
726 glide: row_glide.as_ref(),
727 },
728 window,
729 cx,
730 )
731 })
732 .unwrap_or_else(|| div().hidden().into_any_element())
733 })
734 .size_full(),
735 )
736 .policy_vertical_scrollbar(&row_list, cx)
737 .on_key_down(move |event, window, cx| {
738 if event.keystroke.modifiers.modified() {
739 return;
740 }
741 let key = event.keystroke.key.clone();
742 let handled = key_owner
743 .update(cx, |nav, cx| nav.navigate(&key, window, cx))
744 .unwrap_or(false);
745 if handled {
746 cx.stop_propagation();
747 }
748 }),
749 )
750 // Capture-phase containment wins over an ancestor catalog
751 // list and releases the wheel at either nav edge.
752 .child(list_scroll_mask(&self.row_list))
753 .into_any_element(),
754 ];
755 if rows_are_empty && !collapsed {
756 content.push(
757 div()
758 .id((ElementId::from(self.id.clone()), "empty-status"))
759 .debug_selector(move || {
760 if empty_selector {
761 "sidebar-nav-empty".to_owned()
762 } else {
763 "sidebar-nav-no-results".to_owned()
764 }
765 })
766 .flex_none()
767 .role(Role::Status)
768 .aria_label(empty_message.clone())
769 .child(crate::surface::empty_state(
770 IconName::Search,
771 empty_message,
772 None,
773 cx,
774 ))
775 .into_any_element(),
776 );
777 }
778 content
779 }
780}