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