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