feat(ui): give the right panel and tab rail an overlay scrollbar

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.
This commit is contained in:
l0ng-ai
2026-07-26 20:46:00 +08:00
parent 87273d980b
commit a0d153c546
8 changed files with 145 additions and 15 deletions
+2 -1
View File
@@ -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
+8 -3
View File
@@ -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).
+1
View File
@@ -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;
+20 -8
View File
@@ -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()
}
+59
View File
@@ -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<ElementId>,
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()
}
+10 -1
View File
@@ -115,6 +115,9 @@ pub(crate) struct SftpPanelState {
editing_path_sub: Vec<Subscription>,
/// 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<Subscription>,
}
@@ -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.));
+7 -2
View File
@@ -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)
}
+38
View File
@@ -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