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 ui_input::ErasedEditor; 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>, _subscription: Subscription, } impl SidebarRecentProjects { pub fn popover( workspace: WeakEntity, window_project_groups: Vec, _focus_handle: FocusHandle, window: &mut Window, cx: &mut App, ) -> Entity { 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> = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() .show_scrollbar(true) }); 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 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) -> impl IntoElement { v_flex() .key_context("SidebarRecentProjects") .w(rems(18.)) .child(self.picker.clone()) } } pub struct SidebarRecentProjectsDelegate { workspace: WeakEntity, window_project_groups: Vec, workspaces: Vec, filtered_workspaces: Vec, selected_index: usize, has_any_non_local_projects: bool, focus_handle: FocusHandle, } impl SidebarRecentProjectsDelegate { pub fn set_workspaces(&mut self, workspaces: Vec) { self.has_any_non_local_projects = workspaces .iter() .any(|workspace| !matches!(workspace.location, SerializedWorkspaceLocation::Local)); self.workspaces = workspaces; } } impl EventEmitter for SidebarRecentProjectsDelegate {} impl PickerDelegate for SidebarRecentProjectsDelegate { type ListItem = AnyElement; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { "Search recent projects…".into() } fn render_editor( &self, editor: &Arc, window: &mut Window, cx: &mut Context>, ) -> 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() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context>, ) { self.selected_index = ix; } fn update_matches( &mut self, query: String, _: &mut Window, cx: &mut Context>, ) -> 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::>() .join(""); 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>) { 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::() { 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::(); 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>) {} fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { 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>, ) -> Option { 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() .gap_3() .flex_grow() .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>) -> Option { 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: false, }; ButtonLike::new("open_local_folder") .child( h_flex() .w_full() .gap_1() .justify_between() .child(Label::new("Add 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("Add Remote Folder")) .child(KeyBinding::for_action( &OpenRemote { from_existing_connection: false, create_new_window: false, }, cx, )), ) .on_click(cx.listener(|_, _, window, cx| { window.dispatch_action( OpenRemote { from_existing_connection: false, create_new_window: false, } .boxed_clone(), cx, ); cx.emit(DismissEvent); })), ) .into_any(), ) } }