From 540255e6a1d560a4cba57503b8a71e5d7b5112e0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:19:23 +0800 Subject: [PATCH] refactor(panel): give the detail panel's top zone back to the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ui/right_panel.rs | 32 +++++++--- src/ui/tab_sidebar.rs | 11 ++++ src/ui/tab_strip.rs | 138 +++++++++++++++++++++++++++++++----------- 3 files changed, 137 insertions(+), 44 deletions(-) diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 4c3b8fe5..3aa9baa4 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -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 diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index c4992a6c..13ff96ba 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -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", diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 23eb666b..646bcd8c 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -349,13 +349,14 @@ pub(crate) fn select_workspace_action(index: usize) -> Option) -> impl IntoElement + use<> { + pub(crate) fn workspace_head(&self, cx: &mut Context) -> 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) -> Vec { 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); })),