recent_projects.rs

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