picker: Improve multi-select discoverability and rendering (#60919)

This PR reworks how multi-select mode is rendered in pickers (only used
by the file finder and text finder).

The "Multi Select" entry previously lived inside the footer's Actions
menu, which was hard to discover and its toggle checkmark misaligned the
menu's keybinding column. This PR changes to an icon button at the
far-roght edge of the search editor, with a tooltip showing the
keybinding to toggle it.

Also made each list item use the actual Checkbox component instead of a
bespoke re-implementation of it. And in doing so, added a few design
improvements to the list item so that it received the checkbox while
preserving proper styles for each interaction state, as well as
displaying the keybinding to select the item or check the item.

Here's a quick video, showing these changes off:


https://github.com/user-attachments/assets/e142f7d1-87ba-4258-844b-953ed06237bd

Release Notes:

- Improved multi-select in the file finder and text finder: the toggle
now lives in the search bar with a `cmd-shift-s` keybinding, and
selection checkboxes render inside list items.
This commit is contained in:
Danilo Leal authored and GitHub committed 2026-07-13 18:21:15 +00:00
1 parent ef94f98dbb
commit ae6cb0aff1
19 files changed
+389 -291

No files matched your search

Generated
-2
View File
@@ -550,7 +550,6 @@ dependencies = [
"time",
"tree-sitter-md",
"ui",
"ui_input",
"unicode-script",
"unicode-segmentation",
"unindent",
@@ -14960,7 +14959,6 @@ dependencies = [
"task",
"telemetry",
"ui",
"ui_input",
"util",
"windows-registry 0.6.1",
"workspace",
+5
View File
@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.24139 2.86957L14.7586 13.1304" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 7.63104V12.8C3.5 13.1182 3.61853 13.4235 3.8295 13.6485C4.04048 13.8735 4.32663 14 4.625 14H11.0079" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.625 2H8.60442C9.29544 2 9.95564 2.28602 10.4283 2.79015L11.8238 4.27877C12.2582 4.74213 12.5 5.35347 12.5 5.98862V7.97886" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.50458 4V6.43334C9.50458 6.77846 9.64168 7.10944 9.88571 7.35348C10.1298 7.59752 10.4607 7.73462 10.8059 7.73462H13.3" stroke="#DCE0E5" stroke-width="1.2" stroke-linejoin="round"/>
<path d="M11.5735 4.65846C11.1797 4.23835 10.6295 4 10.0537 4H6.73749C6.48885 4 6.25039 4.10536 6.07458 4.29289C5.89876 4.48043 5.79999 4.73478 5.79999 5V13C5.79999 13.2652 5.89876 13.5196 6.07458 13.7071C6.25039 13.8946 6.48885 14 6.73749 14H12.3625C12.6111 14 12.8496 13.8946 13.0254 13.7071C13.2012 13.5196 13.3 13.2652 13.3 13V7.32385C13.3 6.79456 13.0985 6.28511 12.7365 5.89897L11.5735 4.65846Z" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 12H3.73749C3.48885 12 3.25039 11.8946 3.07458 11.7071C2.89876 11.5196 2.79999 11.2652 2.79999 11V3C2.79999 2.73478 2.89876 2.48043 3.07458 2.29289C3.25039 2.10536 3.48885 2 3.73749 2H7.05367C7.62952 2 8.17969 2.23835 8.57354 2.65846L9.83124 4" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -11,6 +11,7 @@
"use_key_equivalents": true,
"bindings": {
"cmd-shift-a": "picker::ToggleActionsMenu",
"cmd-shift-s": "picker::ToggleMultiSelect",
},
},
{
+1
View File
@@ -10,6 +10,7 @@
"context": "Picker > Editor",
"bindings": {
"ctrl-shift-a": "picker::ToggleActionsMenu",
"ctrl-shift-s": "picker::ToggleMultiSelect",
},
},
{
-1
View File
@@ -108,7 +108,6 @@ theme.workspace = true
theme_settings.workspace = true
time.workspace = true
ui.workspace = true
ui_input.workspace = true
unicode-script.workspace = true
unicode-segmentation.workspace = true
url.workspace = true
@@ -36,7 +36,6 @@ use ui::{
Scrollbars, Tab, ThreadItem, Tooltip, WithScrollbar, prelude::*,
utils::platform_title_bar_height,
};
use ui_input::ErasedEditor;
use util::ResultExt;
use util::paths::PathExt;
use workspace::{
@@ -1302,22 +1301,6 @@ impl PickerDelegate for ProjectPickerDelegate {
.into()
}
fn render_editor(
&self,
editor: &Arc<dyn ErasedEditor>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
h_flex()
.flex_none()
.h_9()
.px_2p5()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(editor.render(window, cx))
}
fn match_count(&self) -> usize {
self.filtered_entries.len()
}
+102 -66
View File
@@ -14,9 +14,9 @@ use file_icons::FileIcons;
use fuzzy::{StringMatch, StringMatchCandidate};
use fuzzy_nucleo::{PathMatch, PathMatchCandidate};
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Empty, Entity, EventEmitter, FocusHandle,
Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task,
TaskExt, WeakEntity, Window, actions, rems,
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, TaskExt,
WeakEntity, Window, actions, rems,
};
use language::{BufferSnapshot, Point};
use open_path_prompt::{
@@ -40,7 +40,7 @@ use std::{
},
time::Duration,
};
use ui::{HighlightedLabel, Indicator, ListItem, ListItemSpacing, Tooltip, prelude::*};
use ui::{Checkbox, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
use util::{
ResultExt, maybe,
paths::{PathStyle, PathWithPosition},
@@ -1768,12 +1768,9 @@ impl PickerDelegate for FileFinderDelegate {
"Include Ignored Files"
};
let filter_button = IconButton::new("filter-ignored", IconName::Sliders)
let filter_button = IconButton::new("filter-ignored", IconName::FileIgnored)
.icon_size(IconSize::Small)
.toggle_state(including_ignored)
.when(self.include_ignored.is_some(), |this| {
this.indicator(Indicator::dot().color(Color::Info))
})
.tooltip(move |_window, cx| {
Tooltip::for_action_in(tooltip_label, &ToggleIncludeIgnored, &focus_handle, cx)
})
@@ -2034,65 +2031,18 @@ impl PickerDelegate for FileFinderDelegate {
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let settings = FileFinderSettings::get_global(cx);
self.render_match_impl(ix, selected, None, window, cx)
}
let path_match = self.matches.get(ix)?;
let end_icon = match path_match {
Match::History { .. } => Icon::new(IconName::HistoryRerun)
.color(Color::Muted)
.size(IconSize::Small)
.into_any_element(),
Match::Search(_) => v_flex()
.flex_none()
.size(IconSize::Small.rems())
.into_any_element(),
Match::Channel { .. } => v_flex()
.flex_none()
.size(IconSize::Small.rems())
.into_any_element(),
Match::CreateNew(_) => Empty.into_any_element(),
};
let is_create_new = matches!(path_match, Match::CreateNew(_));
let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
let file_icon = match path_match {
Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
_ => maybe!({
if !settings.file_icons {
return None;
}
let abs_path = path_match.abs_path(&self.project, cx)?;
let file_name = abs_path.file_name()?;
let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
Some(Icon::from_path(icon).color(Color::Muted))
}),
};
Some(
ListItem::new(ix)
.spacing(ListItemSpacing::Sparse)
.inset(true)
.toggle_state(selected)
.map(|this| {
if is_create_new {
this.start_slot(Icon::new(IconName::Plus).size(IconSize::Small))
} else {
this.start_slot::<Icon>(file_icon)
}
})
.child(
h_flex()
.w_full()
.min_w_0()
.gap_1p5()
.child(file_name_label.truncate_middle())
.child(full_path_label.truncate_start()),
)
.end_slot::<AnyElement>(end_icon),
)
fn render_match_with_checkbox(
&self,
ix: usize,
selected: bool,
checkbox: AnyElement,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
self.render_match_impl(ix, selected, Some(checkbox), window, cx)
}
fn actions_menu(
@@ -2117,6 +2067,92 @@ impl PickerDelegate for FileFinderDelegate {
}
}
impl FileFinderDelegate {
fn render_match_impl(
&self,
ix: usize,
selected: bool,
checkbox: Option<AnyElement>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<ListItem> {
let settings = FileFinderSettings::get_global(cx);
let path_match = self.matches.get(ix)?;
let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx);
let start_icon = match path_match {
Match::CreateNew(_) => Some(Icon::new(IconName::Plus).size(IconSize::Small)),
Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)),
_ => maybe!({
if !settings.file_icons {
return None;
}
let abs_path = path_match.abs_path(&self.project, cx)?;
let file_name = abs_path.file_name()?;
let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
Some(Icon::from_path(icon).color(Color::Muted))
}),
};
let checkbox = checkbox.map(|checkbox| {
if matches!(path_match, Match::CreateNew(_) | Match::Channel { .. }) {
div()
.flex_none()
.size(Checkbox::container_size())
.into_any_element()
} else {
checkbox
}
});
let start_slot: Option<AnyElement> = match (checkbox, start_icon) {
(Some(checkbox), icon) => Some(
h_flex()
.gap_1p5()
.child(checkbox)
.children(icon)
.into_any_element(),
),
(None, icon) => icon.map(IntoElement::into_any_element),
};
let end_slot: Option<AnyElement> = match path_match {
Match::History { .. } => Some(
Icon::new(IconName::HistoryRerun)
.color(Color::Muted)
.size(IconSize::Small)
.into_any_element(),
),
Match::Search(_) | Match::Channel { .. } => Some(
div()
.flex_none()
.size(IconSize::Small.rems())
.into_any_element(),
),
Match::CreateNew(_) => None,
};
Some(
ListItem::new(ix)
.spacing(ListItemSpacing::Sparse)
.inset(true)
.toggle_state(selected)
.start_slot::<AnyElement>(start_slot)
.child(
h_flex()
.w_full()
.min_w_0()
.gap_1p5()
.child(file_name_label.truncate_middle())
.child(full_path_label.truncate_start()),
)
.end_slot::<AnyElement>(end_slot),
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PathComponentSlice<'a> {
path: Cow<'a, Path>,
+68 -66
View File
@@ -1201,79 +1201,81 @@ impl PickerDelegate for BranchListDelegate {
editor: &Arc<dyn ErasedEditor>,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
) -> Option<Div> {
let picker = cx.weak_entity();
let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
let show_inline_filter =
self.editor_position() == PickerEditorPosition::End || !self.show_footer;
v_flex()
.when(
self.editor_position() == PickerEditorPosition::End,
|this| this.child(Divider::horizontal()),
)
.when_some(self.branch_list_error.clone(), |this, error| {
let message = format!("Some branches could not be loaded: {error}");
this.child(
div()
.id("branch-list-error")
.p_1p5()
.child(
Banner::new().severity(Severity::Warning).child(
Label::new(message.clone())
.size(LabelSize::Small)
.single_line()
.truncate(),
),
)
.tooltip(Tooltip::text(message)),
Some(
v_flex()
.when(
self.editor_position() == PickerEditorPosition::End,
|this| this.child(Divider::horizontal()),
)
})
.child(
h_flex()
.overflow_hidden()
.flex_none()
.h_9()
.px_2p5()
.child(editor.clone())
.when(show_inline_filter, |this| {
let branch_filter = self.branch_filter;
let picker_for_menu_open = picker.clone();
this.gap_1().justify_between().child(
PopoverMenu::new("branch-filter-menu")
.with_handle(self.branch_filter_menu_handle.clone())
.on_open(Rc::new(move |_, cx| {
picker_for_menu_open
.update(cx, |picker, _| {
picker.delegate.branch_filter_menu_open = true;
})
.log_err();
}))
.trigger(
IconButton::new("branch-filter", IconName::ListFilter)
.toggle_state(branch_filter != BranchFilter::All)
.when(branch_filter != BranchFilter::All, |this| {
this.indicator(Indicator::dot().color(Color::Info))
})
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Filter branches")),
)
.menu(move |window, cx| {
Some(branch_filter_menu(
branch_filter,
picker.clone(),
window,
cx,
))
}),
)
}),
)
.when(
self.editor_position() == PickerEditorPosition::Start,
|this| this.child(Divider::horizontal()),
)
.when_some(self.branch_list_error.clone(), |this, error| {
let message = format!("Some branches could not be loaded: {error}");
this.child(
div()
.id("branch-list-error")
.p_1p5()
.child(
Banner::new().severity(Severity::Warning).child(
Label::new(message.clone())
.size(LabelSize::Small)
.single_line()
.truncate(),
),
)
.tooltip(Tooltip::text(message)),
)
})
.child(
h_flex()
.overflow_hidden()
.flex_none()
.h_9()
.px_2p5()
.child(editor.clone())
.when(show_inline_filter, |this| {
let branch_filter = self.branch_filter;
let picker_for_menu_open = picker.clone();
this.gap_1().justify_between().child(
PopoverMenu::new("branch-filter-menu")
.with_handle(self.branch_filter_menu_handle.clone())
.on_open(Rc::new(move |_, cx| {
picker_for_menu_open
.update(cx, |picker, _| {
picker.delegate.branch_filter_menu_open = true;
})
.log_err();
}))
.trigger(
IconButton::new("branch-filter", IconName::ListFilter)
.toggle_state(branch_filter != BranchFilter::All)
.when(branch_filter != BranchFilter::All, |this| {
this.indicator(Indicator::dot().color(Color::Info))
})
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Filter branches")),
)
.menu(move |window, cx| {
Some(branch_filter_menu(
branch_filter,
picker.clone(),
window,
cx,
))
}),
)
}),
)
.when(
self.editor_position() == PickerEditorPosition::Start,
|this| this.child(Divider::horizontal()),
),
)
}
fn editor_position(&self) -> PickerEditorPosition {
+2
View File
@@ -129,8 +129,10 @@ pub enum IconName {
FileDoc,
FileGeneric,
FileGit,
FileIgnored,
FileLock,
FileMarkdown,
FileMultiple,
FileRust,
FileTextFilled,
FileTextOutlined,
+10 -18
View File
@@ -8,7 +8,6 @@ use crate::PickerDelegate;
use crate::SetPreviewBelow;
use crate::SetPreviewRight;
use crate::ToggleActionsMenu;
use crate::ToggleMultiSelect;
use crate::TogglePreview;
use crate::preview;
@@ -98,18 +97,8 @@ impl<D: PickerDelegate> Picker<D> {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let mut actions = self.delegate.actions_menu(window, cx);
// The multi-select entry is picker-owned so it can reflect the mode,
// which delegates don't know about.
if self.delegate.supports_multi_select() {
if !actions.is_empty() {
actions.push(PickerAction::separator());
}
actions.push(
PickerAction::button("Multi Select", ToggleMultiSelect.boxed_clone())
.toggled(self.select_instead_of_open),
);
}
let actions = self.delegate.actions_menu(window, cx);
if self.preview.is_none() && actions.is_empty() {
return None;
}
@@ -123,9 +112,9 @@ impl<D: PickerDelegate> Picker<D> {
.justify_between()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(div().when(self.preview.is_some(), |this| {
.when(self.preview.is_some(), |this| {
this.child(self.render_preview_controls(window, cx))
}))
})
.when(!actions.is_empty(), |this| {
this.child(self.render_actions_button(actions.into(), focus_handle, window, cx))
})
@@ -206,11 +195,8 @@ impl<D: PickerDelegate> Picker<D> {
_window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let _ = cx;
PopoverMenu::new("picker-actions-menu")
.with_handle(self.actions_menu_handle.clone())
.attach(gpui::Anchor::TopRight)
.anchor(gpui::Anchor::BottomRight)
.trigger(
Button::new("picker-actions-trigger", "Actions…")
.key_binding(
@@ -230,5 +216,11 @@ impl<D: PickerDelegate> Picker<D> {
menu
}))
})
.attach(gpui::Anchor::TopRight)
.anchor(gpui::Anchor::BottomRight)
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
}
}
+88 -59
View File
@@ -11,7 +11,10 @@ use serde::Deserialize;
use std::{
cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration,
};
use ui::{ContextMenu, Divider, DocumentationAside, PopoverMenuHandle, prelude::*, v_flex};
use ui::{
Checkbox, ContextMenu, Divider, DocumentationAside, KeyBinding, PopoverMenuHandle, Tooltip,
prelude::*,
};
use ui_input::ErasedEditorEvent;
use util::ResultExt;
use workspace::ModalView;
@@ -323,30 +326,17 @@ pub trait PickerDelegate: Sized + 'static {
None
}
/// Overrides the search bar entirely. Most delegates should return `None`
/// to get the picker-rendered default (which includes
/// [`Self::searchbar_trailer`] and the multi-select toggle); override for
/// full control over the search bar.
fn render_editor(
&self,
editor: &Arc<dyn ErasedEditor>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
v_flex()
.when(
self.editor_position() == PickerEditorPosition::End,
|this| this.child(Divider::horizontal()),
)
.child(
h_flex()
.overflow_hidden()
.flex_none()
.h_9()
.px_2p5()
.child(div().flex_1().child(editor.render(window, cx)))
.children(self.searchbar_trailer(window, cx)),
)
.when(
self.editor_position() == PickerEditorPosition::Start,
|this| this.child(Divider::horizontal()),
)
_editor: &Arc<dyn ErasedEditor>,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<Div> {
None
}
fn try_get_preview_data_for_match(&self, _cx: &App) -> Option<PreviewUpdate> {
@@ -365,6 +355,17 @@ pub trait PickerDelegate: Sized + 'static {
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem>;
fn render_match_with_checkbox(
&self,
_ix: usize,
_selected: bool,
_checkbox: AnyElement,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
None
}
fn render_header(
&self,
_window: &mut Window,
@@ -922,6 +923,7 @@ impl<D: PickerDelegate> Picker<D> {
cx: &mut Context<Self>,
) {
if !self.delegate.supports_multi_select() {
cx.propagate();
return;
}
self.select_instead_of_open = !self.select_instead_of_open;
@@ -1263,12 +1265,55 @@ impl<D: PickerDelegate> Picker<D> {
let is_multi_selected = supports_multi_select && self.delegate.is_item_selected(ix);
let multi_select_active = supports_multi_select && self.select_instead_of_open;
let item_with_checkbox = if multi_select_active && selectable {
let checkbox = self
.render_multi_select_indicator(ix, is_multi_selected, cx)
.into_any_element();
self.delegate.render_match_with_checkbox(
ix,
ix == self.delegate.selected_index(),
checkbox,
window,
cx,
)
} else {
None
};
let use_fallback_indicator =
multi_select_active && selectable && item_with_checkbox.is_none();
let focus_handle = self.focus_handle(cx);
div()
.id(("item", ix))
.when(selectable, |this| this.cursor_pointer())
.when(selectable && multi_select_active, |this| {
.when(use_fallback_indicator, |this| {
this.hover(|s| s.bg(cx.theme().colors().ghost_element_hover))
})
.when(multi_select_active && selectable, |this| {
this.tooltip(Tooltip::element(move |_window, cx| {
h_flex()
.gap_2()
.child(
h_flex()
.gap_1()
.child(KeyBinding::for_action_in(
&MultiSelectNext,
&focus_handle,
cx,
))
.child(Label::new("Select")),
)
.child(Divider::vertical())
.child(
h_flex()
.gap_1()
.child(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx))
.child(Label::new("Open")),
)
.into_any_element()
}))
})
.child(
canvas(
move |bounds, _window, _cx| {
@@ -1310,17 +1355,19 @@ impl<D: PickerDelegate> Picker<D> {
}))
})
.map(|row| {
// Pickers without multi-select keep their element tree
// unchanged.
if supports_multi_select {
if let Some(item) = item_with_checkbox {
row.child(item)
} else if supports_multi_select {
row.child(
h_flex()
// Headers and separators cannot be part of the
// selection, so they get no indicator.
.when(multi_select_active && selectable, |this| {
this.child(
self.render_multi_select_indicator(is_multi_selected, cx),
)
.when(use_fallback_indicator, |this| {
this.child(self.render_multi_select_indicator(
ix,
is_multi_selected,
cx,
))
})
.children(self.delegate.render_match(
ix,
@@ -1349,39 +1396,21 @@ impl<D: PickerDelegate> Picker<D> {
)
}
/// The checkbox in front of items while in multi-select mode.
fn render_multi_select_indicator(
&self,
ix: usize,
is_selected: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.w_6()
.flex_none()
.justify_center()
.items_center()
.child(
div()
.size_4()
.flex_none()
.rounded_sm()
.border_1()
.border_color(if is_selected {
cx.theme().colors().border_focused
} else {
cx.theme().colors().border
})
.flex()
.items_center()
.justify_center()
.when(is_selected, |this| {
this.bg(cx.theme().colors().element_selected).child(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Accent),
)
}),
)
Checkbox::new(("picker-multi-select-checkbox", ix), is_selected.into())
.fill()
.elevation(ui::ElevationIndex::ModalSurface)
.on_click(cx.listener(move |this, _: &ui::ToggleState, window, cx| {
// Don't let the row's click handler also toggle the item.
cx.stop_propagation();
this.delegate.toggle_item_selected(ix, window, cx);
cx.notify();
}))
}
fn render_element_container(&self, cx: &mut Context<Self>) -> impl IntoElement {
+64 -9
View File
@@ -10,12 +10,17 @@ use ui::{
use crate::shape::Shape;
use crate::{
ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview,
ElementContainer, Picker, PickerDelegate, PickerEditorPosition, Preview, ToggleMultiSelect,
head::Head,
preview::Layout,
render::window_controls::{Bottom, Left, LeftCorner, Middle, Right, RightCorner},
};
use crate::{persistence, preview};
use gpui::Action as _;
use gpui::Focusable as _;
use std::sync::Arc;
use ui::{Divider, Tooltip, prelude::*};
use ui_input::ErasedEditor;
pub mod window_controls;
@@ -88,6 +93,54 @@ impl<D: PickerDelegate> Render for Picker<D> {
}
impl<D: PickerDelegate> Picker<D> {
fn render_editor(
&self,
editor: &Arc<dyn ErasedEditor>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Div {
if let Some(custom) = self.delegate.render_editor(editor, window, cx) {
return custom;
}
let editor_position = self.delegate.editor_position();
v_flex()
.when(editor_position == PickerEditorPosition::End, |this| {
this.child(Divider::horizontal())
})
.child(
h_flex()
.h_9()
.px_2p5()
.flex_none()
.overflow_hidden()
.child(div().flex_1().child(editor.render(window, cx)))
.children(self.delegate.searchbar_trailer(window, cx))
.when(self.delegate.supports_multi_select(), |this| {
this.child(self.render_multi_select_toggle(cx))
}),
)
.when(editor_position == PickerEditorPosition::Start, |this| {
this.child(Divider::horizontal())
})
}
/// The multi-select toggle is picker-owned so it can reflect the mode,
/// which delegates don't know about.
fn render_multi_select_toggle(&self, cx: &mut Context<Self>) -> impl IntoElement {
let active = self.select_instead_of_open;
let focus_handle = self.focus_handle(cx);
IconButton::new("picker-multi-select-toggle", IconName::FileMultiple)
.icon_size(IconSize::Small)
.toggle_state(active)
.tooltip(move |_window, cx| {
Tooltip::for_action_in("Toggle Multi Select", &ToggleMultiSelect, &focus_handle, cx)
})
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(ToggleMultiSelect.boxed_clone(), cx);
}))
}
pub(crate) fn render_results(
&self,
window: &mut Window,
@@ -154,13 +207,14 @@ impl<D: PickerDelegate> Picker<D> {
.children(match &self.head {
Head::Editor(editor) => {
if editor_position == PickerEditorPosition::Start {
Some(h_flex().w_full().child(
div().flex_1().child(self.delegate.render_editor(
&editor.clone(),
window,
cx,
)),
))
let editor = editor.clone();
Some(
h_flex().w_full().child(
div()
.flex_1()
.child(self.render_editor(&editor, window, cx)),
),
)
} else {
None
}
@@ -220,7 +274,8 @@ impl<D: PickerDelegate> Picker<D> {
.children(match &self.head {
Head::Editor(editor) => {
if editor_position == PickerEditorPosition::End {
Some(self.delegate.render_editor(&editor.clone(), window, cx))
let editor = editor.clone();
Some(self.render_editor(&editor, window, cx))
} else {
None
}
+5 -5
View File
@@ -1,6 +1,6 @@
use gpui::Window;
use gpui::{Pixels, Rems, Size};
use ui::{Div, Styled};
use ui::{Div, Styled, rems_from_px};
use crate::preview::Layout;
@@ -242,12 +242,12 @@ impl Default for SizeBounds {
// over the lower bar so clear another 5 rems there.
max_height: (RelativeHeight::FULL - Rems(10.0)) * 0.95,
min_results: Size {
width: Rems(15.0),
height: Rems(20.0),
width: rems_from_px(280.),
height: rems_from_px(320.),
},
min_preview: Size {
width: Rems(8.0),
height: Rems(6.0),
width: rems_from_px(128.),
height: rems_from_px(96.),
},
}
}
-1
View File
@@ -47,7 +47,6 @@ settings.workspace = true
task.workspace = true
telemetry.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
workspace.workspace = true
worktree.workspace = true
@@ -36,7 +36,6 @@ use project::{Worktree, git_store::Repository};
pub use remote_connections::RemoteSettings;
pub use remote_servers::RemoteServerProjects;
use settings::{DefaultOpenBehavior, Settings, WorktreeId};
use ui_input::ErasedEditor;
use workspace::ProjectGroupKey;
use dev_container::{DevContainerContext, find_devcontainer_configs};
@@ -938,22 +937,6 @@ impl PickerDelegate for RecentProjectsDelegate {
"Search projects…".into()
}
fn render_editor(
&self,
editor: &Arc<dyn ErasedEditor>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
h_flex()
.flex_none()
.h_9()
.px_2p5()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(editor.render(window, cx))
}
fn match_count(&self) -> usize {
self.filtered_entries.len()
}
@@ -12,7 +12,6 @@ use picker::{
use remote::RemoteConnectionOptions;
use settings::Settings;
use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
use ui_input::ErasedEditor;
use util::{ResultExt, paths::PathExt};
use workspace::{
MultiWorkspace, OpenMode, OpenOptions, ProjectGroupKey, RecentWorkspace,
@@ -144,22 +143,6 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
"Search projects…".into()
}
fn render_editor(
&self,
editor: &Arc<dyn ErasedEditor>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Div {
h_flex()
.flex_none()
.h_9()
.px_2p5()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(editor.render(window, cx))
}
fn match_count(&self) -> usize {
self.filtered_workspaces.len()
}
+6 -7
View File
@@ -5,8 +5,7 @@ use editor::SearchSettings;
use gpui::{Action, App, ClickEvent, Entity, FocusHandle, IntoElement, actions};
use project::search::SearchQuery;
pub use project_search::ProjectSearchView;
use ui::{ButtonStyle, IconButton, IconButtonShape};
use ui::{Tooltip, prelude::*};
use ui::{IconButtonShape, Tooltip, prelude::*};
use util::paths::PathMatcher;
use workspace::notifications::NotificationId;
use workspace::{Toast, Workspace};
@@ -115,10 +114,10 @@ impl SearchOption {
pub fn icon(&self) -> ui::IconName {
match self {
SearchOption::WholeWord => ui::IconName::WholeWord,
SearchOption::CaseSensitive => ui::IconName::CaseSensitive,
SearchOption::IncludeIgnored => ui::IconName::Sliders,
SearchOption::Regex => ui::IconName::Regex,
SearchOption::WholeWord => IconName::WholeWord,
SearchOption::CaseSensitive => IconName::CaseSensitive,
SearchOption::IncludeIgnored => IconName::FileIgnored,
SearchOption::Regex => IconName::Regex,
_ => panic!("{self:?} is not a named SearchOption"),
}
}
@@ -141,6 +140,7 @@ impl SearchOption {
) -> impl IntoElement {
let action = self.to_toggle_action();
let label = self.label();
IconButton::new(
(label, matches!(search_source, SearchSource::Buffer) as u32),
self.icon(),
@@ -162,7 +162,6 @@ impl SearchOption {
}))
}
})
.style(ButtonStyle::Subtle)
.shape(IconButtonShape::Square)
.toggle_state(active.contains(self.as_options()))
.tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx))
+32 -6
View File
@@ -45,8 +45,8 @@ use smol::future::yield_now;
use text::Anchor;
use theme_settings::ThemeSettings;
use ui::{
Disclosure, Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable,
Tooltip, prelude::*, text_for_keystroke,
Disclosure, Divider, FluentBuilder, ListItem, ListItemSpacing, Toggleable, Tooltip, prelude::*,
text_for_keystroke,
};
use util::ResultExt;
use workspace::SplitDirection;
@@ -719,12 +719,12 @@ impl PickerDelegate for Delegate {
let label = option.label();
let focus_handle = focus_handle.clone();
let picker = picker.clone();
IconButton::new(
("text-finder-search-option", option as usize),
option.icon(),
)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.toggle_state(active.contains(options))
.tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx))
.on_click(move |_, window, cx| {
@@ -737,8 +737,9 @@ impl PickerDelegate for Delegate {
Some(
h_flex()
.gap_1()
.gap_px()
.children(filter_buttons)
.child(Divider::vertical().ml_px().mr_0p5())
.children(picker::parts::project_scan_indicator(
self.active_query.is_some(),
self.project(cx),
@@ -772,7 +773,7 @@ impl PickerDelegate for Delegate {
picker::PickerAction::separator(),
picker::PickerAction::button(
if self.selected_matches.len() > 1 {
"Open multiple"
"Open Multiple"
} else {
"Open File"
},
@@ -1009,9 +1010,33 @@ impl PickerDelegate for Delegate {
&self,
ix: usize,
selected: bool,
_: &mut Window,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
self.render_match_impl(ix, selected, None, window, cx)
}
fn render_match_with_checkbox(
&self,
ix: usize,
selected: bool,
checkbox: AnyElement,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
self.render_match_impl(ix, selected, Some(checkbox), window, cx)
}
}
impl Delegate {
fn render_match_impl(
&self,
ix: usize,
selected: bool,
checkbox: Option<AnyElement>,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
match self.entries.get(ix)? {
Entry::Separator => Some(
div()
@@ -1129,6 +1154,7 @@ impl PickerDelegate for Delegate {
.spacing(ListItemSpacing::Sparse)
.inset(true)
.toggle_state(selected)
.when_some(checkbox, |this, checkbox| this.start_slot(checkbox))
.child(
h_flex()
.w_full()