refactor(panel): give the detail panel's top zone back to the panel

The panel's title-bar-height top zone carried seven controls belonging to
three different layers: four panel tabs, the panel toggle, the "..." menu,
and the workspace chip. All seven at chrome scale, in a column the user can
drag down to MIN_WIDTH. The row wants 268px there — it overflows by 68px
before anything else goes wrong.

The layers are the real defect; the crowding is a symptom. Switching to
Files is "what does this panel show", the workspace chip is "which machine
am I on", and they sat side by side, same size, same weight.

Three changes, none of which touch the window layout — both columns stay
full-height and the corner controls stay where they are:

- The workspace chip moves to the head of the rail, as a full row with the
  workspace's name rather than a monogram in a corner. The rail below it
  enumerates that workspace's tabs, so this is where the name belongs; it
  also stops a window-scoped control from competing for the panel's width.
  Note it is *not* folded into the repo group headers under it — those are
  repositories, and one workspace holds several.

- The panel's tab tiles drop to TILE_SIZE_SM. That constant exists for
  tiles inside a panel, which is exactly what these are, and the one size
  step separates them from the window chrome sharing their row without
  spending a divider on it.

- On macOS panel_title draws nothing. The tile row above it already says
  which tab you are on, and so does the content — a file tree is Files, a
  diff is Changes. It cost a whole row: tiles, then a title, then a search
  box, before one line of content. Changes' file count moves into that
  tab's tooltip, where it stays readable without switching tabs. Outline's
  count does not survive: it needs the active leaf, which needs a &Window
  right_panel_tabs has no reason to take, and its list is one click away.
  A tab passing `trailing` still gets the row; none currently do.

Off macOS nothing changes: the panel hangs below a spanning title bar
there, and panel_title is still that panel's tab switcher.

The top zone now needs ~164px against a 200px minimum.
This commit is contained in:
l0ng-ai
2026-07-28 14:19:23 +08:00
parent d534bdbb75
commit 540255e6a1
3 changed files with 137 additions and 44 deletions
+22 -10
View File
@@ -28,10 +28,7 @@ use std::rc::Rc;
use crate::core::config::{Config, RightPanelTab};
use crate::daemon::protocol::PaneProcs;
use crate::terminal::git_diff::{self, DiffSnapshot};
use crate::ui::app::{
CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset,
tile_trailing_inset_sm,
};
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset_sm};
use crate::ui::scrollbar::with_vertical_scrollbar;
/// Bounds for the panel's width, mirroring the rail's: a floor so the tree never
@@ -249,7 +246,11 @@ impl Tty7App {
.on_double_click(|_, window, _| window.titlebar_double_click())
.items_center()
.gap(px(2.))
.pl(px(tile_trailing_inset()))
// `_sm` because the tabs are body-scale tiles now
// (`right_panel_tabs`): the leading inset has to line the
// *glyph* up on `CONTENT_INSET`, and a 24px tile holds its
// glyph a different distance inside the box than a 32px one.
.pl(px(crate::ui::app::tile_trailing_inset_sm()))
.children(self.right_panel_tabs(cx))
.child(div().flex_1())
// The panel is what reaches the window's right edge while
@@ -375,6 +376,16 @@ impl Tty7App {
/// of this one meant three stacked headers before a single line of content —
/// so the two that were saying "this is a header" merge into one that also
/// says which tab you are on.
///
/// **On macOS it draws nothing unless a tab passes `trailing`.** The panel
/// there has its own tile row in its top zone, which already says which tab
/// you are on — restating it in words underneath was a whole row spent on
/// something the selected tile and the content below both already answer
/// (a file tree is Files, a diff is Changes). What it did cost was the row:
/// the panel opened with tiles, then a title, then a search box, before one
/// line of content. The counts it used to carry move into the tab tooltips.
/// A tab that has its own control still gets the row, because that control
/// has nowhere else to go.
pub(crate) fn panel_title(
&self,
text: &str,
@@ -384,6 +395,9 @@ impl Tty7App {
) -> AnyElement {
let tabs = (!cfg!(target_os = "macos")).then(|| self.right_panel_tabs(cx));
let has_trailing = trailing.is_some();
if tabs.is_none() && !has_trailing {
return div().flex_none().into_any_element();
}
h_flex()
.flex_none()
// Tall enough to seat the chrome-scale tiles when it carries them;
@@ -396,12 +410,10 @@ impl Tty7App {
.items_center()
.pl(px(CONTENT_INSET))
// Trailing tiles align on the glyph like every other control in the
// window; a label-only header just takes the plain inset. `_SM` for a
// tab's own control, whose glyph sits a different distance inside its
// box than the chrome-scale tab tiles do.
// window; a label-only header just takes the plain inset. `_SM` covers
// both tile cases now — the tab tiles are body-scale too.
.pr(px(match (&tabs, has_trailing) {
(Some(_), _) => tile_trailing_inset(),
(None, true) => tile_trailing_inset_sm(),
(Some(_), _) | (None, true) => tile_trailing_inset_sm(),
(None, false) => CONTENT_INSET,
}))
// The line that separates the header from the tab's content. Only
+11
View File
@@ -823,6 +823,16 @@ impl Tty7App {
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
),
);
// The rail's head: which workspace this window is on. A row of its own,
// above the search box, because it names the thing the whole column
// enumerates. It is deliberately *not* folded into the repo group headers
// below — those are repositories, and one workspace holds several of them.
let workspace_head = h_flex()
.flex_shrink_0()
.px(px(crate::ui::app::CONTENT_INSET - 7.))
.pt(px(4.))
.child(self.workspace_head(cx));
// Borderless "Search tabs…" that sits directly on the sunk surface: a
// leading magnifier + an appearance-less input, no box and no divider
// under the bar, so the control row and list read as one continuous rail
@@ -969,6 +979,7 @@ impl Tty7App {
.child(crate::ui::app::title_bar_drag(
controls.id("sidebar-titlebar-drag"),
))
.child(workspace_head)
.child(top_bar)
.child(crate::ui::scrollbar::with_vertical_scrollbar(
"tab-sidebar-scrollbar",
+104 -34
View File
@@ -349,13 +349,14 @@ pub(crate) fn select_workspace_action(index: usize) -> Option<Box<dyn gpui::Acti
}
impl Tty7App {
/// Diameter of the workspace avatar, matching the 32px chrome tiles beside
/// it so the corner reads as one row of controls.
const AVATAR_PX: f32 = 26.0;
/// Diameter of the workspace avatar. Sized to the rail's row glyphs rather
/// than to a chrome tile: this control is the head of the tab list, so it
/// reads down the column instead of across a row of buttons.
const AVATAR_PX: f32 = 20.0;
/// The title-bar workspace control: a monogram of the current workspace
/// plus a chevron, opening the one menu that owns everything
/// workspace-scoped — and the app-level entries the "⋯" used to hold.
/// The rail's head: which workspace this window is on, plus the menu that
/// owns everything workspace-scoped — and the app-level entries the "⋯"
/// used to hold.
///
/// This exists because the rest of it was too well hidden. Switching lived
/// in the command palette, reopening lived on the home page, ending lived
@@ -363,14 +364,22 @@ impl Tty7App {
/// defensible, together undiscoverable. Nothing in the window even *said*
/// which workspace it was, which starts to matter the moment there are two.
///
/// A monogram rather than the full name: a fixed-width control can't be
/// pushed off the corner by a long repo name, and it sits level with the
/// icon tiles instead of introducing a third shape. The full name is in the
/// tooltip and checked in the menu.
/// It used to be a monogram in the window's top-right corner. Two things
/// were wrong with that. Physically: the corner it sat in is the *panel's*
/// top zone while the panel is open, so a control that has nothing to do
/// with the panel was eating width the panel's own tabs needed — at the
/// 200px minimum the row wanted 268px. Semantically: a window's workspace is
/// exactly what the rail below enumerates (this workspace's tabs), so the
/// name belonged at the head of that column, not across the window from it.
///
/// Here it can afford the full name — the rail is a column with a width of
/// its own, and it truncates rather than pushing anything off an edge. The
/// monogram stays as a leading avatar because it is the mark that identifies
/// *this* workspace at a glance across windows.
///
/// While a rename is in flight the control becomes the text field, so the
/// name is edited where it is displayed.
pub(crate) fn workspace_chip(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
pub(crate) fn workspace_head(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
if let Some(rename) = self.workspace_rename.as_ref() {
// The tile itself becomes the field — same height, same radius, and
// the hover fill standing in for "this control is being edited".
@@ -382,10 +391,13 @@ impl Tty7App {
.id("workspace-rename")
.flex_shrink_0()
.items_center()
.h(px(32.))
.w(px(150.))
.px(px(8.))
.rounded_lg()
// Full rail width, not the old fixed 150px: this control is a row
// in a column now, so it takes the column's width like every other
// row does.
.h(px(30.))
.w_full()
.px(px(7.))
.rounded_md()
.bg(cx.theme().sidebar_accent)
// Swallow mouse-downs (including the double-click that selects a
// word) so they never reach the enclosing TitleBar and zoom the
@@ -418,34 +430,55 @@ impl Tty7App {
div()
.occlude()
.flex_shrink_0()
.w_full()
.child(
Button::new("titlebar-workspace")
Button::new("rail-workspace-head")
.custom(chrome_tile_variant(cx))
.child(
h_flex()
.w_full()
.items_center()
.gap(px(3.))
.gap(px(6.))
.child(
div()
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.size(px(Self::AVATAR_PX))
.rounded_full()
.bg(cx.theme().secondary)
.text_size(px(11.))
.text_size(px(10.))
.font_weight(FontWeight::SEMIBOLD)
.child(monogram),
)
// A chevron, unlike the toggles beside it: those do
// one thing on click, this opens something, and the
// The name shrinks and truncates rather than pushing
// the chevron out — same rule the group headers below
// it follow, so a long workspace name and a long repo
// name behave identically.
.child(
div()
.flex_shrink(1.)
.min_w_0()
.truncate()
.text_size(px(12.5))
.font_weight(FontWeight::SEMIBOLD)
.child(SharedString::from(current.clone())),
)
// A chevron, unlike the tiles in the row above: those
// do one thing on click, this opens something, and the
// glyph is what says so.
.child(Icon::new(IconName::ChevronDown).size(px(11.))),
.child(
Icon::new(IconName::ChevronDown)
.size(px(11.))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground),
),
)
.xsmall()
.h(px(32.))
.rounded_lg()
.w_full()
.h(px(30.))
.rounded_md()
.tooltip(SharedString::from(current))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_switcher(window, cx);
@@ -518,12 +551,15 @@ impl Tty7App {
.flex_shrink_0()
.items_center()
.gap(px(2.))
// The workspace control leads the corner chrome: it is the only
// thing in the window that says *which* workspace this is, which
// starts to matter the moment there are two. It also absorbed the
// old "⋯" menu, so the corner has one menu instead of two adjacent
// ones, and nothing workspace-scoped is left behind a modifier
// gesture or a palette entry the user has to already know about.
// Two controls, both window-scoped, and that is the whole corner now.
// The workspace chip used to lead this group; it moved to the rail's
// head (`tab_sidebar`), where the list it names actually lives. What
// forced the move is that this group's other host is the *panel's* top
// zone, and a panel the user can drag down to 200px cannot seat the
// panel's four tabs plus three window controls — the row wanted 268px.
// Nothing that has no business being scoped to the panel gets to
// compete for that width.
//
// The "⋯" glyph ends on the window's content inset like every other
// right edge in the chrome — hence `inset - TILE_PAD`, which puts the
// *glyph's ink* there instead of its hit box.
@@ -562,13 +598,37 @@ impl Tty7App {
),
)
.child(self.app_menu_tile(window, cx))
.child(self.workspace_chip(cx))
}
/// The detail panel's tab tiles — icon-only, one per view. Lives here beside
/// the rest of the chrome tiles so all of them share one styling helper.
///
/// Body scale ([`TILE_SIZE_SM`]), not chrome scale. Two reasons, and the
/// second is why it changed: these tiles live *inside* a panel, which is what
/// that constant is for; and the row they sit in also carries the window's
/// own chrome at its trailing edge, so drawing both at 32px made seven
/// identical squares out of controls belonging to three different layers.
/// One size step is what separates "the panel's own tabs" from "the window's
/// buttons" without adding a rule or a divider. It also buys back the 32px
/// that made the row overflow a 200px-wide panel.
///
/// [`TILE_SIZE_SM`]: crate::ui::app::TILE_SIZE_SM
pub(crate) fn right_panel_tabs(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let active_tab = self.right_panel_tab;
// Changed-file count, carried in the Changes tooltip. It used to be a
// tally in that tab's header row, and that row is gone on macOS
// (`panel_title`) — but this is the one count worth keeping reachable
// *without* switching to the tab, since it answers "did I touch anything"
// from wherever you are. Outline's count didn't survive the move: it
// needs the active leaf, which needs a `&Window` this function doesn't
// take, and its list is right there the moment you switch.
let changed = match &self.right_panel.diff {
Some(Some(snap)) => {
let n = snap.files.len() + snap.untracked.len();
(n > 0).then_some(n)
}
_ => None,
};
[
(
RightPanelTab::Info,
@@ -600,13 +660,23 @@ impl Tty7App {
.occlude()
.flex_shrink_0()
.child(
chrome_tile(
chrome_tile_sized(
Button::new(("right-panel-tab", tab as usize)).icon(icon),
crate::ui::app::TILE_SIZE_SM,
crate::ui::app::TILE_GLYPH_SM,
active_tab == tab,
cx,
)
.rounded_lg()
.tooltip(label)
// `rounded_md` against the chrome tiles' `rounded_lg`: the
// corner radius tracks the box, or a 24px tile reads as a
// 32px one with its sides shaved off.
.rounded_md()
.tooltip(match (tab, changed) {
(RightPanelTab::Changes, Some(n)) => {
SharedString::from(format!("{label} · {n}"))
}
_ => SharedString::from(label),
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.set_right_panel_tab(tab, cx);
})),