From a0d153c5461265bea7cc438ff6ed6d50aa661d26 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:54:55 +0800 Subject: [PATCH] feat(ui): give the right panel and tab rail an overlay scrollbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail panel (Info / Outline / Changes / Files), the remote SFTP listing and the tab rail all scrolled with gpui's `overflow_y_scroll()`, which paints nothing: a deep file tree or a long tab list gave no hint that there was more content, or where in it you were (issue #185). Hang gpui-component's `Scrollbar` over each of those containers through one shared helper (`ui::scrollbar`). The handle stays app-owned so `scroll_to_item` keeps working — that rules out gpui-component's `overflow_y_scrollbar()`, which mints its own handle internally. Theme the bar from the active theme's own background→foreground mix ladder rather than the stock fixed greys, and keep the track transparent so the thumb floats instead of laying a slab down the panel edge. Show mode follows the platform: auto-hide on macOS (and anywhere else the OS uses overlay scrollbars), permanently visible on Windows and Linux. gpui-component's own default picks `Hover` there, which only reveals the bar within 16px of the edge — that is the "no scrollbar at all" the issue reports from Windows. --- src/ui/app.rs | 3 ++- src/ui/file_tree.rs | 11 +++++--- src/ui/mod.rs | 1 + src/ui/right_panel.rs | 28 ++++++++++++++------ src/ui/scrollbar.rs | 59 +++++++++++++++++++++++++++++++++++++++++++ src/ui/sftp.rs | 11 +++++++- src/ui/tab_sidebar.rs | 9 +++++-- src/ui/theme.rs | 38 ++++++++++++++++++++++++++++ 8 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 src/ui/scrollbar.rs diff --git a/src/ui/app.rs b/src/ui/app.rs index 5a3f3563..6772af2c 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -543,7 +543,8 @@ pub struct Tty7App { pub(crate) right_panel_tab: RightPanelTab, pub(crate) sidebar_collapsed: bool, /// Scroll handle for the sidebar's row list, so activating a tab scrolls its - /// row into view. + /// row into view — and so the rail's overlay scrollbar has an offset to + /// track and drag (see [`crate::ui::scrollbar`]). pub(crate) sidebar_scroll: gpui::ScrollHandle, /// `Some` while a tab / group is being dragged to a new position, in either /// the strip or the rail: the frozen geometry the live preview reflow is diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 770500a3..e9a4358f 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1120,11 +1120,12 @@ impl Tty7App { } else { self.file_tree.search_rows() }; - v_flex() + let column = v_flex() .id("right-panel-tree-rows") .flex_1() .min_h_0() .overflow_y_scroll() + .track_scroll(&self.right_panel.tree_scroll) .px_1() .pb_1() // Keyboard nav (arrows / enter / rename) followed the tree out of the @@ -1136,8 +1137,12 @@ impl Tty7App { .children( rows.iter() .flat_map(|row| self.render_tree_row(row, window, cx)), - ) - .into_any_element() + ); + crate::ui::scrollbar::with_vertical_scrollbar( + "right-panel-tree-scrollbar", + column, + &self.right_panel.tree_scroll, + ) } /// One row (plus, when an inline edit targets it, the edit input row). diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 71e4a3cb..15ddf17d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,6 +20,7 @@ pub mod perf; pub mod presets; pub mod reorder; pub mod right_panel; +pub mod scrollbar; pub mod settings; pub mod sftp; pub mod ssh_connect; diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index bf38b2af..fea8c475 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -32,6 +32,7 @@ use crate::ui::app::{ CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset, 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 /// becomes an ellipsis parade, and a ceiling as a fraction of the window so a @@ -77,6 +78,13 @@ pub(crate) struct RightPanelState { /// already watching finishes connecting — and the loop reads this on each /// reschedule so it picks the change up on the next tick. pub(crate) procs_forwards: bool, + /// Scroll position of the shared body container (Info / Outline / Changes), + /// owned here rather than left to gpui's element-id state so the overlay + /// scrollbar has a handle to read the offset from and to drag. + pub(crate) scroll: gpui::ScrollHandle, + /// The Files tab's local tree scrolls in its own container (it carries the + /// tree's focus handle and key bindings), so it needs its own handle. + pub(crate) tree_scroll: gpui::ScrollHandle, } /// How often the Info tab re-queries processes and ports while it's open. Fast @@ -465,18 +473,22 @@ impl Tty7App { /// The body's scrolling area, so every tab shares one scroll container and /// one content inset. fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement { + let body = div() + .id("right-panel-body") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.right_panel.scroll) + .child(inner); v_flex() .flex_1() .min_h_0() .child(title) - .child( - div() - .id("right-panel-body") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .child(inner), - ) + .child(with_vertical_scrollbar( + "right-panel-body-scrollbar", + body, + &self.right_panel.scroll, + )) .into_any_element() } diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs new file mode 100644 index 00000000..3878549a --- /dev/null +++ b/src/ui/scrollbar.rs @@ -0,0 +1,59 @@ +//! The overlay scrollbar the app's own scroll areas wear (issue #185). +//! +//! gpui's `overflow_y_scroll()` scrolls but paints nothing, so a long file tree +//! or tab list gave no hint that there was more content — or where in it you +//! were. gpui-component ships the [`Scrollbar`] element for exactly this; the +//! only thing missing was a house shape for hanging it off our containers. +//! +//! Why not gpui-component's own `overflow_y_scrollbar()` wrapper: it mints its +//! own `ScrollHandle` internally via `use_keyed_state`, which leaves nothing for +//! `scroll_to_item` to aim at. Our lists need programmatic scrolling (activating +//! a tab pulls its row into view), so the handle stays app-owned and the +//! scrollbar is layered on top of it. +//! +//! Appearance (thumb colour, whether it auto-hides) comes from the theme — see +//! the scrollbar block in [`crate::ui::theme::apply_theme`]. +//! +//! One behaviour worth knowing: while the bar is live it claims mouse-downs in +//! the 16px strip along the container's right edge, so the trailing few pixels +//! of a row stop being clickable there. That is inherent to an overlay +//! scrollbar (macOS's own behave the same way) and the reason the bar is only +//! permanently live on the platforms whose scrollbars are permanently visible. + +use gpui::{AnyElement, ElementId, ScrollHandle, div, prelude::*}; +use gpui_component::scroll::Scrollbar; +use gpui_component::v_flex; + +/// Wrap a scrolling column so a vertical scrollbar floats over its right edge. +/// +/// `scroll_area` must be the element that carries `.overflow_y_scroll()` and +/// `.track_scroll(handle)`; this only adds the positioned parent the scrollbar +/// measures against and the absolute layer it paints into. The scrollbar draws +/// *over* the content rather than reserving a gutter, so adopting it never +/// reflows the wrapped list. +/// +/// `id` names the scrollbar's element state (hover/drag/fade), so it must be +/// unique per scroll area — the helper can't fall back to `Location::caller` +/// the way [`Scrollbar::vertical`] does, since every call site would then share +/// this function's line. +pub(crate) fn with_vertical_scrollbar( + id: impl Into, + scroll_area: impl IntoElement, + handle: &ScrollHandle, +) -> AnyElement { + v_flex() + .relative() + .flex_1() + .min_h_0() + .child(scroll_area) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(handle).id(id)), + ) + .into_any_element() +} diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 626f0c99..650e9f0e 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -115,6 +115,9 @@ pub(crate) struct SftpPanelState { editing_path_sub: Vec, /// Bumped on every (re)open so a stale poll loop exits. pub(crate) poll_gen: u64, + /// Scroll position of the remote listing, owned here so the Files tab's + /// overlay scrollbar has a handle to read and drag (see `ui::scrollbar`). + scroll: gpui::ScrollHandle, _subs: Vec, } @@ -144,6 +147,7 @@ impl SftpPanelState { editing_path: None, editing_path_sub: Vec::new(), poll_gen: 0, + scroll: gpui::ScrollHandle::new(), _subs: vec![sub], } } @@ -957,7 +961,11 @@ impl Tty7App { .child(breadcrumb) .child(filter) .children(form) - .child(list) + .child(crate::ui::scrollbar::with_vertical_scrollbar( + "sftp-list-scrollbar", + list, + &self.sftp_panel.scroll, + )) // FR-T5: a Finder drop uploads onto the current directory. .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { this.sftp_upload_paths(paths.paths().to_vec(), cx); @@ -1204,6 +1212,7 @@ impl Tty7App { .flex_1() .min_h_0() .overflow_y_scroll() + .track_scroll(&self.sftp_panel.scroll) .px(px(CONTENT_INSET - 6.)) .pb(px(4.)); diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index fdb7a76d..bc4e7768 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -95,7 +95,8 @@ impl Tty7App { // An id + `overflow_y_scroll` makes the row column scroll on its own // when the tabs outgrow the window height, leaving the "+" footer // pinned (same pattern the settings panel uses). `track_scroll` lets - // `activate` pull the selected row into view. + // `activate` pull the selected row into view — and feeds the overlay + // scrollbar the wrapper below hangs over this column. .id("tab-sidebar-list") .track_scroll(&self.sidebar_scroll) .flex_1() @@ -962,7 +963,11 @@ impl Tty7App { .on_double_click(|_, window, _| window.titlebar_double_click()) }) .child(top_bar) - .child(list), + .child(crate::ui::scrollbar::with_vertical_scrollbar( + "tab-sidebar-scrollbar", + list, + &self.sidebar_scroll, + )), ) .child(handle) } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 788610ef..c1ef0903 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -7,6 +7,7 @@ use gpui::{ App, Background, Hsla, Menu, MenuItem, OsAction, Pixels, Point, SystemMenuType, Window, WindowBackgroundAppearance, linear_color_stop, linear_gradient, point, px, rgb, }; +use gpui_component::scroll::ScrollbarShow; use gpui_component::{Theme, ThemeMode}; use crate::core::actions::*; @@ -374,6 +375,11 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { } let m = theme.neutrals(); let active = theme.active_palette(); + // Read before `Theme::global_mut` borrows `cx`. macOS reports the overlay / + // legacy scroller preference here; Windows reports the accessibility + // "always show scrollbars" setting (false by default) and Linux always + // false — which is exactly the platform split we want below. + let auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); // Never `Opaque`: on macOS 26 (Tahoe) flipping a window's opacity after // creation doesn't reach the compositor — the window keeps compositing @@ -470,6 +476,38 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.caret = rgb(m.caret).into(); t.selection = rgb(m.selection).into(); // text selection highlight + // Overlay scrollbars (right panel, file tree, tab rail — see + // `ui::scrollbar`). gpui-component's `Scrollbar` paints the thumb from + // `tokens.scrollbar_thumb{,_hover}` and the track from the plain + // `scrollbar` field; the stock values are fixed neutral greys per light/dark + // mode, so on a tinted theme the thumb reads as a foreign grey. Derive both + // from this theme's own foreground instead — the same background→foreground + // mix ladder the borders and chips use. + // + // The track stays fully transparent: the thumb floats over the content the + // way macOS overlay scrollbars do, and a filled channel would put a vertical + // slab down the edge of every panel. + let scrollbar_thumb: Hsla = rgb(presets::mix(m.background, m.foreground, 0.26)).into(); + let scrollbar_thumb_hover: Hsla = rgb(presets::mix(m.background, m.foreground, 0.42)).into(); + t.scrollbar = gpui::transparent_black(); + t.scrollbar_thumb = scrollbar_thumb; + t.scrollbar_thumb_hover = scrollbar_thumb_hover; + t.tokens.scrollbar = gpui::transparent_black().into(); + t.tokens.scrollbar_thumb = scrollbar_thumb.into(); + t.tokens.scrollbar_thumb_hover = scrollbar_thumb_hover.into(); + + // Follow the platform: auto-hide (fade in on scroll, out when idle) where + // the OS uses overlay scrollbars — the macOS default — and stay permanently + // visible where it doesn't, which is Windows and Linux. gpui-component's own + // `sync_scrollbar_appearance` picks `Hover` for that second case, meaning the + // bar only appears once the pointer is within 16px of the panel edge; that's + // the "no scrollbar at all" report from issue #185 on Windows. + t.scrollbar_show = if auto_hide_scrollbars { + ScrollbarShow::Scrolling + } else { + ScrollbarShow::Always + }; + // Round every gpui-component widget (buttons, inputs, selects, switches, // segmented controls, menus) to match the shell's own hand-rolled chrome, // which uses `rounded_lg` (8px) for tab chips, title-bar tiles and the