sidebar_recent_projects.rs

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