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