recent_projects.rs

  1pub mod disconnected_overlay;
  2mod remote_servers;
  3mod ssh_connections;
  4pub use ssh_connections::open_ssh_project;
  5
  6use disconnected_overlay::DisconnectedOverlay;
  7use fuzzy::{StringMatch, StringMatchCandidate};
  8use gpui::{
  9    Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
 10    Subscription, Task, View, ViewContext, WeakView,
 11};
 12use itertools::Itertools;
 13use ordered_float::OrderedFloat;
 14use picker::{
 15    highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
 16    Picker, PickerDelegate,
 17};
 18pub use remote_servers::RemoteServerProjects;
 19use serde::Deserialize;
 20use settings::Settings;
 21pub use ssh_connections::SshSettings;
 22use std::{
 23    path::{Path, PathBuf},
 24    sync::Arc,
 25};
 26use ui::{
 27    prelude::*, tooltip_container, ButtonLike, KeyBinding, ListItem, ListItemSpacing, Tooltip,
 28};
 29use util::{paths::PathExt, ResultExt};
 30use workspace::{
 31    CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, Workspace, WorkspaceId,
 32    WORKSPACE_DB,
 33};
 34
 35#[derive(PartialEq, Clone, Deserialize, Default)]
 36pub struct OpenRecent {
 37    #[serde(default = "default_create_new_window")]
 38    pub create_new_window: bool,
 39}
 40
 41fn default_create_new_window() -> bool {
 42    true
 43}
 44
 45gpui::impl_actions!(projects, [OpenRecent]);
 46gpui::actions!(projects, [OpenRemote]);
 47
 48pub fn init(cx: &mut AppContext) {
 49    SshSettings::register(cx);
 50    cx.observe_new_views(RecentProjects::register).detach();
 51    cx.observe_new_views(RemoteServerProjects::register)
 52        .detach();
 53    cx.observe_new_views(DisconnectedOverlay::register).detach();
 54}
 55
 56pub struct RecentProjects {
 57    pub picker: View<Picker<RecentProjectsDelegate>>,
 58    rem_width: f32,
 59    _subscription: Subscription,
 60}
 61
 62impl ModalView for RecentProjects {}
 63
 64impl RecentProjects {
 65    fn new(delegate: RecentProjectsDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
 66        let picker = cx.new_view(|cx| {
 67            // We want to use a list when we render paths, because the items can have different heights (multiple paths).
 68            if delegate.render_paths {
 69                Picker::list(delegate, cx)
 70            } else {
 71                Picker::uniform_list(delegate, cx)
 72            }
 73        });
 74        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
 75        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
 76        // out workspace locations once the future runs to completion.
 77        cx.spawn(|this, mut cx| async move {
 78            let workspaces = WORKSPACE_DB
 79                .recent_workspaces_on_disk()
 80                .await
 81                .log_err()
 82                .unwrap_or_default();
 83            this.update(&mut cx, move |this, cx| {
 84                this.picker.update(cx, move |picker, cx| {
 85                    picker.delegate.set_workspaces(workspaces);
 86                    picker.update_matches(picker.query(cx), cx)
 87                })
 88            })
 89            .ok()
 90        })
 91        .detach();
 92        Self {
 93            picker,
 94            rem_width,
 95            _subscription,
 96        }
 97    }
 98
 99    fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
100        workspace.register_action(|workspace, open_recent: &OpenRecent, cx| {
101            let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
102                Self::open(workspace, open_recent.create_new_window, cx);
103                return;
104            };
105
106            recent_projects.update(cx, |recent_projects, cx| {
107                recent_projects
108                    .picker
109                    .update(cx, |picker, cx| picker.cycle_selection(cx))
110            });
111        });
112    }
113
114    pub fn open(
115        workspace: &mut Workspace,
116        create_new_window: bool,
117        cx: &mut ViewContext<Workspace>,
118    ) {
119        let weak = cx.view().downgrade();
120        workspace.toggle_modal(cx, |cx| {
121            let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
122
123            Self::new(delegate, 34., cx)
124        })
125    }
126}
127
128impl EventEmitter<DismissEvent> for RecentProjects {}
129
130impl FocusableView for RecentProjects {
131    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
132        self.picker.focus_handle(cx)
133    }
134}
135
136impl Render for RecentProjects {
137    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
138        v_flex()
139            .w(rems(self.rem_width))
140            .child(self.picker.clone())
141            .on_mouse_down_out(cx.listener(|this, _, cx| {
142                this.picker.update(cx, |this, cx| {
143                    this.cancel(&Default::default(), cx);
144                })
145            }))
146    }
147}
148
149pub struct RecentProjectsDelegate {
150    workspace: WeakView<Workspace>,
151    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
152    selected_match_index: usize,
153    matches: Vec<StringMatch>,
154    render_paths: bool,
155    create_new_window: bool,
156    // Flag to reset index when there is a new query vs not reset index when user delete an item
157    reset_selected_match_index: bool,
158    has_any_non_local_projects: bool,
159}
160
161impl RecentProjectsDelegate {
162    fn new(workspace: WeakView<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
163        Self {
164            workspace,
165            workspaces: Vec::new(),
166            selected_match_index: 0,
167            matches: Default::default(),
168            create_new_window,
169            render_paths,
170            reset_selected_match_index: true,
171            has_any_non_local_projects: false,
172        }
173    }
174
175    pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
176        self.workspaces = workspaces;
177        self.has_any_non_local_projects = !self
178            .workspaces
179            .iter()
180            .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _)));
181    }
182}
183impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
184impl PickerDelegate for RecentProjectsDelegate {
185    type ListItem = ListItem;
186
187    fn placeholder_text(&self, cx: &mut WindowContext) -> Arc<str> {
188        let (create_window, reuse_window) = if self.create_new_window {
189            (
190                cx.keystroke_text_for(&menu::Confirm),
191                cx.keystroke_text_for(&menu::SecondaryConfirm),
192            )
193        } else {
194            (
195                cx.keystroke_text_for(&menu::SecondaryConfirm),
196                cx.keystroke_text_for(&menu::Confirm),
197            )
198        };
199        Arc::from(format!(
200            "{reuse_window} reuses this window, {create_window} opens a new one",
201        ))
202    }
203
204    fn match_count(&self) -> usize {
205        self.matches.len()
206    }
207
208    fn selected_index(&self) -> usize {
209        self.selected_match_index
210    }
211
212    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
213        self.selected_match_index = ix;
214    }
215
216    fn update_matches(
217        &mut self,
218        query: String,
219        cx: &mut ViewContext<Picker<Self>>,
220    ) -> gpui::Task<()> {
221        let query = query.trim_start();
222        let smart_case = query.chars().any(|c| c.is_uppercase());
223        let candidates = self
224            .workspaces
225            .iter()
226            .enumerate()
227            .filter(|(_, (id, _))| !self.is_current_workspace(*id, cx))
228            .map(|(id, (_, location))| {
229                let combined_string = match location {
230                    SerializedWorkspaceLocation::Local(paths, order) => order
231                        .order()
232                        .iter()
233                        .zip(paths.paths().iter())
234                        .sorted_by_key(|(i, _)| *i)
235                        .map(|(_, path)| path.compact().to_string_lossy().into_owned())
236                        .collect::<Vec<_>>()
237                        .join(""),
238                    SerializedWorkspaceLocation::Ssh(ssh_project) => ssh_project
239                        .ssh_urls()
240                        .iter()
241                        .map(|path| path.to_string_lossy().to_string())
242                        .collect::<Vec<_>>()
243                        .join(""),
244                };
245
246                StringMatchCandidate::new(id, combined_string)
247            })
248            .collect::<Vec<_>>();
249        self.matches = smol::block_on(fuzzy::match_strings(
250            candidates.as_slice(),
251            query,
252            smart_case,
253            100,
254            &Default::default(),
255            cx.background_executor().clone(),
256        ));
257        self.matches.sort_unstable_by_key(|m| m.candidate_id);
258
259        if self.reset_selected_match_index {
260            self.selected_match_index = self
261                .matches
262                .iter()
263                .enumerate()
264                .rev()
265                .max_by_key(|(_, m)| OrderedFloat(m.score))
266                .map(|(ix, _)| ix)
267                .unwrap_or(0);
268        }
269        self.reset_selected_match_index = true;
270        Task::ready(())
271    }
272
273    fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
274        if let Some((selected_match, workspace)) = self
275            .matches
276            .get(self.selected_index())
277            .zip(self.workspace.upgrade())
278        {
279            let (candidate_workspace_id, candidate_workspace_location) =
280                &self.workspaces[selected_match.candidate_id];
281            let replace_current_window = if self.create_new_window {
282                secondary
283            } else {
284                !secondary
285            };
286            workspace
287                .update(cx, |workspace, cx| {
288                    if workspace.database_id() == Some(*candidate_workspace_id) {
289                        Task::ready(Ok(()))
290                    } else {
291                        match candidate_workspace_location {
292                            SerializedWorkspaceLocation::Local(paths, _) => {
293                                let paths = paths.paths().to_vec();
294                                if replace_current_window {
295                                    cx.spawn(move |workspace, mut cx| async move {
296                                        let continue_replacing = workspace
297                                            .update(&mut cx, |workspace, cx| {
298                                                workspace.prepare_to_close(
299                                                    CloseIntent::ReplaceWindow,
300                                                    cx,
301                                                )
302                                            })?
303                                            .await?;
304                                        if continue_replacing {
305                                            workspace
306                                                .update(&mut cx, |workspace, cx| {
307                                                    workspace
308                                                        .open_workspace_for_paths(true, paths, cx)
309                                                })?
310                                                .await
311                                        } else {
312                                            Ok(())
313                                        }
314                                    })
315                                } else {
316                                    workspace.open_workspace_for_paths(false, paths, cx)
317                                }
318                            }
319                            SerializedWorkspaceLocation::Ssh(ssh_project) => {
320                                let app_state = workspace.app_state().clone();
321
322                                let replace_window = if replace_current_window {
323                                    cx.window_handle().downcast::<Workspace>()
324                                } else {
325                                    None
326                                };
327
328                                let open_options = OpenOptions {
329                                    replace_window,
330                                    ..Default::default()
331                                };
332
333                                let connection_options = SshSettings::get_global(cx)
334                                    .connection_options_for(
335                                        ssh_project.host.clone(),
336                                        ssh_project.port,
337                                        ssh_project.user.clone(),
338                                    );
339
340                                let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
341
342                                cx.spawn(|_, mut cx| async move {
343                                    open_ssh_project(
344                                        connection_options,
345                                        paths,
346                                        app_state,
347                                        open_options,
348                                        &mut cx,
349                                    )
350                                    .await
351                                })
352                            }
353                        }
354                    }
355                })
356                .detach_and_log_err(cx);
357            cx.emit(DismissEvent);
358        }
359    }
360
361    fn dismissed(&mut self, _: &mut ViewContext<Picker<Self>>) {}
362
363    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
364        if self.workspaces.is_empty() {
365            "Recently opened projects will show up here".into()
366        } else {
367            "No matches".into()
368        }
369    }
370
371    fn render_match(
372        &self,
373        ix: usize,
374        selected: bool,
375        cx: &mut ViewContext<Picker<Self>>,
376    ) -> Option<Self::ListItem> {
377        let hit = self.matches.get(ix)?;
378
379        let (_, location) = self.workspaces.get(hit.candidate_id)?;
380
381        let mut path_start_offset = 0;
382        let paths = match location {
383            SerializedWorkspaceLocation::Local(paths, order) => Arc::new(
384                order
385                    .order()
386                    .iter()
387                    .zip(paths.paths().iter())
388                    .sorted_by_key(|(i, _)| **i)
389                    .map(|(_, path)| path.compact())
390                    .collect(),
391            ),
392            SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
393        };
394
395        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
396            .iter()
397            .map(|path| {
398                let highlighted_text =
399                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
400
401                path_start_offset += highlighted_text.1.char_count;
402                highlighted_text
403            })
404            .unzip();
405
406        let highlighted_match = HighlightedMatchWithPaths {
407            match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", "),
408            paths,
409        };
410
411        Some(
412            ListItem::new(ix)
413                .selected(selected)
414                .inset(true)
415                .spacing(ListItemSpacing::Sparse)
416                .child(
417                    h_flex()
418                        .flex_grow()
419                        .gap_3()
420                        .when(self.has_any_non_local_projects, |this| {
421                            this.child(match location {
422                                SerializedWorkspaceLocation::Local(_, _) => {
423                                    Icon::new(IconName::Screen)
424                                        .color(Color::Muted)
425                                        .into_any_element()
426                                }
427                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
428                                    .color(Color::Muted)
429                                    .into_any_element(),
430                            })
431                        })
432                        .child({
433                            let mut highlighted = highlighted_match.clone();
434                            if !self.render_paths {
435                                highlighted.paths.clear();
436                            }
437                            highlighted.render(cx)
438                        }),
439                )
440                .map(|el| {
441                    let delete_button = div()
442                        .child(
443                            IconButton::new("delete", IconName::Close)
444                                .icon_size(IconSize::Small)
445                                .on_click(cx.listener(move |this, _event, cx| {
446                                    cx.stop_propagation();
447                                    cx.prevent_default();
448
449                                    this.delegate.delete_recent_project(ix, cx)
450                                }))
451                                .tooltip(|cx| Tooltip::text("Delete from Recent Projects...", cx)),
452                        )
453                        .into_any_element();
454
455                    if self.selected_index() == ix {
456                        el.end_slot::<AnyElement>(delete_button)
457                    } else {
458                        el.end_hover_slot::<AnyElement>(delete_button)
459                    }
460                })
461                .tooltip(move |cx| {
462                    let tooltip_highlighted_location = highlighted_match.clone();
463                    cx.new_view(move |_| MatchTooltip {
464                        highlighted_location: tooltip_highlighted_location,
465                    })
466                    .into()
467                }),
468        )
469    }
470
471    fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
472        Some(
473            h_flex()
474                .border_t_1()
475                .py_2()
476                .pr_2()
477                .border_color(cx.theme().colors().border_variant)
478                .justify_end()
479                .gap_4()
480                .child(
481                    ButtonLike::new("remote")
482                        .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| {
483                            button.child(key)
484                        })
485                        .child(Label::new("Open Remote Folder…").color(Color::Muted))
486                        .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())),
487                )
488                .child(
489                    ButtonLike::new("local")
490                        .when_some(
491                            KeyBinding::for_action(&workspace::Open, cx),
492                            |button, key| button.child(key),
493                        )
494                        .child(Label::new("Open Local Folder…").color(Color::Muted))
495                        .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())),
496                )
497                .into_any(),
498        )
499    }
500}
501
502// Compute the highlighted text for the name and path
503fn highlights_for_path(
504    path: &Path,
505    match_positions: &Vec<usize>,
506    path_start_offset: usize,
507) -> (Option<HighlightedText>, HighlightedText) {
508    let path_string = path.to_string_lossy();
509    let path_char_count = path_string.chars().count();
510    // Get the subset of match highlight positions that line up with the given path.
511    // Also adjusts them to start at the path start
512    let path_positions = match_positions
513        .iter()
514        .copied()
515        .skip_while(|position| *position < path_start_offset)
516        .take_while(|position| *position < path_start_offset + path_char_count)
517        .map(|position| position - path_start_offset)
518        .collect::<Vec<_>>();
519
520    // Again subset the highlight positions to just those that line up with the file_name
521    // again adjusted to the start of the file_name
522    let file_name_text_and_positions = path.file_name().map(|file_name| {
523        let text = file_name.to_string_lossy();
524        let char_count = text.chars().count();
525        let file_name_start = path_char_count - char_count;
526        let highlight_positions = path_positions
527            .iter()
528            .copied()
529            .skip_while(|position| *position < file_name_start)
530            .take_while(|position| *position < file_name_start + char_count)
531            .map(|position| position - file_name_start)
532            .collect::<Vec<_>>();
533        HighlightedText {
534            text: text.to_string(),
535            highlight_positions,
536            char_count,
537            color: Color::Default,
538        }
539    });
540
541    (
542        file_name_text_and_positions,
543        HighlightedText {
544            text: path_string.to_string(),
545            highlight_positions: path_positions,
546            char_count: path_char_count,
547            color: Color::Default,
548        },
549    )
550}
551impl RecentProjectsDelegate {
552    fn delete_recent_project(&self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
553        if let Some(selected_match) = self.matches.get(ix) {
554            let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
555            cx.spawn(move |this, mut cx| async move {
556                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
557                let workspaces = WORKSPACE_DB
558                    .recent_workspaces_on_disk()
559                    .await
560                    .unwrap_or_default();
561                this.update(&mut cx, move |picker, cx| {
562                    picker.delegate.set_workspaces(workspaces);
563                    picker.delegate.set_selected_index(ix.saturating_sub(1), cx);
564                    picker.delegate.reset_selected_match_index = false;
565                    picker.update_matches(picker.query(cx), cx)
566                })
567            })
568            .detach();
569        }
570    }
571
572    fn is_current_workspace(
573        &self,
574        workspace_id: WorkspaceId,
575        cx: &mut ViewContext<Picker<Self>>,
576    ) -> bool {
577        if let Some(workspace) = self.workspace.upgrade() {
578            let workspace = workspace.read(cx);
579            if Some(workspace_id) == workspace.database_id() {
580                return true;
581            }
582        }
583
584        false
585    }
586}
587struct MatchTooltip {
588    highlighted_location: HighlightedMatchWithPaths,
589}
590
591impl Render for MatchTooltip {
592    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
593        tooltip_container(cx, |div, _| {
594            self.highlighted_location.render_paths_children(div)
595        })
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use std::path::PathBuf;
602
603    use editor::Editor;
604    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
605    use project::{project_settings::ProjectSettings, Project};
606    use serde_json::json;
607    use settings::SettingsStore;
608    use workspace::{open_paths, AppState};
609
610    use super::*;
611
612    #[gpui::test]
613    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
614        let app_state = init_test(cx);
615
616        cx.update(|cx| {
617            SettingsStore::update_global(cx, |store, cx| {
618                store.update_user_settings::<ProjectSettings>(cx, |settings| {
619                    settings.session.restore_unsaved_buffers = false
620                });
621            });
622        });
623
624        app_state
625            .fs
626            .as_fake()
627            .insert_tree(
628                "/dir",
629                json!({
630                    "main.ts": "a"
631                }),
632            )
633            .await;
634        cx.update(|cx| {
635            open_paths(
636                &[PathBuf::from("/dir/main.ts")],
637                app_state,
638                workspace::OpenOptions::default(),
639                cx,
640            )
641        })
642        .await
643        .unwrap();
644        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
645
646        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
647        workspace
648            .update(cx, |workspace, _| assert!(!workspace.is_edited()))
649            .unwrap();
650
651        let editor = workspace
652            .read_with(cx, |workspace, cx| {
653                workspace
654                    .active_item(cx)
655                    .unwrap()
656                    .downcast::<Editor>()
657                    .unwrap()
658            })
659            .unwrap();
660        workspace
661            .update(cx, |_, cx| {
662                editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
663            })
664            .unwrap();
665        workspace
666            .update(cx, |workspace, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
667            .unwrap();
668
669        let recent_projects_picker = open_recent_projects(&workspace, cx);
670        workspace
671            .update(cx, |_, cx| {
672                recent_projects_picker.update(cx, |picker, cx| {
673                    assert_eq!(picker.query(cx), "");
674                    let delegate = &mut picker.delegate;
675                    delegate.matches = vec![StringMatch {
676                        candidate_id: 0,
677                        score: 1.0,
678                        positions: Vec::new(),
679                        string: "fake candidate".to_string(),
680                    }];
681                    delegate.set_workspaces(vec![(
682                        WorkspaceId::default(),
683                        SerializedWorkspaceLocation::from_local_paths(vec!["/test/path/"]),
684                    )]);
685                });
686            })
687            .unwrap();
688
689        assert!(
690            !cx.has_pending_prompt(),
691            "Should have no pending prompt on dirty project before opening the new recent project"
692        );
693        cx.dispatch_action(*workspace, menu::Confirm);
694        workspace
695            .update(cx, |workspace, cx| {
696                assert!(
697                    workspace.active_modal::<RecentProjects>(cx).is_none(),
698                    "Should remove the modal after selecting new recent project"
699                )
700            })
701            .unwrap();
702        assert!(
703            cx.has_pending_prompt(),
704            "Dirty workspace should prompt before opening the new recent project"
705        );
706        // Cancel
707        cx.simulate_prompt_answer(0);
708        assert!(
709            !cx.has_pending_prompt(),
710            "Should have no pending prompt after cancelling"
711        );
712        workspace
713            .update(cx, |workspace, _| {
714                assert!(
715                    workspace.is_edited(),
716                    "Should be in the same dirty project after cancelling"
717                )
718            })
719            .unwrap();
720    }
721
722    fn open_recent_projects(
723        workspace: &WindowHandle<Workspace>,
724        cx: &mut TestAppContext,
725    ) -> View<Picker<RecentProjectsDelegate>> {
726        cx.dispatch_action(
727            (*workspace).into(),
728            OpenRecent {
729                create_new_window: false,
730            },
731        );
732        workspace
733            .update(cx, |workspace, cx| {
734                workspace
735                    .active_modal::<RecentProjects>(cx)
736                    .unwrap()
737                    .read(cx)
738                    .picker
739                    .clone()
740            })
741            .unwrap()
742    }
743
744    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
745        cx.update(|cx| {
746            let state = AppState::test(cx);
747            language::init(cx);
748            crate::init(cx);
749            editor::init(cx);
750            workspace::init_settings(cx);
751            Project::init_settings(cx);
752            state
753        })
754    }
755}