Skip to main content

gpui_component/dock/
tab_panel.rs

1//! The gpui-component appearance for a tab group.
2//!
3//! `gpui_base::dock::TabGroup` owns the behavior — membership, the displayed
4//! tab, drag hit-testing, the zoom flag — and draws none of it. Everything
5//! visible is here: the tab bar, the toolbar, the ellipsis menu, the dock
6//! collapse affordances, the drop placeholder, and the styled drag preview.
7
8use std::{
9    cell::{Cell, RefCell},
10    collections::HashSet,
11    rc::Rc,
12    sync::Arc,
13};
14
15use gpui::{
16    Anchor, AnyElement, AnyView, App, AppContext as _, Context, Div, Empty,
17    InteractiveElement as _, IntoElement, ParentElement as _, Render, ScrollHandle, SharedString,
18    Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, div,
19    prelude::FluentBuilder as _, px,
20};
21use gpui_base::{
22    dock::{
23        AnyDrag, DockPlacement, DragPanel, DropIndicator, NodeId, PaneNode, PaneRef, PanelId,
24        TabGroupContext, TabGroupRenderer,
25    },
26    spring,
27};
28use rust_i18n::t;
29
30use crate::{
31    ActiveTheme as _, IconName, Selectable as _, Sizable as _,
32    button::{Button, ButtonCustomVariant, ButtonVariants as _},
33    dock::{
34        ClosePanel, PanelControl, PanelHandle, PanelStyle, SkinShared, ToggleZoom,
35        toggle_dock_by_user,
36    },
37    h_flex,
38    menu::DropdownMenu as _,
39    tab::{Tab, TabBar},
40};
41
42/// Names the tab bar's zoom button in the debug-bounds map, so a test can ask
43/// a really-drawn frame whether the control was offered.
44const ZOOM_CONTROL_SELECTOR: &str = "dock-tab-bar-zoom-control";
45
46/// Debug-bounds selector for a tab's close (X) button, for tests.
47const CLOSE_BUTTON_SELECTOR: &str = "dock-tab-close-button";
48
49/// Debug-bounds selector prefix for a tab, suffixed with its panel index.
50const TAB_SELECTOR: &str = "dock-tab";
51
52/// Debug-bounds selector prefix for a dock's collapse button, suffixed with
53/// its placement.
54const DOCK_TOGGLE_SELECTOR: &str = "dock-toggle";
55
56/// The size the styled drag preview occupies, reported to base so a drop
57/// placeholder knows where to fly in from.
58const DRAG_PREVIEW_SIZE: gpui::Size<gpui::Pixels> = gpui::size(px(96.), px(30.));
59
60/// The preview that follows the cursor while a panel is dragged.
61///
62/// `gpui_base::dock::DragPanel` is the payload and draws nothing; this is the
63/// appearance half, reintroduced here.
64pub struct DragPanelPreview {
65    panel: Arc<dyn gpui_base::dock::PanelView>,
66}
67
68impl Render for DragPanelPreview {
69    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
70        div()
71            .id("drag-panel")
72            .cursor_grab()
73            .py_1()
74            .px_3()
75            .w_24()
76            .overflow_hidden()
77            .whitespace_nowrap()
78            .border_1()
79            .border_color(cx.theme().border)
80            .rounded(cx.theme().radius)
81            .text_color(cx.theme().tab_foreground)
82            .bg(cx.theme().tokens.tab_active)
83            .opacity(0.75)
84            .child(panel_title(&self.panel, window, cx))
85    }
86}
87
88/// A panel's title, or its registered name when it reached base without this
89/// crate's handle and so carries no presentation. See [`PanelHandle::of`].
90pub(crate) fn panel_title(
91    panel: &Arc<dyn gpui_base::dock::PanelView>,
92    window: &mut Window,
93    cx: &mut App,
94) -> AnyElement {
95    let Some(handle) = PanelHandle::of(panel) else {
96        let name = panel.panel_name(cx);
97        warn_unwrapped_once(panel.panel_id(cx), name);
98        return SharedString::from(name).into_any_element();
99    };
100    handle.title(window, cx)
101}
102
103thread_local! {
104    /// Panels already warned about. `panel_title` sits on the render path, so
105    /// an unguarded warning would repeat at frame rate and bury the very
106    /// signal it exists to give.
107    ///
108    /// Keyed by panel rather than a bare `Once` so a second wrongly installed
109    /// panel is still named, and a runtime set rather than a `debug_assert!`
110    /// so a release build says it too — the consequence is a shipped app whose
111    /// tabs are titleless, which is exactly when someone needs to be told.
112    /// Thread-local because rendering happens on one thread, so no lock is
113    /// needed.
114    static WARNED_UNWRAPPED: RefCell<HashSet<PanelId>> = RefCell::new(HashSet::new());
115}
116
117/// Say once, per panel, that a panel reached the skin without its
118/// presentation handle. Silent otherwise, and visual-only: the panel docks,
119/// drags and persists, it just has no title. The shorter method is the wrong
120/// one — `DockLayout::panel` and `DockArea::add_panel` accept a
121/// `gpui_component::dock::Panel` and store the bare entity — so this says
122/// which panel and what to call instead.
123fn warn_unwrapped_once(panel: PanelId, name: &'static str) {
124    if !WARNED_UNWRAPPED.with(|warned| warned.borrow_mut().insert(panel)) {
125        return;
126    }
127    tracing::warn!(
128        panel = name,
129        "dock panel reached the skin without its presentation handle, so it \
130         draws its panel name instead of its title; install it with \
131         `gpui_component::dock::panel_handle(..)` and `DockLayout::panel_view` \
132         / `DockArea::add_panel_view` rather than `DockLayout::panel` / \
133         `DockArea::add_panel`"
134    );
135}
136
137/// Where the zoom affordance goes for the group's displayed panel, or `None`
138/// when there is none to offer.
139///
140/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
141/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
142/// whether zooming happens at all, and base refuses a zoom that fails it. The
143/// old dock had a single `zoomable() -> Option<PanelControl>` that could not
144/// disagree with itself; split across the seam it can, and a panel answering
145/// `zoomable() == false` with `zoom_control() == Some(Toolbar)` would
146/// otherwise draw a button that does nothing.
147fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
148    let panel = group.active_panel()?;
149    panel
150        .zoomable(cx)
151        .then(|| PanelHandle::of(panel).and_then(|handle| handle.zoom_control(cx)))
152        .flatten()
153}
154
155/// The payload for dragging the tab at `ix` out of its group, or `None` when
156/// this group must not be rearranged.
157///
158/// The guard is the skin's. [`TabGroupContext::drag_panel`] answers for any
159/// tab in range whether or not the group may be rearranged, so a tab bar that
160/// forgets to ask [`TabGroupContext::is_draggable`] makes a group that has
161/// nowhere to go — a dock's last group, a locked dock — draggable anyway. The
162/// old dock asked the same question, spelled `state.draggable`.
163fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
164    group
165        .is_draggable()
166        .then(|| group.drag_panel(ix, cx))
167        .flatten()
168}
169
170/// The left-most, top-most tab group in a container — where a left dock's
171/// collapse affordance goes. Mirrors the old `StackPanel::left_top_tab_panel`.
172fn left_top_group(node: &PaneNode) -> Option<NodeId> {
173    match node.kind() {
174        PaneRef::Tabs { .. } => Some(node.id()),
175        PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
176    }
177}
178
179/// The right-most, top-most tab group. A vertical split stacks its children,
180/// so its *first* child is the top one; a horizontal split's last child is the
181/// right-most. Mirrors the old `StackPanel::right_top_tab_panel`.
182fn right_top_group(node: &PaneNode) -> Option<NodeId> {
183    match node.kind() {
184        PaneRef::Tabs { .. } => Some(node.id()),
185        PaneRef::Split { axis, children, .. } => match axis {
186            gpui::Axis::Vertical => children.first(),
187            gpui::Axis::Horizontal => children.last(),
188        }
189        .and_then(right_top_group),
190    }
191}
192
193/// One tab group's appearance.
194///
195/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once per
196/// container — so the tab bar's scroll position belongs to the group whose
197/// tabs it scrolls.
198pub(crate) struct TabGroupSkin {
199    shared: Rc<SkinShared>,
200    scroll_handle: ScrollHandle,
201    /// The displayed tab the last frame drew, so a change scrolls the new tab
202    /// into view. The old dock recorded this at the moment of selection; the
203    /// group now owns selection, so the skin notices instead of being told.
204    last_active_ix: Rc<Cell<Option<usize>>>,
205}
206
207impl TabGroupSkin {
208    pub(crate) fn new(shared: Rc<SkinShared>) -> Self {
209        Self {
210            shared,
211            scroll_handle: ScrollHandle::default(),
212            last_active_ix: Rc::new(Cell::new(None)),
213        }
214    }
215
216    /// Whether a dock's collapse affordance belongs in *this* group's tab bar,
217    /// and which way it points. `None` means this group draws none.
218    fn dock_toggle_button(
219        &self,
220        placement: DockPlacement,
221        group: &TabGroupContext,
222        cx: &mut App,
223    ) -> Option<Button> {
224        if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
225            return None;
226        }
227
228        let area = self.shared.area().upgrade()?;
229        let area = area.read(cx);
230        // A dock that does not exist is not collapsible, so this covers the
231        // old `left_dock.is_some()` test too.
232        if !area.is_dock_collapsible(placement) {
233            return None;
234        }
235
236        let designated = match placement {
237            DockPlacement::Left => area
238                .layout(DockPlacement::Center)
239                .and_then(|tree| left_top_group(tree.root())),
240            DockPlacement::Right => area
241                .layout(DockPlacement::Center)
242                .and_then(|tree| right_top_group(tree.root())),
243            DockPlacement::Bottom => area
244                .layout(DockPlacement::Bottom)
245                .and_then(|tree| left_top_group(tree.root())),
246            DockPlacement::Center => None,
247        };
248        if designated != Some(group.node()) {
249            return None;
250        }
251
252        let is_open = area.is_dock_open(placement);
253        let icon = match (placement, is_open) {
254            (DockPlacement::Left, true) => IconName::PanelLeft,
255            (DockPlacement::Left, false) => IconName::PanelLeftOpen,
256            (DockPlacement::Right, true) => IconName::PanelRight,
257            (DockPlacement::Right, false) => IconName::PanelRightOpen,
258            (DockPlacement::Bottom, true) => IconName::PanelBottom,
259            (DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
260            (DockPlacement::Center, _) => return None,
261        };
262
263        let area = self.shared.area().clone();
264        Some(
265            Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
266                .icon(icon)
267                .xsmall()
268                .ghost()
269                .tab_stop(false)
270                .tooltip(match is_open {
271                    true => t!("Dock.Collapse"),
272                    false => t!("Dock.Expand"),
273                })
274                .debug_selector(move || format!("{DOCK_TOGGLE_SELECTOR}-{placement:?}"))
275                .on_click(move |_, window, cx| {
276                    _ = area.update(cx, |area, cx| {
277                        toggle_dock_by_user(area, placement, window, cx)
278                    });
279                }),
280        )
281    }
282
283    /// The trailing controls: the panel's own buttons, the zoom affordance,
284    /// and the ellipsis menu.
285    fn render_toolbar(
286        &self,
287        group: &TabGroupContext,
288        window: &mut Window,
289        cx: &mut App,
290    ) -> impl IntoElement {
291        if group.is_collapsed() {
292            return div();
293        }
294
295        let zoomed = group.is_zoomed();
296        let handle = group.active_panel().and_then(PanelHandle::of);
297        let control = zoom_control(group, cx);
298        let toolbar_zoom = control.is_some_and(|control| control.toolbar_visible());
299        let menu_zoom = control.is_some_and(|control| control.menu_visible());
300        let closable = group.is_closable();
301        let buttons = handle.and_then(|handle| handle.toolbar_buttons(window, cx));
302        let panel = handle.map(|handle| handle.panel());
303
304        h_flex()
305            .gap_1()
306            .occlude()
307            .when_some(buttons, |this, buttons| {
308                this.children(
309                    buttons
310                        .into_iter()
311                        .map(|button| button.xsmall().ghost().tab_stop(false)),
312                )
313            })
314            .when_some(
315                match (zoomed, toolbar_zoom) {
316                    (true, _) => Some(("zoom-out", IconName::Minimize, t!("Dock.Zoom Out"))),
317                    (false, true) => Some(("zoom-in", IconName::Maximize, t!("Dock.Zoom In"))),
318                    (false, false) => None,
319                },
320                |this, (id, icon, tooltip)| {
321                    this.child(
322                        Button::new(id)
323                            .icon(icon)
324                            .xsmall()
325                            .ghost()
326                            .tab_stop(false)
327                            .tooltip_with_action(tooltip, &ToggleZoom, None)
328                            .selected(zoomed)
329                            // Whether this button was drawn is the whole of
330                            // the `zoom_control` decision, and there is no
331                            // other way to ask a drawn tree about it. A no-op
332                            // outside test builds; see `debug_selector`.
333                            .debug_selector(|| ZOOM_CONTROL_SELECTOR.to_string())
334                            .on_click({
335                                let group = group.clone();
336                                move |_, window, cx| group.toggle_zoom(window, cx)
337                            }),
338                    )
339                },
340            )
341            .child(
342                Button::new("menu")
343                    .icon(IconName::Ellipsis)
344                    .xsmall()
345                    .ghost()
346                    .tab_stop(false)
347                    .dropdown_menu(move |menu, window, cx| {
348                        menu.when_some(panel.clone(), |menu, panel| {
349                            panel.dropdown_menu(menu, window, cx)
350                        })
351                        .separator()
352                        .menu_with_disabled(
353                            match zoomed {
354                                true => t!("Dock.Zoom Out"),
355                                false => t!("Dock.Zoom In"),
356                            },
357                            Box::new(ToggleZoom),
358                            !menu_zoom,
359                        )
360                        .when(closable, |menu| {
361                            menu.separator()
362                                .menu(t!("Dock.Close"), Box::new(ClosePanel))
363                        })
364                    })
365                    .anchor(Anchor::TopRight),
366            )
367    }
368
369    /// The one-panel title bar: no tabs, just the title and the controls.
370    fn render_title(
371        &self,
372        group: &TabGroupContext,
373        ix: usize,
374        window: &mut Window,
375        cx: &mut App,
376    ) -> AnyElement {
377        let panel = &group.panels()[ix];
378        let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
379        let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
380        let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
381        let has_leading = left_button.is_some() || bottom_button.is_some();
382        let handle = PanelHandle::of(panel);
383        let title_style = handle.and_then(|handle| handle.title_style(cx));
384        let drag = tab_drag(group, ix, cx);
385
386        h_flex()
387            .justify_between()
388            .h(px(30.))
389            .py_2()
390            .pl_3()
391            .pr_2()
392            .when(left_button.is_some(), |this| this.pl_2())
393            .when(right_button.is_some(), |this| this.pr_2())
394            .when_some(title_style, |this, style| {
395                this.bg(style.background).text_color(style.foreground)
396            })
397            .when(has_leading, |this| {
398                this.child(
399                    h_flex()
400                        .flex_shrink_0()
401                        .mr_1()
402                        .gap_1()
403                        .children(left_button)
404                        .children(bottom_button),
405                )
406            })
407            .child(
408                div()
409                    .id("tab")
410                    .flex_1()
411                    .min_w_16()
412                    .overflow_hidden()
413                    .text_ellipsis()
414                    .whitespace_nowrap()
415                    .child(panel_title(panel, window, cx))
416                    .when_some(drag, |this, drag| {
417                        this.on_drag(drag, {
418                            let panel = panel.clone();
419                            move |drag, offset, _, cx| {
420                                cx.stop_propagation();
421                                drag.set_drag_offset(offset);
422                                drag.set_preview_size(DRAG_PREVIEW_SIZE);
423                                cx.new(|_| DragPanelPreview {
424                                    panel: panel.clone(),
425                                })
426                            }
427                        })
428                    }),
429            )
430            .children(handle.and_then(|handle| handle.title_suffix(window, cx)))
431            .child(
432                h_flex()
433                    .flex_shrink_0()
434                    .ml_1()
435                    .gap_1()
436                    .child(self.render_toolbar(group, window, cx))
437                    .children(right_button),
438            )
439            .into_any_element()
440    }
441
442    /// The full tab bar.
443    fn render_tabs(
444        &self,
445        group: &TabGroupContext,
446        window: &mut Window,
447        cx: &mut App,
448    ) -> AnyElement {
449        let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
450        let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
451        let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
452        let has_leading = left_button.is_some() || bottom_button.is_some();
453        let is_bottom_dock = bottom_button.is_some();
454        let collapsed = group.is_collapsed();
455
456        let droppable = group.is_droppable();
457        let tabs_count = group.panels().len();
458        let active_ix = group.active_ix();
459        let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
460        let visible: Vec<usize> = group
461            .panels()
462            .iter()
463            .enumerate()
464            .filter(|(_, panel)| panel.visible(cx))
465            .map(|(ix, _)| ix)
466            .collect();
467        let displayed_ix = displayed.and_then(|displayed| {
468            group
469                .panels()
470                .iter()
471                .position(|panel| panel.panel_id(cx) == displayed)
472        });
473
474        // Bring a newly displayed tab into view. The group owns selection now,
475        // so the skin notices the change rather than being told about it.
476        if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix) {
477            if let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix) {
478                self.scroll_handle.scroll_to_item(visible_ix);
479            }
480        }
481
482        TabBar::new("tab-bar")
483            .track_scroll(&self.scroll_handle)
484            .when(has_leading, |this| {
485                this.prefix(
486                    h_flex()
487                        .items_center()
488                        .top_0()
489                        // Right -1 for avoid border overlap with the first tab
490                        .right(-px(1.))
491                        .border_r_1()
492                        .border_b_1()
493                        .h_full()
494                        .border_color(cx.theme().border)
495                        .bg(cx.theme().tokens.tab_bar)
496                        .px_2()
497                        .children(left_button)
498                        .children(bottom_button),
499                )
500            })
501            .children(
502                visible
503                    .into_iter()
504                    .map(|ix| {
505                        let panel = &group.panels()[ix];
506                        let handle = PanelHandle::of(panel);
507                        let drag = tab_drag(group, ix, cx);
508
509                        Tab::new()
510                            .ix(ix)
511                            .debug_selector(move || format!("{TAB_SELECTOR}-{ix}"))
512                            .tab_bar_prefix(has_leading)
513                            .map(|this| match handle.and_then(|handle| handle.tab_name(cx)) {
514                                Some(tab_name) => this.child(tab_name),
515                                None => this.child(panel_title(panel, window, cx)),
516                            })
517                            // Per-tab close (X) button. The gate mirrors
518                            // `TabGroup::close_panel`, plus `!collapsed`, since a
519                            // collapsed strip is a way back in, not a place to
520                            // close. Stops propagation so the click closes by id
521                            // without also selecting the tab.
522                            .when(
523                                !collapsed
524                                    && self.shared.close_button_visible.get()
525                                    && group.is_panel_closable(panel.panel_id(cx), cx),
526                                |this| {
527                                    this.suffix(
528                                        Button::new(("close-tab", ix))
529                                            .icon(IconName::Close)
530                                            .xsmall()
531                                            // The regular ghost hover matches the
532                                            // inactive tab bar background in the
533                                            // default theme. Use a stronger theme
534                                            // surface while keeping a transparent
535                                            // idle background and no border.
536                                            .custom(
537                                                ButtonCustomVariant::new(cx)
538                                                    .foreground(cx.theme().secondary_foreground)
539                                                    .hover(*cx.theme().tokens.secondary_hover)
540                                                    .active(*cx.theme().tokens.secondary_active),
541                                            )
542                                            // The 20px XS button has a 12px icon:
543                                            // 4px inside plus 8px outside matches
544                                            // the label's 12px leading padding.
545                                            // Offset the label's own 12px right
546                                            // padding and the tab's 4px gap so the
547                                            // text-to-icon distance is also 12px.
548                                            .ml(-px(8.))
549                                            .mr_2()
550                                            .tab_stop(false)
551                                            .debug_selector(|| CLOSE_BUTTON_SELECTOR.to_string())
552                                            .on_click({
553                                                let group = group.clone();
554                                                let panel_id = panel.panel_id(cx);
555                                                move |_, window, cx| {
556                                                    cx.stop_propagation();
557                                                    group.close(panel_id, window, cx);
558                                                }
559                                            }),
560                                    )
561                                },
562                            )
563                            // A collapsed group shows no tab as active: the
564                            // strip is a way back in, not a selection. The
565                            // comparison is against the panel on screen, not
566                            // the stored index: a hidden displayed tab falls
567                            // back to the first visible one.
568                            .selected(!collapsed && Some(ix) == displayed_ix)
569                            .on_click({
570                                let group = group.clone();
571                                let area = self.shared.area().clone();
572                                let last_active_ix = self.last_active_ix.clone();
573                                move |_, window, cx| {
574                                    // The tab bar reveals a clicked tab itself,
575                                    // so the change must not snap it into view.
576                                    last_active_ix.set(Some(ix));
577                                    group.select_tab(ix, window, cx);
578
579                                    // Clicking the strip of a collapsed bottom
580                                    // dock is how it is opened again.
581                                    if is_bottom_dock && collapsed {
582                                        _ = area.update(cx, |area, cx| {
583                                            toggle_dock_by_user(
584                                                area,
585                                                DockPlacement::Bottom,
586                                                window,
587                                                cx,
588                                            )
589                                        });
590                                    }
591                                }
592                            })
593                            // A collapsed group is a strip of tabs with no
594                            // content, so there is nothing to rearrange in it.
595                            .when(!collapsed, |this| {
596                                this.when_some(drag, |this, drag| {
597                                    this.on_drag(drag, {
598                                        let panel = panel.clone();
599                                        move |drag, offset, _, cx| {
600                                            cx.stop_propagation();
601                                            drag.set_drag_offset(offset);
602                                            drag.set_preview_size(DRAG_PREVIEW_SIZE);
603                                            cx.new(|_| DragPanelPreview {
604                                                panel: panel.clone(),
605                                            })
606                                        }
607                                    })
608                                })
609                                .when(droppable, |this| {
610                                    this.drag_over::<DragPanel>(|this, _, _, cx| {
611                                        this.rounded_l_none()
612                                            .border_l_2()
613                                            .border_r_0()
614                                            .border_color(cx.theme().drag_border)
615                                    })
616                                    .on_drop({
617                                        let group = group.clone();
618                                        move |drag: &DragPanel, window, cx| {
619                                            group.drop_panel(
620                                                drag.clone(),
621                                                Some(ix),
622                                                true,
623                                                window,
624                                                cx,
625                                            );
626                                        }
627                                    })
628                                    .drag_over::<AnyDrag>(|this, _, _, cx| {
629                                        this.rounded_l_none()
630                                            .border_l_2()
631                                            .border_r_0()
632                                            .border_color(cx.theme().drag_border)
633                                    })
634                                    .on_drop({
635                                        let group = group.clone();
636                                        move |item: &AnyDrag, window, cx| {
637                                            group.drop_item(item.clone(), None, window, cx);
638                                        }
639                                    })
640                                })
641                            })
642                    })
643                    .collect::<Vec<_>>(),
644            )
645            .last_empty_space(
646                // Empty space so a panel can be moved past the last tab.
647                div()
648                    .id("tab-bar-empty-space")
649                    .h_full()
650                    .flex_grow_1()
651                    .min_w_16()
652                    .when(droppable, |this| {
653                        this.drag_over::<DragPanel>(|this, _, _, cx| {
654                            this.bg(cx.theme().tokens.drop_target)
655                        })
656                        .on_drop({
657                            let group = group.clone();
658                            let node = group.node();
659                            move |drag: &DragPanel, window, cx| {
660                                // A panel dropped past its own last tab lands
661                                // in the final slot; one from elsewhere is
662                                // appended in the background.
663                                let ix = (drag.source() == node).then(|| tabs_count - 1);
664                                group.drop_panel(drag.clone(), ix, false, window, cx);
665                            }
666                        })
667                        .drag_over::<AnyDrag>(|this, _, _, cx| {
668                            this.bg(cx.theme().tokens.drop_target)
669                        })
670                        .on_drop({
671                            let group = group.clone();
672                            move |item: &AnyDrag, window, cx| {
673                                group.drop_item(item.clone(), None, window, cx);
674                            }
675                        })
676                    }),
677            )
678            .when(!collapsed, |this| {
679                this.suffix(
680                    h_flex()
681                        .items_center()
682                        .top_0()
683                        .right_0()
684                        .border_l_1()
685                        .border_b_1()
686                        .h_full()
687                        .border_color(cx.theme().border)
688                        .bg(cx.theme().tokens.tab_bar)
689                        .px_2()
690                        .gap_1()
691                        .children(
692                            group
693                                .active_panel()
694                                .and_then(PanelHandle::of)
695                                .and_then(|handle| handle.title_suffix(window, cx)),
696                        )
697                        .child(self.render_toolbar(group, window, cx))
698                        .children(right_button),
699                )
700            })
701            .into_any_element()
702    }
703}
704
705impl TabGroupRenderer for TabGroupSkin {
706    fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
707        let control = zoom_control(group, cx);
708
709        // The column, the fill and the clip are base's now, applied around
710        // this. What is left is the background and the two actions.
711        div()
712            .id("tab-panel")
713            .bg(cx.theme().tokens.background)
714            // A collapsed group is a strip of tabs with no content, and the
715            // actions act on content. The old dock gated them the same way.
716            .when(!group.is_collapsed(), |this| {
717                this.on_action({
718                    let group = group.clone();
719                    move |_: &ToggleZoom, window, cx| {
720                        // The affordance decides the control, so a panel that
721                        // offers none is not zoomed *in* by the keybinding
722                        // either. Zooming out is never refused: a panel that
723                        // stopped offering the control while zoomed would
724                        // otherwise strand the user with no way back.
725                        if !group.is_zoomed() && control.is_none() {
726                            return;
727                        }
728                        group.toggle_zoom(window, cx);
729                    }
730                })
731                .on_action({
732                    let group = group.clone();
733                    move |_: &ClosePanel, window, cx| {
734                        let Some(panel) = group.active_panel() else {
735                            return;
736                        };
737                        let panel = panel.panel_id(cx);
738                        group.close(panel, window, cx);
739                    }
740                })
741            })
742    }
743
744    fn content_frame(
745        &self,
746        group: &TabGroupContext,
747        _: &mut Window,
748        cx: &mut App,
749    ) -> Stateful<Div> {
750        let padded = group.panels().len() > 1
751            && group
752                .active_panel()
753                .and_then(PanelHandle::of)
754                .is_none_or(|handle| handle.inner_padding(cx));
755
756        // The fill and the collapsed-group exception are base's; the padding
757        // is this skin's, and is the only reason this hook is implemented.
758        div().id("active-panel").when(padded, |this| this.pt_2())
759    }
760
761    fn render_tab_bar(
762        &self,
763        group: &TabGroupContext,
764        window: &mut Window,
765        cx: &mut App,
766    ) -> AnyElement {
767        let visible: Vec<usize> = group
768            .panels()
769            .iter()
770            .enumerate()
771            .filter(|(_, panel)| panel.visible(cx))
772            .map(|(ix, _)| ix)
773            .collect();
774
775        match visible.as_slice() {
776            [] => Empty.into_any_element(),
777            [ix] if self.shared.panel_style() == PanelStyle::Auto => {
778                // A panel that draws its own chrome declines the title bar.
779                let panel = &group.panels()[*ix];
780                if PanelHandle::of(panel).is_some_and(|handle| !handle.title_bar(cx)) {
781                    return Empty.into_any_element();
782                }
783                self.render_title(group, *ix, window, cx)
784            }
785            _ => self.render_tabs(group, window, cx),
786        }
787    }
788
789    fn render_active_panel(
790        &self,
791        panel: AnyView,
792        group: &TabGroupContext,
793        _: &mut Window,
794        _: &mut App,
795    ) -> AnyElement {
796        if group.is_collapsed() {
797            return Empty.into_any_element();
798        }
799
800        div()
801            .id("tab-content")
802            .overflow_y_scroll()
803            .overflow_x_hidden()
804            .flex_1()
805            .child(panel.cached(StyleRefinement::default().absolute().size_full()))
806            .into_any_element()
807    }
808
809    fn render_drop_indicator(
810        &self,
811        indicator: DropIndicator,
812        window: &mut Window,
813        cx: &mut App,
814    ) -> Option<AnyElement> {
815        let to = indicator.to();
816        // The placeholder chases the drop it would land in. Its rect was
817        // previously replayed from the drag source on every epoch, so crossing
818        // several drop zones in one drag restarted the walk at each one; the
819        // springs carry it through instead, and the element no longer needs an
820        // outer frame to hold the destination while an inner one walks to it.
821        let id = "drop-placeholder";
822        let placeholder_spring = cx.theme().motion_tokens().spring_move.with_epsilon(0.5);
823        let left = spring((id, "left"), to.origin().x, placeholder_spring, window, cx);
824        let top = spring((id, "top"), to.origin().y, placeholder_spring, window, cx);
825        let width = spring(
826            (id, "width"),
827            to.size().width,
828            placeholder_spring,
829            window,
830            cx,
831        );
832        let height = spring(
833            (id, "height"),
834            to.size().height,
835            placeholder_spring,
836            window,
837            cx,
838        );
839
840        Some(
841            div()
842                .absolute()
843                .bg(cx.theme().tokens.drop_target)
844                .left(left)
845                .top(top)
846                .w(width)
847                .h(height)
848                .into_any_element(),
849        )
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use std::{
856        cell::{Cell, RefCell},
857        sync::Arc,
858    };
859
860    use gpui::{
861        Entity, EventEmitter, FocusHandle, Focusable, Modifiers, MouseButton, Pixels,
862        TestAppContext, VisualTestContext,
863    };
864    use gpui_base::dock::{DockArea, DockAreaRenderer, DockLayout, DockPlacement, PanelEvent};
865
866    use super::*;
867    use crate::{
868        ElementExt as _,
869        dock::{
870            DockSkin, Panel, panel_handle,
871            test_support::{HideableProbe, MeasuredProbe},
872        },
873    };
874
875    struct Probe {
876        focus_handle: FocusHandle,
877    }
878
879    impl Probe {
880        fn new(cx: &mut App) -> Entity<Self> {
881            cx.new(|cx| Self {
882                focus_handle: cx.focus_handle(),
883            })
884        }
885    }
886
887    impl gpui_base::dock::Panel for Probe {
888        fn panel_name(&self) -> &'static str {
889            "Probe"
890        }
891    }
892
893    impl Panel for Probe {}
894    impl EventEmitter<PanelEvent> for Probe {}
895
896    impl Focusable for Probe {
897        fn focus_handle(&self, _: &App) -> FocusHandle {
898            self.focus_handle.clone()
899        }
900    }
901
902    impl Render for Probe {
903        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
904            Empty
905        }
906    }
907
908    /// Draws nothing, but runs the real gates the skin's tab bar runs and
909    /// records what they decided for every group it was asked to draw.
910    #[derive(Default)]
911    struct Recorded {
912        /// One entry per tab: whether its tab would start a drag.
913        draggable: Vec<bool>,
914    }
915
916    struct Recorder {
917        log: Rc<RefCell<Recorded>>,
918    }
919
920    impl TabGroupRenderer for Recorder {
921        fn render_tab_bar(
922            &self,
923            group: &TabGroupContext,
924            _: &mut Window,
925            cx: &mut App,
926        ) -> AnyElement {
927            let mut log = self.log.borrow_mut();
928            for ix in 0..group.panels().len() {
929                log.draggable.push(tab_drag(group, ix, cx).is_some());
930            }
931            Empty.into_any_element()
932        }
933    }
934
935    impl DockAreaRenderer for Recorder {
936        fn frame(&self, _: &mut Window, _: &mut App) -> Stateful<Div> {
937            div().id("recorder").size_full()
938        }
939
940        fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
941            Rc::new(Recorder {
942                log: self.log.clone(),
943            })
944        }
945    }
946
947    fn recording_area(
948        cx: &mut TestAppContext,
949    ) -> (
950        Entity<DockArea>,
951        Rc<RefCell<Recorded>>,
952        &mut VisualTestContext,
953    ) {
954        cx.update(|cx| {
955            crate::init(cx);
956        });
957        let log = Rc::new(RefCell::new(Recorded::default()));
958        let renderer = Rc::new(Recorder { log: log.clone() });
959        let (area, cx) = cx.add_window_view(|window, cx| {
960            DockArea::new("skin", None, window, cx).with_renderer(renderer)
961        });
962        (area, log, cx)
963    }
964
965    /// The gate carried forward from the old `TabPanel::render`, which wrapped
966    /// `on_drag` in `.when(state.draggable, ..)`. Base does not enforce it:
967    /// `TabGroupContext::drag_panel` answers for any tab in range.
968    #[gpui::test]
969    fn the_last_group_in_a_dock_offers_no_drag(cx: &mut TestAppContext) {
970        let (area, log, cx) = recording_area(cx);
971
972        cx.update(|window, cx| {
973            let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
974            area.update(cx, |area, cx| area.set_center(layout, window, cx));
975        });
976        cx.run_until_parked();
977        log.borrow_mut().draggable.clear();
978        cx.update(|window, cx| window.draw(cx).clear(cx));
979
980        assert_eq!(
981            log.borrow().draggable,
982            vec![false],
983            "the only visible panel in the dock has nowhere to go, so its tab \
984             must not start a drag"
985        );
986    }
987
988    #[gpui::test]
989    fn a_group_beside_another_offers_a_drag(cx: &mut TestAppContext) {
990        let (area, log, cx) = recording_area(cx);
991
992        cx.update(|window, cx| {
993            let layout = DockLayout::h_split()
994                .child(
995                    DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
996                    None,
997                )
998                .child(
999                    DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx),
1000                    None,
1001                );
1002            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1003        });
1004        cx.run_until_parked();
1005        log.borrow_mut().draggable.clear();
1006        cx.update(|window, cx| window.draw(cx).clear(cx));
1007
1008        assert_eq!(
1009            log.borrow().draggable,
1010            vec![true, true],
1011            "each group has somewhere to go, so both tabs start a drag"
1012        );
1013    }
1014
1015    /// A panel that allows zooming and asks for the control in the toolbar.
1016    /// `Probe`'s default is `PanelControl::Menu`, which draws no button.
1017    struct ToolbarZoomProbe {
1018        focus_handle: FocusHandle,
1019    }
1020
1021    impl ToolbarZoomProbe {
1022        fn new(cx: &mut App) -> Entity<Self> {
1023            cx.new(|cx| Self {
1024                focus_handle: cx.focus_handle(),
1025            })
1026        }
1027    }
1028
1029    impl gpui_base::dock::Panel for ToolbarZoomProbe {
1030        fn panel_name(&self) -> &'static str {
1031            "ToolbarZoomProbe"
1032        }
1033    }
1034
1035    impl Panel for ToolbarZoomProbe {
1036        fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1037            Some(PanelControl::Toolbar)
1038        }
1039    }
1040
1041    impl EventEmitter<PanelEvent> for ToolbarZoomProbe {}
1042
1043    impl Focusable for ToolbarZoomProbe {
1044        fn focus_handle(&self, _: &App) -> FocusHandle {
1045            self.focus_handle.clone()
1046        }
1047    }
1048
1049    impl Render for ToolbarZoomProbe {
1050        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1051            Empty
1052        }
1053    }
1054
1055    /// A panel that offers no zoom control but leaves base's `zoomable`
1056    /// default alone. Withholding the control is meant to be enough.
1057    struct NoControlProbe {
1058        focus_handle: FocusHandle,
1059    }
1060
1061    impl NoControlProbe {
1062        fn new(cx: &mut App) -> Entity<Self> {
1063            cx.new(|cx| Self {
1064                focus_handle: cx.focus_handle(),
1065            })
1066        }
1067    }
1068
1069    impl gpui_base::dock::Panel for NoControlProbe {
1070        fn panel_name(&self) -> &'static str {
1071            "NoControlProbe"
1072        }
1073    }
1074
1075    impl Panel for NoControlProbe {
1076        fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1077            None
1078        }
1079    }
1080
1081    impl EventEmitter<PanelEvent> for NoControlProbe {}
1082
1083    impl Focusable for NoControlProbe {
1084        fn focus_handle(&self, _: &App) -> FocusHandle {
1085            self.focus_handle.clone()
1086        }
1087    }
1088
1089    impl Render for NoControlProbe {
1090        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1091            Empty
1092        }
1093    }
1094
1095    /// A panel that says "never zoom" in base's half but still names a place
1096    /// for the control in this crate's half.
1097    struct UnzoomableProbe {
1098        focus_handle: FocusHandle,
1099    }
1100
1101    impl gpui_base::dock::Panel for UnzoomableProbe {
1102        fn panel_name(&self) -> &'static str {
1103            "UnzoomableProbe"
1104        }
1105
1106        fn zoomable(&self, _: &App) -> bool {
1107            false
1108        }
1109    }
1110
1111    impl Panel for UnzoomableProbe {
1112        fn zoom_control(&self, _: &App) -> Option<PanelControl> {
1113            Some(PanelControl::Toolbar)
1114        }
1115    }
1116
1117    impl EventEmitter<PanelEvent> for UnzoomableProbe {}
1118
1119    impl Focusable for UnzoomableProbe {
1120        fn focus_handle(&self, _: &App) -> FocusHandle {
1121            self.focus_handle.clone()
1122        }
1123    }
1124
1125    impl Render for UnzoomableProbe {
1126        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1127            Empty
1128        }
1129    }
1130
1131    /// Draw one panel through the real [`DockSkin`] and report whether its tab
1132    /// bar offered a zoom control.
1133    ///
1134    /// The real skin, not a recorder: the bug this guards is `render_toolbar`
1135    /// asking only half the question, so a test that calls `zoom_control`
1136    /// itself would pass with the bug in place.
1137    fn drew_zoom_control(
1138        cx: &mut TestAppContext,
1139        panel: impl FnOnce(&mut App) -> Arc<dyn gpui_base::dock::PanelView>,
1140    ) -> bool {
1141        cx.update(|cx| {
1142            crate::init(cx);
1143        });
1144        let (area, cx) = cx.add_window_view(|window, cx| {
1145            let skin = DockSkin::new(cx);
1146            DockArea::new("skin", None, window, cx).with_renderer(skin)
1147        });
1148
1149        cx.update(|window, cx| {
1150            let layout = DockLayout::tabs().panel_view(panel(cx), cx);
1151            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1152        });
1153        cx.run_until_parked();
1154        cx.update(|window, cx| window.draw(cx).clear(cx));
1155        cx.debug_bounds(ZOOM_CONTROL_SELECTOR).is_some()
1156    }
1157
1158    /// The two halves of the old `zoomable()` can now disagree, and base is
1159    /// the one that decides. A control drawn against base's refusal is dead:
1160    /// pressing it does nothing.
1161    #[gpui::test]
1162    fn a_panel_base_will_not_zoom_gets_no_zoom_control(cx: &mut TestAppContext) {
1163        let drew = drew_zoom_control(cx, |cx| {
1164            panel_handle(cx.new(|cx| UnzoomableProbe {
1165                focus_handle: cx.focus_handle(),
1166            }))
1167        });
1168
1169        assert!(
1170            !drew,
1171            "the panel names a place for the control, but base refuses the zoom"
1172        );
1173    }
1174
1175    /// The other half: a panel that allows zoom and asks for a toolbar control
1176    /// gets one drawn. Without this the test above would also pass a skin that
1177    /// never draws a zoom control at all.
1178    #[gpui::test]
1179    fn a_zoomable_panel_gets_its_zoom_control(cx: &mut TestAppContext) {
1180        let drew = drew_zoom_control(cx, |cx| panel_handle(ToolbarZoomProbe::new(cx)));
1181
1182        assert!(
1183            drew,
1184            "a zoomable panel asking for a toolbar control gets one"
1185        );
1186    }
1187
1188    /// The centre and the bottom dock share the centre column, and both get
1189    /// height.
1190    ///
1191    /// `DockSkin::center_frame` is a flex column holding the centre's root
1192    /// split and the bottom dock, and the centre's split frame sits between
1193    /// them. This pins that the frame carries a size at all: strip
1194    /// `split_frame` of both `size_full` and `flex_1` and every panel here
1195    /// measures zero. It does not pin *which* of the two does the work —
1196    /// either alone passes.
1197    #[gpui::test]
1198    fn the_centre_and_the_bottom_dock_share_the_column(cx: &mut TestAppContext) {
1199        cx.update(|cx| {
1200            crate::init(cx);
1201        });
1202        let centre = Rc::new(Cell::new(px(0.)));
1203        let bottom = Rc::new(Cell::new(px(0.)));
1204        let (area, cx) = cx.add_window_view(|window, cx| {
1205            let skin = DockSkin::new(cx);
1206            DockArea::new("skin", None, window, cx).with_renderer(skin)
1207        });
1208
1209        let (centre_probe, bottom_probe) = (centre.clone(), bottom.clone());
1210        cx.update(|window, cx| {
1211            let centre_panel = MeasuredProbe::new(centre_probe, cx);
1212            let bottom_panel = MeasuredProbe::new(bottom_probe, cx);
1213            area.update(cx, |area, cx| {
1214                // A split inside a split, so the *nested* `split_frame` — the
1215                // one that sits inside a `resizable_panel` — is exercised too,
1216                // not only the centre's root.
1217                area.set_center(
1218                    DockLayout::v_split().child(
1219                        DockLayout::h_split().child(
1220                            DockLayout::tabs().panel_view(panel_handle(centre_panel), cx),
1221                            None,
1222                        ),
1223                        None,
1224                    ),
1225                    window,
1226                    cx,
1227                );
1228                area.set_dock(
1229                    DockPlacement::Bottom,
1230                    DockLayout::tabs().panel_view(panel_handle(bottom_panel), cx),
1231                    window,
1232                    cx,
1233                );
1234                area.set_dock_size(DockPlacement::Bottom, px(200.), window, cx);
1235            });
1236        });
1237        cx.run_until_parked();
1238        cx.update(|window, cx| window.draw(cx).clear(cx));
1239
1240        let window_height = cx.update(|window, _| window.viewport_size().height);
1241        assert!(
1242            centre.get() > px(0.),
1243            "the centre panel must receive height; it got {:?}",
1244            centre.get()
1245        );
1246        assert!(
1247            bottom.get() > px(0.),
1248            "the bottom dock's panel must receive height; it got {:?}",
1249            bottom.get()
1250        );
1251        assert!(
1252            centre.get() < window_height - px(150.),
1253            "the centre must give the 200px bottom dock its share; the centre \
1254             got {:?} of {window_height:?}",
1255            centre.get()
1256        );
1257    }
1258
1259    /// Whichever slots of a split are hidden, the drawn ones fill it.
1260    ///
1261    /// `render_node` pins every slot but one to a fixed size and lets the
1262    /// remaining one absorb whatever the container has spare. Picking that
1263    /// slot by tree position alone picks a hidden one whenever the trailing
1264    /// container's panels are all hidden — nothing draws there, nothing
1265    /// grows, and the split stops short of its frame, showing a band of the
1266    /// frame's own background under the last visible panel. Hiding a slot is
1267    /// an everyday event: a panel that is only meaningful for some symbols
1268    /// answers `visible` with `false` for the rest.
1269    ///
1270    /// Every subset is covered because the defect is positional: only the
1271    /// cases that hide the trailing slot fail, and a test that hid one fixed
1272    /// slot would pass against a fix that only special-cased that slot.
1273    #[gpui::test]
1274    fn a_split_fills_its_container_whichever_slots_are_hidden(cx: &mut TestAppContext) {
1275        cx.update(|cx| {
1276            crate::init(cx);
1277        });
1278        let heights: Vec<Rc<Cell<Pixels>>> = (0..3).map(|_| Rc::new(Cell::new(px(0.)))).collect();
1279        let (area, cx) = cx.add_window_view(|window, cx| {
1280            let skin = DockSkin::new(cx);
1281            DockArea::new("skin", None, window, cx).with_renderer(skin)
1282        });
1283
1284        let slots = heights.clone();
1285        let probes = cx.update(|window, cx| {
1286            let probes: Vec<_> = slots
1287                .iter()
1288                .map(|height| HideableProbe::new(height.clone(), cx))
1289                .collect();
1290            area.update(cx, |area, cx| {
1291                area.set_dock(
1292                    DockPlacement::Right,
1293                    probes.iter().zip([260., 320., 200.]).fold(
1294                        DockLayout::v_split(),
1295                        |split, (probe, size)| {
1296                            split.child(
1297                                DockLayout::tabs().panel_view(panel_handle(probe.clone()), cx),
1298                                Some(px(size)),
1299                            )
1300                        },
1301                    ),
1302                    window,
1303                    cx,
1304                );
1305                area.set_dock_size(DockPlacement::Right, px(380.), window, cx);
1306            });
1307            probes
1308        });
1309        cx.run_until_parked();
1310        let draw = |cx: &mut VisualTestContext| {
1311            cx.update(|window, _| window.refresh());
1312            cx.update(|window, cx| window.draw(cx).clear(cx));
1313            cx.update(|window, cx| window.draw(cx).clear(cx));
1314        };
1315        draw(cx);
1316
1317        let dock_height = cx.update(|window, _| window.viewport_size().height);
1318        // Each slot spends a tab bar out of its height and the probe under it
1319        // measures the rest, so the drawn slots account for the whole dock
1320        // once one tab bar per drawn slot is added back.
1321        let drawn: Pixels = heights.iter().map(|height| height.get()).sum();
1322        let bar = (dock_height - drawn) / 3.;
1323        assert!(
1324            bar > px(0.) && bar < px(60.),
1325            "the three slots fill the dock to begin with, one tab bar each; \
1326             that leaves {bar:?} per slot of {dock_height:?}"
1327        );
1328
1329        // Every subset except "all three hidden", which gives the whole node
1330        // up to *its* parent and so has no container of its own to fill.
1331        for hidden in 1..0b111u8 {
1332            let shown = (0..3).filter(|slot| hidden & (1 << slot) == 0);
1333            cx.update(|_, cx| {
1334                for (slot, probe) in probes.iter().enumerate() {
1335                    // A sentinel, so a slot that stopped drawing is not read
1336                    // as one that kept the height it had.
1337                    heights[slot].set(px(-1.));
1338                    probe.update(cx, |probe, cx| {
1339                        probe.set_visible(hidden & (1 << slot) == 0, cx)
1340                    });
1341                }
1342            });
1343            cx.run_until_parked();
1344            draw(cx);
1345
1346            let mut count = 0;
1347            let mut total = px(0.);
1348            for slot in shown {
1349                assert_ne!(
1350                    heights[slot].get(),
1351                    px(-1.),
1352                    "hiding {hidden:03b}: slot {slot} is shown and must draw"
1353                );
1354                count += 1;
1355                total += heights[slot].get();
1356            }
1357            let empty = dock_height - total - bar * count as f32;
1358            assert!(
1359                empty.abs() < px(1.),
1360                "hiding {hidden:03b}: the drawn slots must take the hidden \
1361                 ones' space between them; they left {empty:?} of \
1362                 {dock_height:?} empty"
1363            );
1364        }
1365    }
1366
1367    /// The old dock installed `ToggleZoom` and `ClosePanel` on the tab panel
1368    /// itself; base installs neither, so the skin's `frame` is the only place
1369    /// the keybindings reach.
1370    #[gpui::test]
1371    fn the_zoom_action_reaches_the_group_through_the_skin(cx: &mut TestAppContext) {
1372        cx.update(|cx| {
1373            crate::init(cx);
1374        });
1375        let (area, cx) = cx.add_window_view(|window, cx| {
1376            let skin = DockSkin::new(cx);
1377            DockArea::new("skin", None, window, cx).with_renderer(skin)
1378        });
1379
1380        let panel = cx.update(|window, cx| {
1381            let panel = Probe::new(cx);
1382            let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1383            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1384            panel
1385        });
1386        cx.run_until_parked();
1387        cx.update(|window, cx| {
1388            panel.read(cx).focus_handle(cx).focus(window, cx);
1389        });
1390        cx.run_until_parked();
1391
1392        assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1393        cx.dispatch_action(ToggleZoom);
1394        cx.run_until_parked();
1395        assert_eq!(
1396            cx.read(|cx| area.read(cx).is_zoomed()),
1397            true,
1398            "the skin's frame is what carries the ToggleZoom handler"
1399        );
1400
1401        cx.dispatch_action(ToggleZoom);
1402        cx.run_until_parked();
1403        assert_eq!(cx.read(|cx| area.read(cx).is_zoomed()), false);
1404    }
1405
1406    /// Withholding the control withholds the whole affordance, keybinding
1407    /// included. The two halves of the zoom question are asked in one place —
1408    /// the skin's `frame` — so a doc claiming the action gets through anyway
1409    /// would send a panel author looking for a second switch that does not
1410    /// exist.
1411    #[gpui::test]
1412    fn the_zoom_action_refuses_a_panel_that_offers_no_control(cx: &mut TestAppContext) {
1413        cx.update(|cx| {
1414            crate::init(cx);
1415        });
1416        let (area, cx) = cx.add_window_view(|window, cx| {
1417            let skin = DockSkin::new(cx);
1418            DockArea::new("skin", None, window, cx).with_renderer(skin)
1419        });
1420
1421        let panel = cx.update(|window, cx| {
1422            let panel = NoControlProbe::new(cx);
1423            let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1424            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1425            panel
1426        });
1427        cx.run_until_parked();
1428        cx.update(|window, cx| {
1429            panel.read(cx).focus_handle(cx).focus(window, cx);
1430        });
1431        cx.run_until_parked();
1432
1433        cx.dispatch_action(ToggleZoom);
1434        cx.run_until_parked();
1435        assert_eq!(
1436            cx.read(|cx| area.read(cx).is_zoomed()),
1437            false,
1438            "no control means no zoom, however the zoom was asked for"
1439        );
1440    }
1441
1442    /// The tab group's own frame has to be a flex column.
1443    ///
1444    /// gpui's default display is Block, and block layout ignores a child's
1445    /// `flex_grow`. With a plain `div()` frame the content region's `flex_1`
1446    /// does nothing, `#tab-content` sizes to its content, and its only child
1447    /// is the panel view positioned absolutely by `cached` — which
1448    /// contributes no content height. The whole chain resolves to zero and
1449    /// the dock draws a tab bar with nothing under it.
1450    ///
1451    /// This asserts a dimension, which the project's testing guidance
1452    /// discourages, because a zero-height content region is not a cosmetic
1453    /// difference: it is the panel not rendering at all, and no behavioral
1454    /// test in this crate can see it. `set_active` still fires, the layout
1455    /// still round-trips, and the window still opens.
1456    #[gpui::test]
1457    fn the_panel_content_region_gets_the_height_below_the_tab_bar(cx: &mut TestAppContext) {
1458        cx.update(|cx| {
1459            crate::init(cx);
1460        });
1461        let height = Rc::new(Cell::new(px(0.)));
1462        let (area, cx) = cx.add_window_view(|window, cx| {
1463            let skin = DockSkin::new(cx);
1464            DockArea::new("skin", None, window, cx).with_renderer(skin)
1465        });
1466
1467        let measured = height.clone();
1468        cx.update(|window, cx| {
1469            let panel = MeasuredProbe::new(measured, cx);
1470            let layout = DockLayout::tabs().panel_view(panel_handle(panel), cx);
1471            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1472        });
1473        cx.run_until_parked();
1474        cx.update(|window, cx| window.draw(cx).clear(cx));
1475
1476        let window_height = cx.update(|window, _| window.viewport_size().height);
1477        let content = height.get();
1478        assert!(
1479            content > px(0.),
1480            "the panel must receive height; it got {content:?} in a {window_height:?} window"
1481        );
1482        // The tab bar is 30px and the padded content region adds none for a
1483        // single tab, so the panel should get nearly the whole window.
1484        assert!(
1485            content > window_height - px(60.),
1486            "the panel should fill what the tab bar leaves; it got {content:?} \
1487             of {window_height:?}"
1488        );
1489    }
1490
1491    /// A collapsed group is a strip of tabs with no content, and the actions
1492    /// act on content. The old `TabPanel::bind_actions` gated them the same
1493    /// way.
1494    ///
1495    /// The bottom dock, not a side one: a closed left or right dock draws
1496    /// nothing at all, so it would pass this whether or not the gate exists.
1497    #[gpui::test]
1498    fn a_collapsed_dock_ignores_the_zoom_action(cx: &mut TestAppContext) {
1499        cx.update(|cx| {
1500            crate::init(cx);
1501        });
1502        let (area, cx) = cx.add_window_view(|window, cx| {
1503            let skin = DockSkin::new(cx);
1504            DockArea::new("skin", None, window, cx).with_renderer(skin)
1505        });
1506
1507        let panel = cx.update(|window, cx| {
1508            let panel = Probe::new(cx);
1509            let layout = DockLayout::tabs().panel_view(panel_handle(panel.clone()), cx);
1510            area.update(cx, |area, cx| {
1511                area.set_dock(DockPlacement::Bottom, layout, window, cx);
1512                area.toggle_dock(DockPlacement::Bottom, window, cx);
1513            });
1514            panel
1515        });
1516        cx.run_until_parked();
1517        cx.update(|window, cx| {
1518            panel.read(cx).focus_handle(cx).focus(window, cx);
1519        });
1520        cx.run_until_parked();
1521
1522        cx.dispatch_action(ToggleZoom);
1523        cx.run_until_parked();
1524        assert_eq!(
1525            cx.read(|cx| area.read(cx).is_zoomed()),
1526            false,
1527            "a collapsed group installs no action handler"
1528        );
1529    }
1530
1531    /// A panel that carries its own chrome declines the one-panel title bar
1532    /// and gets the whole group.
1533    #[gpui::test]
1534    fn a_panel_without_a_title_bar_gets_the_whole_group(cx: &mut TestAppContext) {
1535        struct Chromeless {
1536            focus_handle: FocusHandle,
1537            height: Rc<Cell<Pixels>>,
1538        }
1539
1540        impl gpui_base::dock::Panel for Chromeless {
1541            fn panel_name(&self) -> &'static str {
1542                "Chromeless"
1543            }
1544        }
1545
1546        impl Panel for Chromeless {
1547            fn title_bar(&self, _: &App) -> bool {
1548                false
1549            }
1550        }
1551
1552        impl EventEmitter<PanelEvent> for Chromeless {}
1553
1554        impl Focusable for Chromeless {
1555            fn focus_handle(&self, _: &App) -> FocusHandle {
1556                self.focus_handle.clone()
1557            }
1558        }
1559
1560        impl Render for Chromeless {
1561            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1562                let height = self.height.clone();
1563                div()
1564                    .size_full()
1565                    .on_prepaint(move |bounds, _, _| height.set(bounds.size.height))
1566            }
1567        }
1568
1569        cx.update(|cx| crate::init(cx));
1570
1571        let measure = |cx: &mut TestAppContext, chromeless: bool| -> Pixels {
1572            let height = Rc::new(Cell::new(px(0.)));
1573            let (area, cx) = cx.add_window_view(|window, cx| {
1574                let skin = DockSkin::new(cx);
1575                DockArea::new("skin", None, window, cx).with_renderer(skin)
1576            });
1577            let measured = height.clone();
1578            cx.update(|window, cx| {
1579                let panel: Arc<dyn gpui_base::dock::PanelView> = if chromeless {
1580                    panel_handle(cx.new(|cx| Chromeless {
1581                        focus_handle: cx.focus_handle(),
1582                        height: measured,
1583                    }))
1584                } else {
1585                    panel_handle(MeasuredProbe::new(measured, cx))
1586                };
1587                let layout = DockLayout::tabs().panel_view(panel, cx);
1588                area.update(cx, |area, cx| area.set_center(layout, window, cx));
1589            });
1590            cx.run_until_parked();
1591            cx.update(|window, cx| window.draw(cx).clear(cx));
1592            height.get()
1593        };
1594
1595        let with_title = measure(cx, false);
1596        let without = measure(cx, true);
1597        assert!(with_title > px(0.), "the probe must have been drawn");
1598        assert_eq!(
1599            without,
1600            with_title + px(30.),
1601            "the 30px the title bar took go to the panel instead"
1602        );
1603    }
1604
1605    /// Panel with a chosen `closable` that records whether it was made active.
1606    struct TabProbe {
1607        focus_handle: FocusHandle,
1608        closable: bool,
1609        activated: Rc<Cell<bool>>,
1610    }
1611
1612    impl TabProbe {
1613        fn new(closable: bool, cx: &mut App) -> Entity<Self> {
1614            cx.new(|cx| Self {
1615                focus_handle: cx.focus_handle(),
1616                closable,
1617                activated: Rc::new(Cell::new(false)),
1618            })
1619        }
1620    }
1621
1622    impl gpui_base::dock::Panel for TabProbe {
1623        fn panel_name(&self) -> &'static str {
1624            "TabProbe"
1625        }
1626
1627        fn closable(&self, _: &App) -> bool {
1628            self.closable
1629        }
1630
1631        fn set_active(&mut self, active: bool, _: &mut Window, _: &mut Context<Self>) {
1632            if active {
1633                self.activated.set(true);
1634            }
1635        }
1636    }
1637
1638    impl Panel for TabProbe {}
1639
1640    impl EventEmitter<PanelEvent> for TabProbe {}
1641
1642    impl Focusable for TabProbe {
1643        fn focus_handle(&self, _: &App) -> FocusHandle {
1644            self.focus_handle.clone()
1645        }
1646    }
1647
1648    impl Render for TabProbe {
1649        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1650            Empty
1651        }
1652    }
1653
1654    /// Render a two-tab group through the real [`DockSkin`] and report whether
1655    /// the first tab drew a close button. The second tab is a non-closable
1656    /// filler (a lone panel draws no tab bar) that never draws one, so the
1657    /// probe is unambiguous.
1658    fn drew_close_button(cx: &mut TestAppContext, closable: bool, enabled: bool) -> bool {
1659        cx.update(|cx| crate::init(cx));
1660        let mut skin = None;
1661        let (area, cx) = cx.add_window_view(|window, cx| {
1662            let renderer = DockSkin::new(cx);
1663            skin = Some(renderer.clone());
1664            DockArea::new("skin", None, window, cx).with_renderer(renderer)
1665        });
1666        let skin = skin.expect("skin constructed with the area");
1667        if enabled {
1668            cx.update(|_, cx| skin.set_close_button_visible(true, cx));
1669        }
1670        cx.update(|window, cx| {
1671            let under_test = TabProbe::new(closable, cx);
1672            let filler = TabProbe::new(false, cx);
1673            let layout = DockLayout::tabs()
1674                .panel_view(panel_handle(under_test), cx)
1675                .panel_view(panel_handle(filler), cx);
1676            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1677        });
1678        cx.run_until_parked();
1679        cx.update(|window, cx| window.draw(cx).clear(cx));
1680        let button = cx.debug_bounds(CLOSE_BUTTON_SELECTOR);
1681        if enabled && closable {
1682            assert_eq!(
1683                button.as_ref().map(|bounds| bounds.size.width),
1684                Some(px(20.)),
1685                "the close button keeps the standard XS hover target"
1686            );
1687        }
1688        button.is_some()
1689    }
1690
1691    /// A closable panel's tab carries a close (X) button.
1692    #[gpui::test]
1693    fn a_closable_panel_gets_a_close_button(cx: &mut TestAppContext) {
1694        assert!(
1695            drew_close_button(cx, true, true),
1696            "a closable panel's tab must draw a close button"
1697        );
1698    }
1699
1700    /// A non-closable panel draws no close button (the gate is real).
1701    #[gpui::test]
1702    fn a_non_closable_panel_gets_no_close_button(cx: &mut TestAppContext) {
1703        assert!(
1704            !drew_close_button(cx, false, true),
1705            "a panel that reports itself non-closable must draw no close button"
1706        );
1707    }
1708
1709    #[gpui::test]
1710    fn close_buttons_are_off_by_default(cx: &mut TestAppContext) {
1711        assert!(!drew_close_button(cx, true, false));
1712    }
1713
1714    #[gpui::test]
1715    fn close_button_visibility_updates_after_the_skin_setting_changes(cx: &mut TestAppContext) {
1716        cx.update(|cx| crate::init(cx));
1717        let mut skin = None;
1718        let (area, cx) = cx.add_window_view(|window, cx| {
1719            let renderer = DockSkin::new(cx);
1720            skin = Some(renderer.clone());
1721            DockArea::new("skin", None, window, cx).with_renderer(renderer)
1722        });
1723        let skin = skin.expect("skin constructed with the area");
1724        cx.update(|window, cx| {
1725            let panel = TabProbe::new(true, cx);
1726            let filler = TabProbe::new(false, cx);
1727            area.update(cx, |area, cx| {
1728                area.set_center(
1729                    DockLayout::tabs()
1730                        .panel_view(panel_handle(panel), cx)
1731                        .panel_view(panel_handle(filler), cx),
1732                    window,
1733                    cx,
1734                )
1735            });
1736        });
1737
1738        for (visible, expected) in [(false, false), (true, true), (false, false)] {
1739            cx.update(|_, cx| skin.set_close_button_visible(visible, cx));
1740            cx.run_until_parked();
1741            cx.update(|window, cx| window.draw(cx).clear(cx));
1742            assert_eq!(cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_some(), expected);
1743        }
1744    }
1745
1746    /// Clicking a non-active tab's close button removes that panel by id and
1747    /// does not select it (`stop_propagation`). The displayed tab is a
1748    /// non-closable filler, so the only close button is the one under test.
1749    #[gpui::test]
1750    fn clicking_a_close_button_removes_only_that_tab(cx: &mut TestAppContext) {
1751        cx.update(|cx| crate::init(cx));
1752        let mut skin = None;
1753        let (area, cx) = cx.add_window_view(|window, cx| {
1754            let renderer = DockSkin::new(cx);
1755            skin = Some(renderer.clone());
1756            DockArea::new("skin", None, window, cx).with_renderer(renderer)
1757        });
1758        cx.update(|_, cx| skin.unwrap().set_close_button_visible(true, cx));
1759
1760        let (closable_id, filler_id, activated) = cx.update(|window, cx| {
1761            let filler = TabProbe::new(false, cx);
1762            let closable = TabProbe::new(true, cx);
1763            let activated = closable.read(cx).activated.clone();
1764            let filler_handle = panel_handle(filler);
1765            let closable_handle = panel_handle(closable);
1766            let ids = (closable_handle.panel_id(cx), filler_handle.panel_id(cx));
1767            // Filler at ix 0 is the displayed tab; the closable tab at ix 1 is
1768            // never active, so its close button is the only one drawn.
1769            let layout = DockLayout::tabs()
1770                .panel_view(filler_handle, cx)
1771                .panel_view(closable_handle, cx);
1772            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1773            (ids.0, ids.1, activated)
1774        });
1775        cx.run_until_parked();
1776        cx.update(|window, cx| window.draw(cx).clear(cx));
1777
1778        assert!(
1779            cx.update(|_, cx| area.read(cx).panel(closable_id).is_some()),
1780            "the closable tab starts owned by the area"
1781        );
1782
1783        let button = cx
1784            .debug_bounds(CLOSE_BUTTON_SELECTOR)
1785            .expect("the non-active closable tab draws a close button");
1786        cx.simulate_mouse_down(button.center(), MouseButton::Left, Modifiers::none());
1787        cx.simulate_mouse_up(button.center(), MouseButton::Left, Modifiers::none());
1788        cx.run_until_parked();
1789
1790        assert!(
1791            cx.update(|_, cx| area.read(cx).panel(closable_id).is_none()),
1792            "the close click removes exactly the tab it belongs to"
1793        );
1794        assert!(
1795            cx.update(|_, cx| area.read(cx).panel(filler_id).is_some()),
1796            "the displayed filler tab is untouched"
1797        );
1798        assert!(
1799            !activated.get(),
1800            "stop_propagation keeps the close click from also selecting the tab"
1801        );
1802    }
1803
1804    /// A click is left to the tab bar's animated reveal: the skin's own snap
1805    /// for a changed tab would land the strip on the first frame and cut the
1806    /// animation short.
1807    #[gpui::test]
1808    fn a_clicked_tab_is_revealed_by_the_tab_bar_not_snapped(cx: &mut TestAppContext) {
1809        cx.update(|cx| {
1810            crate::init(cx);
1811            cx.set_reduce_motion(false);
1812        });
1813        let (area, cx) = cx.add_window_view(|window, cx| {
1814            DockArea::new("skin", None, window, cx).with_renderer(DockSkin::new(cx))
1815        });
1816        cx.simulate_resize(gpui::size(px(300.), px(300.)));
1817        cx.update(|window, cx| {
1818            let layout = (0..8).fold(DockLayout::tabs(), |layout, _| {
1819                layout.panel_view(panel_handle(Probe::new(cx)), cx)
1820            });
1821            area.update(cx, |area, cx| area.set_center(layout, window, cx));
1822        });
1823        cx.run_until_parked();
1824        cx.update(|window, cx| window.draw(cx).clear(cx));
1825
1826        // `debug_bounds` takes a `'static` selector.
1827        let tab = |cx: &mut VisualTestContext, ix: usize| {
1828            cx.debug_bounds(format!("{TAB_SELECTOR}-{ix}").leak())
1829                .unwrap()
1830        };
1831        let first_left = |cx: &mut VisualTestContext| tab(cx, 0).left();
1832        let start = first_left(cx);
1833        // The tab that runs off the 300px window: only its first few pixels
1834        // show before the bar's toolbar.
1835        let clipped = (0..8)
1836            .map(|ix| tab(cx, ix))
1837            .find(|tab| tab.right() > px(300.))
1838            .unwrap();
1839
1840        cx.simulate_click(
1841            clipped.origin + gpui::point(px(4.), px(16.)),
1842            Modifiers::none(),
1843        );
1844        cx.update(|window, cx| window.draw(cx).clear(cx));
1845        assert_eq!(first_left(cx), start, "the first frame has not snapped");
1846
1847        let mut lefts = Vec::new();
1848        for _ in 0..60 {
1849            cx.executor()
1850                .advance_clock(std::time::Duration::from_millis(16));
1851            cx.update(|window, cx| {
1852                window.simulate_next_frame(cx);
1853                window.draw(cx).clear(cx);
1854            });
1855            lefts.push(first_left(cx));
1856        }
1857        let end = *lefts.last().unwrap();
1858        assert!(end < start, "the strip scrolled");
1859        assert!(
1860            lefts.iter().any(|left| *left < start && *left > end),
1861            "the strip slid rather than jumped: {lefts:?}"
1862        );
1863    }
1864
1865    /// The collapse button reports the user's toggle; a toggle made by code
1866    /// does not.
1867    #[gpui::test]
1868    fn the_collapse_button_reports_the_toggle(cx: &mut TestAppContext) {
1869        cx.update(|cx| crate::init(cx));
1870        let (area, cx) = cx.add_window_view(|window, cx| {
1871            DockArea::new("toggle", None, window, cx).with_renderer(DockSkin::new(cx))
1872        });
1873        cx.simulate_resize(gpui::size(px(800.), px(600.)));
1874        let events = Rc::new(RefCell::new(Vec::new()));
1875        cx.update(|window, cx| {
1876            let center = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
1877            let left = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
1878            area.update(cx, |area, cx| {
1879                area.set_center(center, window, cx);
1880                area.set_dock(DockPlacement::Left, left, window, cx);
1881            });
1882            let events = events.clone();
1883            cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
1884                events
1885                    .borrow_mut()
1886                    .push((event.placement(), event.is_open()));
1887            })
1888            .detach();
1889        });
1890        cx.run_until_parked();
1891        cx.update(|window, cx| window.draw(cx).clear(cx));
1892
1893        let selector = format!("{DOCK_TOGGLE_SELECTOR}-{:?}", DockPlacement::Left).leak();
1894        let button = cx
1895            .debug_bounds(selector)
1896            .expect("the left dock offers a toggle");
1897        cx.simulate_click(button.center(), Modifiers::none());
1898        cx.run_until_parked();
1899        assert!(!cx.read(|cx| area.read(cx).is_dock_open(DockPlacement::Left)));
1900        assert_eq!(*events.borrow(), vec![(DockPlacement::Left, false)]);
1901
1902        cx.update(|window, cx| {
1903            area.update(cx, |area, cx| {
1904                area.toggle_dock(DockPlacement::Left, window, cx)
1905            });
1906        });
1907        cx.run_until_parked();
1908        assert_eq!(
1909            events.borrow().len(),
1910            1,
1911            "a toggle made by code is not reported"
1912        );
1913    }
1914
1915    /// Clicking a tab on a collapsed bottom dock's strip opens the dock and
1916    /// reports it.
1917    #[gpui::test]
1918    fn opening_the_bottom_strip_reports_the_toggle(cx: &mut TestAppContext) {
1919        cx.update(|cx| crate::init(cx));
1920        let (area, cx) = cx.add_window_view(|window, cx| {
1921            DockArea::new("strip", None, window, cx).with_renderer(DockSkin::new(cx))
1922        });
1923        cx.simulate_resize(gpui::size(px(800.), px(600.)));
1924        let events = Rc::new(RefCell::new(Vec::new()));
1925        cx.update(|window, cx| {
1926            // Two panels: a group with one panel draws its title, not a tab bar.
1927            let layout = DockLayout::tabs()
1928                .panel_view(panel_handle(Probe::new(cx)), cx)
1929                .panel_view(panel_handle(Probe::new(cx)), cx);
1930            area.update(cx, |area, cx| {
1931                area.set_dock(DockPlacement::Bottom, layout, window, cx);
1932                area.toggle_dock(DockPlacement::Bottom, window, cx);
1933            });
1934            let events = events.clone();
1935            cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
1936                events
1937                    .borrow_mut()
1938                    .push((event.placement(), event.is_open()));
1939            })
1940            .detach();
1941        });
1942        cx.run_until_parked();
1943        cx.update(|window, cx| window.draw(cx).clear(cx));
1944        assert!(!cx.read(|cx| area.read(cx).is_dock_open(DockPlacement::Bottom)));
1945
1946        let tab = cx
1947            .debug_bounds(format!("{TAB_SELECTOR}-0").leak())
1948            .expect("the collapsed strip still draws its tab");
1949        cx.simulate_click(tab.center(), Modifiers::none());
1950        cx.run_until_parked();
1951        assert_eq!(*events.borrow(), vec![(DockPlacement::Bottom, true)]);
1952    }
1953
1954    /// A dock that refuses to close is not reported as toggled.
1955    #[gpui::test]
1956    fn a_refused_toggle_is_not_reported(cx: &mut TestAppContext) {
1957        cx.update(|cx| crate::init(cx));
1958        let (area, cx) = cx.add_window_view(|window, cx| {
1959            DockArea::new("refuse", None, window, cx).with_renderer(DockSkin::new(cx))
1960        });
1961        let events = Rc::new(RefCell::new(Vec::new()));
1962        cx.update(|window, cx| {
1963            let layout = DockLayout::tabs().panel_view(panel_handle(Probe::new(cx)), cx);
1964            area.update(cx, |area, cx| {
1965                area.set_dock(DockPlacement::Left, layout, window, cx);
1966                area.set_dock_collapsible(DockPlacement::Left, false, window, cx);
1967            });
1968            let events = events.clone();
1969            cx.subscribe(&area, move |_, event: &crate::dock::DockToggleEvent, _| {
1970                events
1971                    .borrow_mut()
1972                    .push((event.placement(), event.is_open()));
1973            })
1974            .detach();
1975        });
1976        cx.update(|window, cx| {
1977            area.update(cx, |area, cx| {
1978                toggle_dock_by_user(area, DockPlacement::Left, window, cx)
1979            });
1980        });
1981        cx.run_until_parked();
1982        assert!(cx.read(|cx| area.read(cx).is_dock_open(DockPlacement::Left)));
1983        assert!(events.borrow().is_empty());
1984    }
1985
1986    /// A collapsed group offers no close button, though the same group does
1987    /// while open. Before/after collapse isolates `!collapsed`; nothing else
1988    /// changes.
1989    #[gpui::test]
1990    fn a_collapsed_group_draws_no_close_button(cx: &mut TestAppContext) {
1991        cx.update(|cx| crate::init(cx));
1992        let mut skin = None;
1993        let (area, cx) = cx.add_window_view(|window, cx| {
1994            let renderer = DockSkin::new(cx);
1995            skin = Some(renderer.clone());
1996            DockArea::new("skin", None, window, cx).with_renderer(renderer)
1997        });
1998        cx.update(|_, cx| skin.unwrap().set_close_button_visible(true, cx));
1999        // Two closable panels so the group is draggable (not on its last
2000        // visible panel) and offers close buttons while open.
2001        cx.update(|window, cx| {
2002            let a = TabProbe::new(true, cx);
2003            let b = TabProbe::new(true, cx);
2004            let layout = DockLayout::tabs()
2005                .panel_view(panel_handle(a), cx)
2006                .panel_view(panel_handle(b), cx);
2007            area.update(cx, |area, cx| {
2008                area.set_dock(DockPlacement::Bottom, layout, window, cx);
2009                if !area.is_dock_open(DockPlacement::Bottom) {
2010                    area.toggle_dock(DockPlacement::Bottom, window, cx);
2011                }
2012            });
2013        });
2014        cx.run_until_parked();
2015        cx.update(|window, cx| window.draw(cx).clear(cx));
2016        assert!(
2017            cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_some(),
2018            "an open group with two closable panels offers a close button"
2019        );
2020
2021        // Collapse the bottom dock: its strip stays clickable, but the close
2022        // buttons on it must not.
2023        cx.update(|window, cx| {
2024            area.update(cx, |area, cx| {
2025                area.toggle_dock(DockPlacement::Bottom, window, cx)
2026            });
2027        });
2028        cx.run_until_parked();
2029        cx.update(|window, cx| window.draw(cx).clear(cx));
2030        assert!(
2031            cx.debug_bounds(CLOSE_BUTTON_SELECTOR).is_none(),
2032            "a collapsed group must not offer a close button"
2033        );
2034    }
2035}