Compare commits

...

5 commits

Author SHA1 Message Date
Aaron Ang
3d56d48824
Merge 119ef17316 into 09165c15dc 2026-05-31 13:06:10 +02:00
Nathan Sobo
09165c15dc
gpui: Support prompt_for_paths in TestPlatform (#58139)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
Implements the previously-`unimplemented!()`
`TestPlatform::prompt_for_paths` so tests can drive the platform Open
dialog deterministically.

Adds `TestAppContext::simulate_path_prompt_response` and
`did_prompt_for_paths`, mirroring the existing `prompt_for_new_path`
test helpers (`simulate_new_path_selection`). The simulated response
validates that callers don't return multiple paths when
`PathPromptOptions::multiple` is false.

Release Notes:

- N/A
2026-05-30 20:37:39 +00:00
Aaron Ang
119ef17316 Make Thumb use full-height track hitbox, remove OverlayTrack
Thumb-only hitboxes (covering just the thumb rectangle) always let
clicks on the track area fall through to content underneath. There is no
use case for this — the scrollbar strip should always intercept its own
clicks. Fold OverlayTrack's behavior directly into Thumb by including it
in needs_scroll_track(), then remove the now-redundant OverlayTrack
variant and with_overlay_track_along() method.

All vertical_scrollbar_for call sites get the correct behavior
automatically. Settings and project panel callers are cleaned up:
the settings main list reverts to vertical_scrollbar_for, and the
now-redundant with_overlay_track_along calls are removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:42:50 -07:00
Aaron Ang
cc22aaadd6 Show scrollbar thumb when hovering the track area
When the cursor was on the scrollbar track (above/below the thumb) but
not directly on the thumb, the scrollbar would immediately schedule
auto-hide because update_hovered_thumb fell through to ThumbState::Inactive.
This caused the thumb to disappear while the user was trying to interact
with the track.

Two fixes:
- update_hovered_thumb: add a second branch that checks if any
  cursor_hitbox is hovered (cursor on track but not thumb) and keeps
  ThumbState::Hover so the scrollbar stays visible.
- parent_hovered: also check cursor_hitbox.is_hovered in addition to
  parent_bounds_hitbox, since BlockMouseExceptScroll behavior can prevent
  the parent hitbox from being considered hovered when the cursor is over
  the scrollbar strip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:45:05 -07:00
Aaron Ang
e3d5bcb0df Fix scrollbar track clicks in project panel and settings
Add `ReservedSpace::OverlayTrack` to the scrollbar component: a track
hitbox that intercepts clicks without reserving layout space. This
differs from `Track` (reserves padding when scrollable) and
`StableTrack` (always reserves padding).

Project panel previously had no vertical track, so clicks below/above
the thumb fell through to tree items. Settings used `Thumb`-only, so
track clicks did nothing and fell through to settings items. Both now
use `with_overlay_track_along` so track clicks scroll correctly without
causing layout asymmetry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:23:59 -07:00
4 changed files with 137 additions and 36 deletions

View file

@ -336,6 +336,20 @@ impl TestAppContext {
self.test_platform.simulate_new_path_selection(select_path);
}
/// Simulates responding to a `prompt_for_paths` ("Open") dialog.
pub fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
self.test_platform
.simulate_path_prompt_response(select_paths);
}
/// Returns true if there's a path selection dialog pending.
pub fn did_prompt_for_paths(&self) -> bool {
self.test_platform.did_prompt_for_paths()
}
/// Simulates clicking a button in an platform-level alert dialog.
#[track_caller]
pub fn simulate_prompt_answer(&self, button: &str) {
@ -1098,3 +1112,54 @@ impl AnyWindowHandle {
.unwrap()
}
}
#[cfg(test)]
mod tests {
use crate::{PathPromptOptions, TestAppContext};
use std::path::PathBuf;
#[gpui::test]
async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) {
assert!(!cx.did_prompt_for_paths());
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: true,
prompt: None,
})
});
assert!(cx.did_prompt_for_paths());
let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")];
cx.simulate_path_prompt_response({
let selected = selected.clone();
move |options| {
assert!(options.multiple);
Some(selected)
}
});
assert!(!cx.did_prompt_for_paths());
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, Some(selected));
}
#[gpui::test]
async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) {
let receiver = cx.update(|cx| {
cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
})
});
cx.simulate_path_prompt_response(|_options| None);
let response = receiver.await.unwrap().unwrap();
assert_eq!(response, None);
}
}

View file

@ -1,9 +1,10 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata,
Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size,
DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform,
PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper,
PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams,
size,
};
use anyhow::Result;
use collections::VecDeque;
@ -85,6 +86,10 @@ struct TestPrompt {
pub(crate) struct TestPrompts {
multiple_choice: VecDeque<TestPrompt>,
new_path: VecDeque<(PathBuf, oneshot::Sender<Result<Option<PathBuf>>>)>,
paths: VecDeque<(
PathPromptOptions,
oneshot::Sender<Result<Option<Vec<PathBuf>>>>,
)>,
}
impl TestPlatform {
@ -147,6 +152,33 @@ impl TestPlatform {
tx.send(Ok(select_path(&path))).ok();
}
pub(crate) fn simulate_path_prompt_response(
&self,
select_paths: impl FnOnce(&PathPromptOptions) -> Option<Vec<std::path::PathBuf>>,
) {
let (options, tx) = self
.prompts
.borrow_mut()
.paths
.pop_front()
.expect("no pending paths prompt");
let selection = select_paths(&options);
if let Some(paths) = &selection
&& !options.multiple
&& paths.len() > 1
{
panic!(
"selected {} paths for a prompt that does not allow multiple selection",
paths.len()
);
}
tx.send(Ok(selection)).ok();
}
pub(crate) fn did_prompt_for_paths(&self) -> bool {
!self.prompts.borrow().paths.is_empty()
}
#[track_caller]
pub(crate) fn simulate_prompt_answer(&self, response: &str) {
let prompt = self
@ -348,9 +380,11 @@ impl Platform for TestPlatform {
fn prompt_for_paths(
&self,
_options: crate::PathPromptOptions,
options: crate::PathPromptOptions,
) -> oneshot::Receiver<Result<Option<Vec<std::path::PathBuf>>>> {
unimplemented!()
let (tx, rx) = oneshot::channel();
self.prompts.borrow_mut().paths.push_back((options, tx));
rx
}
fn prompt_for_new_path(

View file

@ -62,7 +62,7 @@ use theme_settings::ThemeSettings;
use ui::{
Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Icon, IconDecoration, IconDecorationKind,
IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, LabelSize, ListItem,
ListItemSpacing, ProjectEmptyState, ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate,
ListItemSpacing, ProjectEmptyState, ScrollableHandle, Scrollbars, StickyCandidate,
Tooltip, WithScrollbar, prelude::*, v_flex,
};
use util::{
@ -7087,16 +7087,9 @@ impl Render for ProjectPanel {
)
.custom_scrollbars(
{
let mut scrollbars =
Scrollbars::for_settings::<ProjectPanelScrollbarProxy>()
.tracked_scroll_handle(&self.scroll_handle);
if horizontal_scroll {
scrollbars = scrollbars.with_track_along(
ScrollAxes::Horizontal,
cx.theme().colors().panel_background,
);
}
scrollbars.notify_content()
Scrollbars::for_settings::<ProjectPanelScrollbarProxy>()
.tracked_scroll_handle(&self.scroll_handle)
.notify_content()
},
window,
cx,

View file

@ -330,7 +330,7 @@ impl ReservedSpace {
}
fn needs_scroll_track(&self) -> bool {
matches!(self, Self::Track | Self::StableTrack)
matches!(self, Self::Thumb | Self::Track | Self::StableTrack)
}
fn needs_space_reserved(&self, max_offset: Pixels) -> bool {
@ -779,21 +779,26 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_thumb_state(
if let Some(&ScrollbarLayout { axis, .. }) =
self.last_prepaint_state.as_ref().and_then(|state| {
state
.thumb_for_position(position)
.filter(|thumb| thumb.cursor_hitbox.is_hovered(window))
})
{
ThumbState::Hover(axis)
} else {
ThumbState::Inactive
},
window,
cx,
);
let thumb_state = if let Some(&ScrollbarLayout { axis, .. }) =
self.last_prepaint_state.as_ref().and_then(|state| {
state
.thumb_for_position(position)
.filter(|thumb| thumb.cursor_hitbox.is_hovered(window))
}) {
ThumbState::Hover(axis)
} else if let Some(&ScrollbarLayout { axis, .. }) =
self.last_prepaint_state.as_ref().and_then(|state| {
state
.thumbs
.iter()
.find(|thumb| thumb.cursor_hitbox.is_hovered(window))
})
{
ThumbState::Hover(axis)
} else {
ThumbState::Inactive
};
self.set_thumb_state(thumb_state, window, cx);
}
fn set_thumb_state(&mut self, state: ThumbState, window: &mut Window, cx: &mut Context<Self>) {
@ -829,9 +834,13 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
}
fn parent_hovered(&self, window: &Window) -> bool {
self.last_prepaint_state
.as_ref()
.is_some_and(|state| state.parent_bounds_hitbox.is_hovered(window))
self.last_prepaint_state.as_ref().is_some_and(|state| {
state.parent_bounds_hitbox.is_hovered(window)
|| state
.thumbs
.iter()
.any(|thumb| thumb.cursor_hitbox.is_hovered(window))
})
}
fn hit_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {