sidebar_recent_projects.rs

  1use std::sync::Arc;
  2
  3use chrono::{DateTime, Utc};
  4use fuzzy::{StringMatch, StringMatchCandidate};
  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::{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_workspaces_on_disk(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 smart_case = query.chars().any(|c| c.is_uppercase());
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            let mut matches = smol::block_on(fuzzy::match_strings(
238                &candidates,
239                query,
240                smart_case,
241                true,
242                100,
243                &Default::default(),
244                cx.background_executor().clone(),
245            ));
246            matches.sort_unstable_by(|a, b| {
247                b.score
248                    .partial_cmp(&a.score)
249                    .unwrap_or(std::cmp::Ordering::Equal)
250                    .then_with(|| a.candidate_id.cmp(&b.candidate_id))
251            });
252            self.filtered_workspaces = matches;
253        }
254
255        self.selected_index = 0;
256        Task::ready(())
257    }
258
259    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
260        let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
261            return;
262        };
263        let Some((_, location, candidate_workspace_paths, _)) =
264            self.workspaces.get(hit.candidate_id)
265        else {
266            return;
267        };
268
269        let Some(workspace) = self.workspace.upgrade() else {
270            return;
271        };
272
273        match location {
274            SerializedWorkspaceLocation::Local => {
275                if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
276                    let paths = candidate_workspace_paths.paths().to_vec();
277                    cx.defer(move |cx| {
278                        if let Some(task) = handle
279                            .update(cx, |multi_workspace, window, cx| {
280                                multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
281                            })
282                            .log_err()
283                        {
284                            task.detach_and_log_err(cx);
285                        }
286                    });
287                }
288            }
289            SerializedWorkspaceLocation::Remote(connection) => {
290                let mut connection = connection.clone();
291                workspace.update(cx, |workspace, cx| {
292                    let app_state = workspace.app_state().clone();
293                    let replace_window = window.window_handle().downcast::<MultiWorkspace>();
294                    let open_options = OpenOptions {
295                        requesting_window: replace_window,
296                        ..Default::default()
297                    };
298                    if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
299                        crate::RemoteSettings::get_global(cx)
300                            .fill_connection_options_from_settings(connection);
301                    };
302                    let paths = candidate_workspace_paths.paths().to_vec();
303                    cx.spawn_in(window, async move |_, cx| {
304                        open_remote_project(connection.clone(), paths, app_state, open_options, cx)
305                            .await
306                    })
307                    .detach_and_prompt_err(
308                        "Failed to open project",
309                        window,
310                        cx,
311                        |_, _, _| None,
312                    );
313                });
314            }
315        }
316        cx.emit(DismissEvent);
317    }
318
319    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
320
321    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
322        let text = if self.workspaces.is_empty() {
323            "Recently opened projects will show up here"
324        } else {
325            "No matches"
326        };
327        Some(text.into())
328    }
329
330    fn render_match(
331        &self,
332        ix: usize,
333        selected: bool,
334        window: &mut Window,
335        cx: &mut Context<Picker<Self>>,
336    ) -> Option<Self::ListItem> {
337        let hit = self.filtered_workspaces.get(ix)?;
338        let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
339
340        let ordered_paths: Vec<_> = paths
341            .ordered_paths()
342            .map(|p| p.compact().to_string_lossy().to_string())
343            .collect();
344
345        let tooltip_path: SharedString = match &location {
346            SerializedWorkspaceLocation::Remote(options) => {
347                let host = options.display_name();
348                if ordered_paths.len() == 1 {
349                    format!("{} ({})", ordered_paths[0], host).into()
350                } else {
351                    format!("{}\n({})", ordered_paths.join("\n"), host).into()
352                }
353            }
354            _ => ordered_paths.join("\n").into(),
355        };
356
357        let mut path_start_offset = 0;
358        let match_labels: Vec<_> = paths
359            .ordered_paths()
360            .map(|p| p.compact())
361            .map(|path| {
362                let (label, path_match) =
363                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
364                path_start_offset += path_match.text.len();
365                label
366            })
367            .collect();
368
369        let prefix = match &location {
370            SerializedWorkspaceLocation::Remote(options) => {
371                Some(SharedString::from(options.display_name()))
372            }
373            _ => None,
374        };
375
376        let highlighted_match = HighlightedMatchWithPaths {
377            prefix,
378            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
379            paths: Vec::new(),
380            active: false,
381        };
382
383        let icon = icon_for_remote_connection(match location {
384            SerializedWorkspaceLocation::Local => None,
385            SerializedWorkspaceLocation::Remote(options) => Some(options),
386        });
387
388        Some(
389            ListItem::new(ix)
390                .toggle_state(selected)
391                .inset(true)
392                .spacing(ListItemSpacing::Sparse)
393                .child(
394                    h_flex()
395                        .gap_3()
396                        .flex_grow()
397                        .when(self.has_any_non_local_projects, |this| {
398                            this.child(Icon::new(icon).color(Color::Muted))
399                        })
400                        .child(highlighted_match.render(window, cx)),
401                )
402                .tooltip(move |_, cx| {
403                    Tooltip::with_meta(
404                        "Open Project in This Window",
405                        None,
406                        tooltip_path.clone(),
407                        cx,
408                    )
409                })
410                .into_any_element(),
411        )
412    }
413
414    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
415        let focus_handle = self.focus_handle.clone();
416
417        Some(
418            v_flex()
419                .p_1p5()
420                .flex_1()
421                .gap_1()
422                .border_t_1()
423                .border_color(cx.theme().colors().border_variant)
424                .child({
425                    let open_action = workspace::Open {
426                        create_new_window: false,
427                    };
428
429                    Button::new("open_local_folder", "Add Local Project")
430                        .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
431                        .on_click(cx.listener(move |_, _, window, cx| {
432                            window.dispatch_action(open_action.boxed_clone(), cx);
433                            cx.emit(DismissEvent);
434                        }))
435                })
436                .child(
437                    Button::new("open_remote_folder", "Add Remote Project")
438                        .key_binding(KeyBinding::for_action(
439                            &OpenRemote {
440                                from_existing_connection: false,
441                                create_new_window: false,
442                            },
443                            cx,
444                        ))
445                        .on_click(cx.listener(|_, _, window, cx| {
446                            window.dispatch_action(
447                                OpenRemote {
448                                    from_existing_connection: false,
449                                    create_new_window: false,
450                                }
451                                .boxed_clone(),
452                                cx,
453                            );
454                            cx.emit(DismissEvent);
455                        })),
456                )
457                .into_any(),
458        )
459    }
460}