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