sidebar_recent_projects.rs

  1use std::collections::HashSet;
  2use std::sync::Arc;
  3
  4use chrono::{DateTime, Utc};
  5use fuzzy::{StringMatch, StringMatchCandidate};
  6use gpui::{
  7    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  8    Subscription, Task, WeakEntity, Window,
  9};
 10use picker::{
 11    Picker, PickerDelegate,
 12    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
 13};
 14use remote::RemoteConnectionOptions;
 15use settings::Settings;
 16use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
 17use ui_input::ErasedEditor;
 18use util::{ResultExt, paths::PathExt};
 19use workspace::{
 20    MultiWorkspace, OpenMode, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace,
 21    WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr,
 22};
 23
 24use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
 25
 26pub struct SidebarRecentProjects {
 27    pub picker: Entity<Picker<SidebarRecentProjectsDelegate>>,
 28    _subscription: Subscription,
 29}
 30
 31impl SidebarRecentProjects {
 32    pub fn popover(
 33        workspace: WeakEntity<Workspace>,
 34        sibling_workspace_ids: HashSet<WorkspaceId>,
 35        _focus_handle: FocusHandle,
 36        window: &mut Window,
 37        cx: &mut App,
 38    ) -> Entity<Self> {
 39        let fs = workspace
 40            .upgrade()
 41            .map(|ws| ws.read(cx).app_state().fs.clone());
 42
 43        cx.new(|cx| {
 44            let delegate = SidebarRecentProjectsDelegate {
 45                workspace,
 46                sibling_workspace_ids,
 47                workspaces: Vec::new(),
 48                filtered_workspaces: Vec::new(),
 49                selected_index: 0,
 50                has_any_non_local_projects: false,
 51                focus_handle: cx.focus_handle(),
 52            };
 53
 54            let picker: Entity<Picker<SidebarRecentProjectsDelegate>> = cx.new(|cx| {
 55                Picker::list(delegate, window, cx)
 56                    .list_measure_all()
 57                    .show_scrollbar(true)
 58            });
 59
 60            let picker_focus_handle = picker.focus_handle(cx);
 61            picker.update(cx, |picker, _| {
 62                picker.delegate.focus_handle = picker_focus_handle;
 63            });
 64
 65            let _subscription =
 66                cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent));
 67
 68            let db = WorkspaceDb::global(cx);
 69            cx.spawn_in(window, async move |this, cx| {
 70                let Some(fs) = fs else { return };
 71                let workspaces = db
 72                    .recent_workspaces_on_disk(fs.as_ref())
 73                    .await
 74                    .log_err()
 75                    .unwrap_or_default();
 76                let workspaces =
 77                    workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
 78                this.update_in(cx, move |this, window, cx| {
 79                    this.picker.update(cx, move |picker, cx| {
 80                        picker.delegate.set_workspaces(workspaces);
 81                        picker.update_matches(picker.query(cx), window, cx)
 82                    })
 83                })
 84                .ok();
 85            })
 86            .detach();
 87
 88            picker.focus_handle(cx).focus(window, cx);
 89
 90            Self {
 91                picker,
 92                _subscription,
 93            }
 94        })
 95    }
 96}
 97
 98impl EventEmitter<DismissEvent> for SidebarRecentProjects {}
 99
100impl Focusable for SidebarRecentProjects {
101    fn focus_handle(&self, cx: &App) -> FocusHandle {
102        self.picker.focus_handle(cx)
103    }
104}
105
106impl Render for SidebarRecentProjects {
107    fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
108        v_flex()
109            .key_context("SidebarRecentProjects")
110            .w(rems(18.))
111            .child(self.picker.clone())
112    }
113}
114
115pub struct SidebarRecentProjectsDelegate {
116    workspace: WeakEntity<Workspace>,
117    sibling_workspace_ids: HashSet<WorkspaceId>,
118    workspaces: Vec<(
119        WorkspaceId,
120        SerializedWorkspaceLocation,
121        PathList,
122        DateTime<Utc>,
123    )>,
124    filtered_workspaces: Vec<StringMatch>,
125    selected_index: usize,
126    has_any_non_local_projects: bool,
127    focus_handle: FocusHandle,
128}
129
130impl SidebarRecentProjectsDelegate {
131    pub fn set_workspaces(
132        &mut self,
133        workspaces: Vec<(
134            WorkspaceId,
135            SerializedWorkspaceLocation,
136            PathList,
137            DateTime<Utc>,
138        )>,
139    ) {
140        self.has_any_non_local_projects = workspaces
141            .iter()
142            .any(|(_, location, _, _)| !matches!(location, SerializedWorkspaceLocation::Local));
143        self.workspaces = workspaces;
144    }
145}
146
147impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
148
149impl PickerDelegate for SidebarRecentProjectsDelegate {
150    type ListItem = AnyElement;
151
152    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
153        "Search recent projects…".into()
154    }
155
156    fn render_editor(
157        &self,
158        editor: &Arc<dyn ErasedEditor>,
159        window: &mut Window,
160        cx: &mut Context<Picker<Self>>,
161    ) -> Div {
162        h_flex()
163            .flex_none()
164            .h_9()
165            .px_2p5()
166            .justify_between()
167            .border_b_1()
168            .border_color(cx.theme().colors().border_variant)
169            .child(editor.render(window, cx))
170    }
171
172    fn match_count(&self) -> usize {
173        self.filtered_workspaces.len()
174    }
175
176    fn selected_index(&self) -> usize {
177        self.selected_index
178    }
179
180    fn set_selected_index(
181        &mut self,
182        ix: usize,
183        _window: &mut Window,
184        _cx: &mut Context<Picker<Self>>,
185    ) {
186        self.selected_index = ix;
187    }
188
189    fn update_matches(
190        &mut self,
191        query: String,
192        _: &mut Window,
193        cx: &mut Context<Picker<Self>>,
194    ) -> Task<()> {
195        let query = query.trim_start();
196        let smart_case = query.chars().any(|c| c.is_uppercase());
197        let is_empty_query = query.is_empty();
198
199        let current_workspace_id = self
200            .workspace
201            .upgrade()
202            .and_then(|ws| ws.read(cx).database_id());
203
204        let candidates: Vec<_> = self
205            .workspaces
206            .iter()
207            .enumerate()
208            .filter(|(_, (id, _, _, _))| {
209                Some(*id) != current_workspace_id && !self.sibling_workspace_ids.contains(id)
210            })
211            .map(|(id, (_, _, paths, _))| {
212                let combined_string = paths
213                    .ordered_paths()
214                    .map(|path| path.compact().to_string_lossy().into_owned())
215                    .collect::<Vec<_>>()
216                    .join("");
217                StringMatchCandidate::new(id, &combined_string)
218            })
219            .collect();
220
221        if is_empty_query {
222            self.filtered_workspaces = candidates
223                .into_iter()
224                .map(|candidate| StringMatch {
225                    candidate_id: candidate.id,
226                    score: 0.0,
227                    positions: Vec::new(),
228                    string: candidate.string,
229                })
230                .collect();
231        } else {
232            let mut matches = smol::block_on(fuzzy::match_strings(
233                &candidates,
234                query,
235                smart_case,
236                true,
237                100,
238                &Default::default(),
239                cx.background_executor().clone(),
240            ));
241            matches.sort_unstable_by(|a, b| {
242                b.score
243                    .partial_cmp(&a.score)
244                    .unwrap_or(std::cmp::Ordering::Equal)
245                    .then_with(|| a.candidate_id.cmp(&b.candidate_id))
246            });
247            self.filtered_workspaces = matches;
248        }
249
250        self.selected_index = 0;
251        Task::ready(())
252    }
253
254    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
255        let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
256            return;
257        };
258        let Some((_, location, candidate_workspace_paths, _)) =
259            self.workspaces.get(hit.candidate_id)
260        else {
261            return;
262        };
263
264        let Some(workspace) = self.workspace.upgrade() else {
265            return;
266        };
267
268        match location {
269            SerializedWorkspaceLocation::Local => {
270                if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
271                    let paths = candidate_workspace_paths.paths().to_vec();
272                    cx.defer(move |cx| {
273                        if let Some(task) = handle
274                            .update(cx, |multi_workspace, window, cx| {
275                                multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
276                            })
277                            .log_err()
278                        {
279                            task.detach_and_log_err(cx);
280                        }
281                    });
282                }
283            }
284            SerializedWorkspaceLocation::Remote(connection) => {
285                let mut connection = connection.clone();
286                workspace.update(cx, |workspace, cx| {
287                    let app_state = workspace.app_state().clone();
288                    let replace_window = window.window_handle().downcast::<MultiWorkspace>();
289                    let open_options = OpenOptions {
290                        requesting_window: replace_window,
291                        ..Default::default()
292                    };
293                    if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
294                        crate::RemoteSettings::get_global(cx)
295                            .fill_connection_options_from_settings(connection);
296                    };
297                    let paths = candidate_workspace_paths.paths().to_vec();
298                    cx.spawn_in(window, async move |_, cx| {
299                        open_remote_project(connection.clone(), paths, app_state, open_options, cx)
300                            .await
301                    })
302                    .detach_and_prompt_err(
303                        "Failed to open project",
304                        window,
305                        cx,
306                        |_, _, _| None,
307                    );
308                });
309            }
310        }
311        cx.emit(DismissEvent);
312    }
313
314    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
315
316    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
317        let text = if self.workspaces.is_empty() {
318            "Recently opened projects will show up here"
319        } else {
320            "No matches"
321        };
322        Some(text.into())
323    }
324
325    fn render_match(
326        &self,
327        ix: usize,
328        selected: bool,
329        window: &mut Window,
330        cx: &mut Context<Picker<Self>>,
331    ) -> Option<Self::ListItem> {
332        let hit = self.filtered_workspaces.get(ix)?;
333        let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
334
335        let ordered_paths: Vec<_> = paths
336            .ordered_paths()
337            .map(|p| p.compact().to_string_lossy().to_string())
338            .collect();
339
340        let tooltip_path: SharedString = match &location {
341            SerializedWorkspaceLocation::Remote(options) => {
342                let host = options.display_name();
343                if ordered_paths.len() == 1 {
344                    format!("{} ({})", ordered_paths[0], host).into()
345                } else {
346                    format!("{}\n({})", ordered_paths.join("\n"), host).into()
347                }
348            }
349            _ => ordered_paths.join("\n").into(),
350        };
351
352        let mut path_start_offset = 0;
353        let match_labels: Vec<_> = paths
354            .ordered_paths()
355            .map(|p| p.compact())
356            .map(|path| {
357                let (label, path_match) =
358                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
359                path_start_offset += path_match.text.len();
360                label
361            })
362            .collect();
363
364        let prefix = match &location {
365            SerializedWorkspaceLocation::Remote(options) => {
366                Some(SharedString::from(options.display_name()))
367            }
368            _ => None,
369        };
370
371        let highlighted_match = HighlightedMatchWithPaths {
372            prefix,
373            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
374            paths: Vec::new(),
375        };
376
377        let icon = icon_for_remote_connection(match location {
378            SerializedWorkspaceLocation::Local => None,
379            SerializedWorkspaceLocation::Remote(options) => Some(options),
380        });
381
382        Some(
383            ListItem::new(ix)
384                .toggle_state(selected)
385                .inset(true)
386                .spacing(ListItemSpacing::Sparse)
387                .child(
388                    h_flex()
389                        .gap_3()
390                        .flex_grow()
391                        .when(self.has_any_non_local_projects, |this| {
392                            this.child(Icon::new(icon).color(Color::Muted))
393                        })
394                        .child(highlighted_match.render(window, cx)),
395                )
396                .tooltip(Tooltip::text(tooltip_path))
397                .into_any_element(),
398        )
399    }
400
401    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
402        let focus_handle = self.focus_handle.clone();
403
404        Some(
405            v_flex()
406                .p_1p5()
407                .flex_1()
408                .gap_1()
409                .border_t_1()
410                .border_color(cx.theme().colors().border_variant)
411                .child({
412                    let open_action = workspace::Open {
413                        create_new_window: false,
414                    };
415                    Button::new("open_local_folder", "Add Local Project")
416                        .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
417                        .on_click(cx.listener(move |_, _, window, cx| {
418                            cx.emit(DismissEvent);
419                            window.dispatch_action(open_action.boxed_clone(), cx)
420                        }))
421                })
422                .into_any(),
423        )
424    }
425}