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    Picker, PickerDelegate,
 15    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
 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::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 25use util::{ResultExt, paths::PathExt};
 26use workspace::{
 27    CloseIntent, HistoryManager, ModalView, OpenOptions, SerializedWorkspaceLocation, WORKSPACE_DB,
 28    Workspace, WorkspaceId,
 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, async move |this, cx| {
 66            let workspaces = WORKSPACE_DB
 67                .recent_workspaces_on_disk()
 68                .await
 69                .log_err()
 70                .unwrap_or_default();
 71            this.update_in(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, async move |workspace, cx| {
285                                        let continue_replacing = workspace
286                                            .update_in(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(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, async move |_, cx| {
334                                    open_ssh_project(
335                                        connection_options,
336                                        paths,
337                                        app_state,
338                                        open_options,
339                                        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) -> Option<SharedString> {
355        let text = if self.workspaces.is_empty() {
356            "Recently opened projects will show up here".into()
357        } else {
358            "No matches".into()
359        };
360        Some(text)
361    }
362
363    fn render_match(
364        &self,
365        ix: usize,
366        selected: bool,
367        window: &mut Window,
368        cx: &mut Context<Picker<Self>>,
369    ) -> Option<Self::ListItem> {
370        let hit = self.matches.get(ix)?;
371
372        let (_, location) = self.workspaces.get(hit.candidate_id)?;
373
374        let mut path_start_offset = 0;
375
376        let (match_labels, paths): (Vec<_>, Vec<_>) = location
377            .sorted_paths()
378            .iter()
379            .map(|p| p.compact())
380            .map(|path| {
381                let highlighted_text =
382                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
383
384                path_start_offset += highlighted_text.1.char_count;
385                highlighted_text
386            })
387            .unzip();
388
389        let highlighted_match = HighlightedMatchWithPaths {
390            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
391            paths,
392        };
393
394        Some(
395            ListItem::new(ix)
396                .toggle_state(selected)
397                .inset(true)
398                .spacing(ListItemSpacing::Sparse)
399                .child(
400                    h_flex()
401                        .flex_grow()
402                        .gap_3()
403                        .when(self.has_any_non_local_projects, |this| {
404                            this.child(match location {
405                                SerializedWorkspaceLocation::Local(_, _) => {
406                                    Icon::new(IconName::Screen)
407                                        .color(Color::Muted)
408                                        .into_any_element()
409                                }
410                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
411                                    .color(Color::Muted)
412                                    .into_any_element(),
413                            })
414                        })
415                        .child({
416                            let mut highlighted = highlighted_match.clone();
417                            if !self.render_paths {
418                                highlighted.paths.clear();
419                            }
420                            highlighted.render(window, cx)
421                        }),
422                )
423                .map(|el| {
424                    let delete_button = div()
425                        .child(
426                            IconButton::new("delete", IconName::Close)
427                                .icon_size(IconSize::Small)
428                                .on_click(cx.listener(move |this, _event, window, cx| {
429                                    cx.stop_propagation();
430                                    window.prevent_default();
431
432                                    this.delegate.delete_recent_project(ix, window, cx)
433                                }))
434                                .tooltip(Tooltip::text("Delete from Recent Projects...")),
435                        )
436                        .into_any_element();
437
438                    if self.selected_index() == ix {
439                        el.end_slot::<AnyElement>(delete_button)
440                    } else {
441                        el.end_hover_slot::<AnyElement>(delete_button)
442                    }
443                })
444                .tooltip(move |_, cx| {
445                    let tooltip_highlighted_location = highlighted_match.clone();
446                    cx.new(|_| MatchTooltip {
447                        highlighted_location: tooltip_highlighted_location,
448                    })
449                    .into()
450                }),
451        )
452    }
453
454    fn render_footer(
455        &self,
456        window: &mut Window,
457        cx: &mut Context<Picker<Self>>,
458    ) -> Option<AnyElement> {
459        Some(
460            h_flex()
461                .w_full()
462                .p_2()
463                .gap_2()
464                .justify_end()
465                .border_t_1()
466                .border_color(cx.theme().colors().border_variant)
467                .child(
468                    Button::new("remote", "Open Remote Folder")
469                        .key_binding(KeyBinding::for_action(&OpenRemote, window, cx))
470                        .on_click(|_, window, cx| {
471                            window.dispatch_action(OpenRemote.boxed_clone(), cx)
472                        }),
473                )
474                .child(
475                    Button::new("local", "Open Local Folder")
476                        .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
477                        .on_click(|_, window, cx| {
478                            window.dispatch_action(workspace::Open.boxed_clone(), cx)
479                        }),
480                )
481                .into_any(),
482        )
483    }
484}
485
486// Compute the highlighted text for the name and path
487fn highlights_for_path(
488    path: &Path,
489    match_positions: &Vec<usize>,
490    path_start_offset: usize,
491) -> (Option<HighlightedMatch>, HighlightedMatch) {
492    let path_string = path.to_string_lossy();
493    let path_char_count = path_string.chars().count();
494    // Get the subset of match highlight positions that line up with the given path.
495    // Also adjusts them to start at the path start
496    let path_positions = match_positions
497        .iter()
498        .copied()
499        .skip_while(|position| *position < path_start_offset)
500        .take_while(|position| *position < path_start_offset + path_char_count)
501        .map(|position| position - path_start_offset)
502        .collect::<Vec<_>>();
503
504    // Again subset the highlight positions to just those that line up with the file_name
505    // again adjusted to the start of the file_name
506    let file_name_text_and_positions = path.file_name().map(|file_name| {
507        let text = file_name.to_string_lossy();
508        let char_count = text.chars().count();
509        let file_name_start = path_char_count - char_count;
510        let highlight_positions = path_positions
511            .iter()
512            .copied()
513            .skip_while(|position| *position < file_name_start)
514            .take_while(|position| *position < file_name_start + char_count)
515            .map(|position| position - file_name_start)
516            .collect::<Vec<_>>();
517        HighlightedMatch {
518            text: text.to_string(),
519            highlight_positions,
520            char_count,
521            color: Color::Default,
522        }
523    });
524
525    (
526        file_name_text_and_positions,
527        HighlightedMatch {
528            text: path_string.to_string(),
529            highlight_positions: path_positions,
530            char_count: path_char_count,
531            color: Color::Default,
532        },
533    )
534}
535impl RecentProjectsDelegate {
536    fn delete_recent_project(
537        &self,
538        ix: usize,
539        window: &mut Window,
540        cx: &mut Context<Picker<Self>>,
541    ) {
542        if let Some(selected_match) = self.matches.get(ix) {
543            let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
544            cx.spawn_in(window, async move |this, cx| {
545                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
546                let workspaces = WORKSPACE_DB
547                    .recent_workspaces_on_disk()
548                    .await
549                    .unwrap_or_default();
550                this.update_in(cx, move |picker, window, cx| {
551                    picker.delegate.set_workspaces(workspaces);
552                    picker
553                        .delegate
554                        .set_selected_index(ix.saturating_sub(1), window, cx);
555                    picker.delegate.reset_selected_match_index = false;
556                    picker.update_matches(picker.query(cx), window, cx);
557                    // After deleting a project, we want to update the history manager to reflect the change.
558                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
559                    if let Some(history_manager) = HistoryManager::global(cx) {
560                        history_manager
561                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
562                    }
563                })
564            })
565            .detach();
566        }
567    }
568
569    fn is_current_workspace(
570        &self,
571        workspace_id: WorkspaceId,
572        cx: &mut Context<Picker<Self>>,
573    ) -> bool {
574        if let Some(workspace) = self.workspace.upgrade() {
575            let workspace = workspace.read(cx);
576            if Some(workspace_id) == workspace.database_id() {
577                return true;
578            }
579        }
580
581        false
582    }
583}
584struct MatchTooltip {
585    highlighted_location: HighlightedMatchWithPaths,
586}
587
588impl Render for MatchTooltip {
589    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
590        tooltip_container(window, cx, |div, _, _| {
591            self.highlighted_location.render_paths_children(div)
592        })
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use std::path::PathBuf;
599
600    use dap::debugger_settings::DebuggerSettings;
601    use editor::Editor;
602    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
603    use project::{Project, project_settings::ProjectSettings};
604    use serde_json::json;
605    use settings::SettingsStore;
606    use util::path;
607    use workspace::{AppState, open_paths};
608
609    use super::*;
610
611    #[gpui::test]
612    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
613        let app_state = init_test(cx);
614
615        cx.update(|cx| {
616            SettingsStore::update_global(cx, |store, cx| {
617                store.update_user_settings::<ProjectSettings>(cx, |settings| {
618                    settings.session.restore_unsaved_buffers = false
619                });
620            });
621        });
622
623        app_state
624            .fs
625            .as_fake()
626            .insert_tree(
627                path!("/dir"),
628                json!({
629                    "main.ts": "a"
630                }),
631            )
632            .await;
633        cx.update(|cx| {
634            open_paths(
635                &[PathBuf::from(path!("/dir/main.ts"))],
636                app_state,
637                workspace::OpenOptions::default(),
638                cx,
639            )
640        })
641        .await
642        .unwrap();
643        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
644
645        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
646        workspace
647            .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
648            .unwrap();
649
650        let editor = workspace
651            .read_with(cx, |workspace, cx| {
652                workspace
653                    .active_item(cx)
654                    .unwrap()
655                    .downcast::<Editor>()
656                    .unwrap()
657            })
658            .unwrap();
659        workspace
660            .update(cx, |_, window, cx| {
661                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
662            })
663            .unwrap();
664        workspace
665            .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
666            .unwrap();
667
668        let recent_projects_picker = open_recent_projects(&workspace, cx);
669        workspace
670            .update(cx, |_, _, cx| {
671                recent_projects_picker.update(cx, |picker, cx| {
672                    assert_eq!(picker.query(cx), "");
673                    let delegate = &mut picker.delegate;
674                    delegate.matches = vec![StringMatch {
675                        candidate_id: 0,
676                        score: 1.0,
677                        positions: Vec::new(),
678                        string: "fake candidate".to_string(),
679                    }];
680                    delegate.set_workspaces(vec![(
681                        WorkspaceId::default(),
682                        SerializedWorkspaceLocation::from_local_paths(vec![path!("/test/path/")]),
683                    )]);
684                });
685            })
686            .unwrap();
687
688        assert!(
689            !cx.has_pending_prompt(),
690            "Should have no pending prompt on dirty project before opening the new recent project"
691        );
692        cx.dispatch_action(*workspace, menu::Confirm);
693        workspace
694            .update(cx, |workspace, _, cx| {
695                assert!(
696                    workspace.active_modal::<RecentProjects>(cx).is_none(),
697                    "Should remove the modal after selecting new recent project"
698                )
699            })
700            .unwrap();
701        assert!(
702            cx.has_pending_prompt(),
703            "Dirty workspace should prompt before opening the new recent project"
704        );
705        cx.simulate_prompt_answer("Cancel");
706        assert!(
707            !cx.has_pending_prompt(),
708            "Should have no pending prompt after cancelling"
709        );
710        workspace
711            .update(cx, |workspace, _, _| {
712                assert!(
713                    workspace.is_edited(),
714                    "Should be in the same dirty project after cancelling"
715                )
716            })
717            .unwrap();
718    }
719
720    fn open_recent_projects(
721        workspace: &WindowHandle<Workspace>,
722        cx: &mut TestAppContext,
723    ) -> Entity<Picker<RecentProjectsDelegate>> {
724        cx.dispatch_action(
725            (*workspace).into(),
726            OpenRecent {
727                create_new_window: false,
728            },
729        );
730        workspace
731            .update(cx, |workspace, _, cx| {
732                workspace
733                    .active_modal::<RecentProjects>(cx)
734                    .unwrap()
735                    .read(cx)
736                    .picker
737                    .clone()
738            })
739            .unwrap()
740    }
741
742    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
743        cx.update(|cx| {
744            let state = AppState::test(cx);
745            language::init(cx);
746            crate::init(cx);
747            editor::init(cx);
748            workspace::init_settings(cx);
749            DebuggerSettings::register(cx);
750            Project::init_settings(cx);
751            state
752        })
753    }
754}