Skip to main content

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(&sections) {
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(&section.items, &mut old_parents);
357        }
358        for section in sections.iter() {
359            collect_parent_ids(&section.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}