Files
zed/crates/recent_projects/src/sidebar_recent_projects.rs
T
Danilo Leal ae6cb0aff1 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.
2026-07-13 18:21:15 +00:00

442 lines
15 KiB
Rust

use std::sync::Arc;
use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
Subscription, Task, TaskExt, WeakEntity, Window,
};
use picker::{
Picker, PickerDelegate,
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
};
use remote::RemoteConnectionOptions;
use settings::Settings;
use ui::{ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
use util::{ResultExt, paths::PathExt};
use workspace::{
MultiWorkspace, OpenMode, OpenOptions, ProjectGroupKey, RecentWorkspace,
SerializedWorkspaceLocation, Workspace, WorkspaceDb, notifications::DetachAndPromptErr,
};
use zed_actions::OpenRemote;
use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
pub struct SidebarRecentProjects {
pub picker: Entity<Picker<SidebarRecentProjectsDelegate>>,
_subscription: Subscription,
}
impl SidebarRecentProjects {
pub fn popover(
workspace: WeakEntity<Workspace>,
window_project_groups: Vec<ProjectGroupKey>,
_focus_handle: FocusHandle,
window: &mut Window,
cx: &mut App,
) -> Entity<Self> {
let fs = workspace
.upgrade()
.map(|ws| ws.read(cx).app_state().fs.clone());
cx.new(|cx| {
let delegate = SidebarRecentProjectsDelegate {
workspace,
window_project_groups,
workspaces: Vec::new(),
filtered_workspaces: Vec::new(),
selected_index: 0,
has_any_non_local_projects: false,
focus_handle: cx.focus_handle(),
};
let picker: Entity<Picker<SidebarRecentProjectsDelegate>> = cx.new(|cx| {
Picker::list(delegate, window, cx)
.list_measure_all()
.show_scrollbar(true)
.initial_width(rems(18.))
.popover()
});
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle;
});
let _subscription =
cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent));
let db = WorkspaceDb::global(cx);
cx.spawn_in(window, async move |this, cx| {
let Some(fs) = fs else { return };
let workspaces = db
.recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
this.update_in(cx, move |this, window, cx| {
this.picker.update(cx, move |picker, cx| {
picker.delegate.set_workspaces(workspaces);
picker.update_matches(picker.query(cx), window, cx)
})
})
.ok();
})
.detach();
picker.focus_handle(cx).focus(window, cx);
Self {
picker,
_subscription,
}
})
}
}
impl EventEmitter<DismissEvent> for SidebarRecentProjects {}
impl Focusable for SidebarRecentProjects {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for SidebarRecentProjects {
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("SidebarRecentProjects")
.w(rems(18.))
.child(self.picker.clone())
}
}
pub struct SidebarRecentProjectsDelegate {
workspace: WeakEntity<Workspace>,
window_project_groups: Vec<ProjectGroupKey>,
workspaces: Vec<RecentWorkspace>,
filtered_workspaces: Vec<StringMatch>,
selected_index: usize,
has_any_non_local_projects: bool,
focus_handle: FocusHandle,
}
impl SidebarRecentProjectsDelegate {
pub fn set_workspaces(&mut self, workspaces: Vec<RecentWorkspace>) {
self.has_any_non_local_projects = workspaces
.iter()
.any(|workspace| !matches!(workspace.location, SerializedWorkspaceLocation::Local));
self.workspaces = workspaces;
}
}
impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
impl PickerDelegate for SidebarRecentProjectsDelegate {
type ListItem = AnyElement;
fn name() -> &'static str {
"sidebar recent projects"
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search projects…".into()
}
fn match_count(&self) -> usize {
self.filtered_workspaces.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let query = query.trim_start();
let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
let is_empty_query = query.is_empty();
let current_workspace_id = self
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).database_id());
let candidates: Vec<_> = self
.workspaces
.iter()
.enumerate()
.filter(|(_, workspace)| {
Some(workspace.workspace_id) != current_workspace_id
&& !self
.window_project_groups
.iter()
.any(|key| key.matches(&workspace.project_group_key()))
})
.map(|(id, workspace)| {
let combined_string = workspace
.identity_paths
.ordered_paths()
.map(|path| path.compact().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.concat();
StringMatchCandidate::new(id, &combined_string)
})
.collect();
if is_empty_query {
self.filtered_workspaces = candidates
.into_iter()
.map(|candidate| StringMatch {
candidate_id: candidate.id,
score: 0.0,
positions: Vec::new(),
string: candidate.string,
})
.collect();
} else {
self.filtered_workspaces = match_strings(
&candidates,
query,
case,
fuzzy_nucleo::LengthPenalty::On,
100,
);
}
self.selected_index = 0;
Task::ready(())
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
return;
};
let Some(recent_workspace) = self.workspaces.get(hit.candidate_id) else {
return;
};
let Some(workspace) = self.workspace.upgrade() else {
return;
};
match &recent_workspace.location {
SerializedWorkspaceLocation::Local => {
if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
let paths = recent_workspace.paths.paths().to_vec();
cx.defer(move |cx| {
if let Some(task) = handle
.update(cx, |multi_workspace, window, cx| {
multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
})
.log_err()
{
task.detach_and_log_err(cx);
}
});
}
}
SerializedWorkspaceLocation::Remote(connection) => {
let mut connection = connection.clone();
workspace.update(cx, |workspace, cx| {
let app_state = workspace.app_state().clone();
let replace_window = window.window_handle().downcast::<MultiWorkspace>();
let open_options = OpenOptions {
requesting_window: replace_window,
..Default::default()
};
if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
crate::RemoteSettings::get_global(cx)
.fill_connection_options_from_settings(connection);
};
let paths = recent_workspace.paths.paths().to_vec();
cx.spawn_in(window, async move |_, cx| {
open_remote_project(connection.clone(), paths, app_state, open_options, cx)
.await
})
.detach_and_prompt_err(
"Failed to open project",
window,
cx,
|_, _, _| None,
);
});
}
}
cx.emit(DismissEvent);
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
let text = if self.workspaces.is_empty() {
"Recently opened projects will show up here"
} else {
"No matches"
};
Some(text.into())
}
fn render_match(
&self,
ix: usize,
selected: bool,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let hit = self.filtered_workspaces.get(ix)?;
let workspace = self.workspaces.get(hit.candidate_id)?;
let ordered_paths: Vec<_> = workspace
.identity_paths
.ordered_paths()
.map(|p| p.compact().to_string_lossy().to_string())
.collect();
let tooltip_path: SharedString = match &workspace.location {
SerializedWorkspaceLocation::Remote(options) => {
let host = options.display_name();
if ordered_paths.len() == 1 {
format!("{} ({})", ordered_paths[0], host).into()
} else {
format!("{}\n({})", ordered_paths.join("\n"), host).into()
}
}
_ => ordered_paths.join("\n").into(),
};
let mut path_start_offset = 0;
let match_labels: Vec<_> = workspace
.identity_paths
.ordered_paths()
.map(|p| p.compact())
.map(|path| {
let (label, path_match) =
highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
path_start_offset += path_match.text.len();
label
})
.collect();
let prefix = match &workspace.location {
SerializedWorkspaceLocation::Remote(options) => {
Some(SharedString::from(options.display_name()))
}
_ => None,
};
let highlighted_match = HighlightedMatchWithPaths {
prefix,
match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
paths: Vec::new(),
active: false,
};
let icon = icon_for_remote_connection(match &workspace.location {
SerializedWorkspaceLocation::Local => None,
SerializedWorkspaceLocation::Remote(options) => Some(options),
});
Some(
ListItem::new(ix)
.toggle_state(selected)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.child(
h_flex()
.w_full()
.min_w_0()
.gap_3()
.flex_grow_1()
.when(self.has_any_non_local_projects, |this| {
this.child(Icon::new(icon).color(Color::Muted))
})
.child(highlighted_match.render(window, cx)),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(
"Open Project in This Window",
None,
tooltip_path.clone(),
cx,
)
})
.into_any_element(),
)
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
let focus_handle = self.focus_handle.clone();
Some(
v_flex()
.p_1p5()
.flex_1()
.gap_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child({
let open_action = workspace::Open {
create_new_window: Some(false),
};
ButtonLike::new("open_local_folder")
.child(
h_flex()
.w_full()
.gap_1()
.justify_between()
.child(Label::new("Open Local Folders"))
.child(KeyBinding::for_action_in(&open_action, &focus_handle, cx)),
)
.on_click(cx.listener(move |_, _, window, cx| {
window.dispatch_action(open_action.boxed_clone(), cx);
cx.emit(DismissEvent);
}))
})
.child(
ButtonLike::new("open_remote_folder")
.child(
h_flex()
.w_full()
.gap_1()
.justify_between()
.child(Label::new("Open Remote Folder"))
.child(KeyBinding::for_action(
&OpenRemote {
from_existing_connection: false,
create_new_window: Some(false),
},
cx,
)),
)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
OpenRemote {
from_existing_connection: false,
create_new_window: Some(false),
}
.boxed_clone(),
cx,
);
cx.emit(DismissEvent);
})),
)
.into_any(),
)
}
}