sidebar_recent_projects.rs

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