recent_projects.rs

  1pub mod disconnected_overlay;
  2mod remote_connections;
  3mod remote_servers;
  4mod ssh_config;
  5
  6#[cfg(target_os = "windows")]
  7mod wsl_picker;
  8
  9use remote::RemoteConnectionOptions;
 10pub use remote_connections::open_remote_project;
 11
 12use disconnected_overlay::DisconnectedOverlay;
 13use fuzzy::{StringMatch, StringMatchCandidate};
 14use gpui::{
 15    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
 16    Subscription, Task, WeakEntity, Window,
 17};
 18use ordered_float::OrderedFloat;
 19use picker::{
 20    Picker, PickerDelegate,
 21    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
 22};
 23pub use remote_connections::SshSettings;
 24pub use remote_servers::RemoteServerProjects;
 25use settings::Settings;
 26use std::{path::Path, sync::Arc};
 27use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 28use util::{ResultExt, paths::PathExt};
 29use workspace::{
 30    CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation,
 31    WORKSPACE_DB, Workspace, WorkspaceId, with_active_or_new_workspace,
 32};
 33use zed_actions::{OpenRecent, OpenRemote};
 34
 35pub fn init(cx: &mut App) {
 36    SshSettings::register(cx);
 37
 38    #[cfg(target_os = "windows")]
 39    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
 40        let create_new_window = open_wsl.create_new_window;
 41        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 42            use gpui::PathPromptOptions;
 43            use project::DirectoryLister;
 44
 45            let paths = workspace.prompt_for_open_path(
 46                PathPromptOptions {
 47                    files: true,
 48                    directories: true,
 49                    multiple: false,
 50                    prompt: None,
 51                },
 52                DirectoryLister::Local(
 53                    workspace.project().clone(),
 54                    workspace.app_state().fs.clone(),
 55                ),
 56                window,
 57                cx,
 58            );
 59
 60            cx.spawn_in(window, async move |workspace, cx| {
 61                use util::paths::SanitizedPath;
 62
 63                let Some(paths) = paths.await.log_err().flatten() else {
 64                    return;
 65                };
 66
 67                let paths = paths
 68                    .into_iter()
 69                    .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
 70                    .collect::<Vec<_>>();
 71
 72                if paths.is_empty() {
 73                    let message = indoc::indoc! { r#"
 74                        Invalid path specified when trying to open a folder inside WSL.
 75
 76                        Please note that Zed currently does not support opening network share folders inside wsl.
 77                    "#};
 78
 79                    let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
 80                    return;
 81                }
 82
 83                workspace.update_in(cx, |workspace, window, cx| {
 84                    workspace.toggle_modal(window, cx, |window, cx| {
 85                        crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
 86                    });
 87                }).log_err();
 88            })
 89            .detach();
 90        });
 91    });
 92
 93    #[cfg(target_os = "windows")]
 94    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
 95        let create_new_window = open_wsl.create_new_window;
 96        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 97            let handle = cx.entity().downgrade();
 98            let fs = workspace.project().read(cx).fs().clone();
 99            workspace.toggle_modal(window, cx, |window, cx| {
100                RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
101            });
102        });
103    });
104
105    #[cfg(target_os = "windows")]
106    cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
107        let open_wsl = open_wsl.clone();
108        with_active_or_new_workspace(cx, move |workspace, window, cx| {
109            let fs = workspace.project().read(cx).fs().clone();
110            add_wsl_distro(fs, &open_wsl.distro, cx);
111            let open_options = OpenOptions {
112                replace_window: window.window_handle().downcast::<Workspace>(),
113                ..Default::default()
114            };
115
116            let app_state = workspace.app_state().clone();
117
118            cx.spawn_in(window, async move |_, cx| {
119                open_remote_project(
120                    RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
121                    open_wsl.paths,
122                    app_state,
123                    open_options,
124                    cx,
125                )
126                .await
127            })
128            .detach();
129        });
130    });
131
132    cx.on_action(|open_recent: &OpenRecent, cx| {
133        let create_new_window = open_recent.create_new_window;
134        with_active_or_new_workspace(cx, move |workspace, window, cx| {
135            let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
136                RecentProjects::open(workspace, create_new_window, window, cx);
137                return;
138            };
139
140            recent_projects.update(cx, |recent_projects, cx| {
141                recent_projects
142                    .picker
143                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
144            });
145        });
146    });
147    cx.on_action(|open_remote: &OpenRemote, cx| {
148        let from_existing_connection = open_remote.from_existing_connection;
149        let create_new_window = open_remote.create_new_window;
150        with_active_or_new_workspace(cx, move |workspace, window, cx| {
151            if from_existing_connection {
152                cx.propagate();
153                return;
154            }
155            let handle = cx.entity().downgrade();
156            let fs = workspace.project().read(cx).fs().clone();
157            workspace.toggle_modal(window, cx, |window, cx| {
158                RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
159            })
160        });
161    });
162
163    cx.observe_new(DisconnectedOverlay::register).detach();
164}
165
166#[cfg(target_os = "windows")]
167pub fn add_wsl_distro(
168    fs: Arc<dyn project::Fs>,
169    connection_options: &remote::WslConnectionOptions,
170    cx: &App,
171) {
172    use gpui::ReadGlobal;
173    use settings::SettingsStore;
174
175    let distro_name = SharedString::from(&connection_options.distro_name);
176    let user = connection_options.user.clone();
177    SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
178        let connections = setting
179            .remote
180            .wsl_connections
181            .get_or_insert(Default::default());
182
183        if !connections
184            .iter()
185            .any(|conn| conn.distro_name == distro_name && conn.user == user)
186        {
187            use std::collections::BTreeSet;
188
189            connections.push(settings::WslConnection {
190                distro_name,
191                user,
192                projects: BTreeSet::new(),
193            })
194        }
195    });
196}
197
198pub struct RecentProjects {
199    pub picker: Entity<Picker<RecentProjectsDelegate>>,
200    rem_width: f32,
201    _subscription: Subscription,
202}
203
204impl ModalView for RecentProjects {}
205
206impl RecentProjects {
207    fn new(
208        delegate: RecentProjectsDelegate,
209        rem_width: f32,
210        window: &mut Window,
211        cx: &mut Context<Self>,
212    ) -> Self {
213        let picker = cx.new(|cx| {
214            // We want to use a list when we render paths, because the items can have different heights (multiple paths).
215            if delegate.render_paths {
216                Picker::list(delegate, window, cx)
217            } else {
218                Picker::uniform_list(delegate, window, cx)
219            }
220        });
221        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
222        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
223        // out workspace locations once the future runs to completion.
224        cx.spawn_in(window, async move |this, cx| {
225            let workspaces = WORKSPACE_DB
226                .recent_workspaces_on_disk()
227                .await
228                .log_err()
229                .unwrap_or_default();
230            this.update_in(cx, move |this, window, cx| {
231                this.picker.update(cx, move |picker, cx| {
232                    picker.delegate.set_workspaces(workspaces);
233                    picker.update_matches(picker.query(cx), window, cx)
234                })
235            })
236            .ok()
237        })
238        .detach();
239        Self {
240            picker,
241            rem_width,
242            _subscription,
243        }
244    }
245
246    pub fn open(
247        workspace: &mut Workspace,
248        create_new_window: bool,
249        window: &mut Window,
250        cx: &mut Context<Workspace>,
251    ) {
252        let weak = cx.entity().downgrade();
253        workspace.toggle_modal(window, cx, |window, cx| {
254            let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
255
256            Self::new(delegate, 34., window, cx)
257        })
258    }
259}
260
261impl EventEmitter<DismissEvent> for RecentProjects {}
262
263impl Focusable for RecentProjects {
264    fn focus_handle(&self, cx: &App) -> FocusHandle {
265        self.picker.focus_handle(cx)
266    }
267}
268
269impl Render for RecentProjects {
270    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
271        v_flex()
272            .key_context("RecentProjects")
273            .w(rems(self.rem_width))
274            .child(self.picker.clone())
275            .on_mouse_down_out(cx.listener(|this, _, window, cx| {
276                this.picker.update(cx, |this, cx| {
277                    this.cancel(&Default::default(), window, cx);
278                })
279            }))
280    }
281}
282
283pub struct RecentProjectsDelegate {
284    workspace: WeakEntity<Workspace>,
285    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
286    selected_match_index: usize,
287    matches: Vec<StringMatch>,
288    render_paths: bool,
289    create_new_window: bool,
290    // Flag to reset index when there is a new query vs not reset index when user delete an item
291    reset_selected_match_index: bool,
292    has_any_non_local_projects: bool,
293}
294
295impl RecentProjectsDelegate {
296    fn new(workspace: WeakEntity<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
297        Self {
298            workspace,
299            workspaces: Vec::new(),
300            selected_match_index: 0,
301            matches: Default::default(),
302            create_new_window,
303            render_paths,
304            reset_selected_match_index: true,
305            has_any_non_local_projects: false,
306        }
307    }
308
309    pub fn set_workspaces(
310        &mut self,
311        workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
312    ) {
313        self.workspaces = workspaces;
314        self.has_any_non_local_projects = !self
315            .workspaces
316            .iter()
317            .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
318    }
319}
320impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
321impl PickerDelegate for RecentProjectsDelegate {
322    type ListItem = ListItem;
323
324    fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
325        let (create_window, reuse_window) = if self.create_new_window {
326            (
327                window.keystroke_text_for(&menu::Confirm),
328                window.keystroke_text_for(&menu::SecondaryConfirm),
329            )
330        } else {
331            (
332                window.keystroke_text_for(&menu::SecondaryConfirm),
333                window.keystroke_text_for(&menu::Confirm),
334            )
335        };
336        Arc::from(format!(
337            "{reuse_window} reuses this window, {create_window} opens a new one",
338        ))
339    }
340
341    fn match_count(&self) -> usize {
342        self.matches.len()
343    }
344
345    fn selected_index(&self) -> usize {
346        self.selected_match_index
347    }
348
349    fn set_selected_index(
350        &mut self,
351        ix: usize,
352        _window: &mut Window,
353        _cx: &mut Context<Picker<Self>>,
354    ) {
355        self.selected_match_index = ix;
356    }
357
358    fn update_matches(
359        &mut self,
360        query: String,
361        _: &mut Window,
362        cx: &mut Context<Picker<Self>>,
363    ) -> gpui::Task<()> {
364        let query = query.trim_start();
365        let smart_case = query.chars().any(|c| c.is_uppercase());
366        let candidates = self
367            .workspaces
368            .iter()
369            .enumerate()
370            .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx))
371            .map(|(id, (_, _, paths))| {
372                let combined_string = paths
373                    .ordered_paths()
374                    .map(|path| path.compact().to_string_lossy().into_owned())
375                    .collect::<Vec<_>>()
376                    .join("");
377                StringMatchCandidate::new(id, &combined_string)
378            })
379            .collect::<Vec<_>>();
380        self.matches = smol::block_on(fuzzy::match_strings(
381            candidates.as_slice(),
382            query,
383            smart_case,
384            true,
385            100,
386            &Default::default(),
387            cx.background_executor().clone(),
388        ));
389        self.matches.sort_unstable_by(|a, b| {
390            b.score
391                .partial_cmp(&a.score) // Descending score
392                .unwrap_or(std::cmp::Ordering::Equal)
393                .then_with(|| a.candidate_id.cmp(&b.candidate_id)) // Ascending candidate_id for ties
394        });
395
396        if self.reset_selected_match_index {
397            self.selected_match_index = self
398                .matches
399                .iter()
400                .enumerate()
401                .rev()
402                .max_by_key(|(_, m)| OrderedFloat(m.score))
403                .map(|(ix, _)| ix)
404                .unwrap_or(0);
405        }
406        self.reset_selected_match_index = true;
407        Task::ready(())
408    }
409
410    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
411        if let Some((selected_match, workspace)) = self
412            .matches
413            .get(self.selected_index())
414            .zip(self.workspace.upgrade())
415        {
416            let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) =
417                &self.workspaces[selected_match.candidate_id];
418            let replace_current_window = if self.create_new_window {
419                secondary
420            } else {
421                !secondary
422            };
423            workspace
424                .update(cx, |workspace, cx| {
425                    if workspace.database_id() == Some(*candidate_workspace_id) {
426                        Task::ready(Ok(()))
427                    } else {
428                        match candidate_workspace_location.clone() {
429                            SerializedWorkspaceLocation::Local => {
430                                let paths = candidate_workspace_paths.paths().to_vec();
431                                if replace_current_window {
432                                    cx.spawn_in(window, async move |workspace, cx| {
433                                        let continue_replacing = workspace
434                                            .update_in(cx, |workspace, window, cx| {
435                                                workspace.prepare_to_close(
436                                                    CloseIntent::ReplaceWindow,
437                                                    window,
438                                                    cx,
439                                                )
440                                            })?
441                                            .await?;
442                                        if continue_replacing {
443                                            workspace
444                                                .update_in(cx, |workspace, window, cx| {
445                                                    workspace.open_workspace_for_paths(
446                                                        true, paths, window, cx,
447                                                    )
448                                                })?
449                                                .await
450                                        } else {
451                                            Ok(())
452                                        }
453                                    })
454                                } else {
455                                    workspace.open_workspace_for_paths(false, paths, window, cx)
456                                }
457                            }
458                            SerializedWorkspaceLocation::Remote(mut connection) => {
459                                let app_state = workspace.app_state().clone();
460
461                                let replace_window = if replace_current_window {
462                                    window.window_handle().downcast::<Workspace>()
463                                } else {
464                                    None
465                                };
466
467                                let open_options = OpenOptions {
468                                    replace_window,
469                                    ..Default::default()
470                                };
471
472                                if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
473                                    SshSettings::get_global(cx)
474                                        .fill_connection_options_from_settings(connection);
475                                };
476
477                                let paths = candidate_workspace_paths.paths().to_vec();
478
479                                cx.spawn_in(window, async move |_, cx| {
480                                    open_remote_project(
481                                        connection.clone(),
482                                        paths,
483                                        app_state,
484                                        open_options,
485                                        cx,
486                                    )
487                                    .await
488                                })
489                            }
490                        }
491                    }
492                })
493                .detach_and_log_err(cx);
494            cx.emit(DismissEvent);
495        }
496    }
497
498    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
499
500    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
501        let text = if self.workspaces.is_empty() {
502            "Recently opened projects will show up here".into()
503        } else {
504            "No matches".into()
505        };
506        Some(text)
507    }
508
509    fn render_match(
510        &self,
511        ix: usize,
512        selected: bool,
513        window: &mut Window,
514        cx: &mut Context<Picker<Self>>,
515    ) -> Option<Self::ListItem> {
516        let hit = self.matches.get(ix)?;
517
518        let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
519
520        let mut path_start_offset = 0;
521
522        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
523            .ordered_paths()
524            .map(|p| p.compact())
525            .map(|path| {
526                let highlighted_text =
527                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
528                path_start_offset += highlighted_text.1.text.len();
529                highlighted_text
530            })
531            .unzip();
532
533        let prefix = match &location {
534            SerializedWorkspaceLocation::Remote(RemoteConnectionOptions::Wsl(wsl)) => {
535                Some(SharedString::from(&wsl.distro_name))
536            }
537            _ => None,
538        };
539
540        let highlighted_match = HighlightedMatchWithPaths {
541            prefix,
542            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
543            paths,
544        };
545
546        Some(
547            ListItem::new(ix)
548                .toggle_state(selected)
549                .inset(true)
550                .spacing(ListItemSpacing::Sparse)
551                .child(
552                    h_flex()
553                        .flex_grow()
554                        .gap_3()
555                        .when(self.has_any_non_local_projects, |this| {
556                            this.child(match location {
557                                SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen)
558                                    .color(Color::Muted)
559                                    .into_any_element(),
560                                SerializedWorkspaceLocation::Remote(options) => {
561                                    Icon::new(match options {
562                                        RemoteConnectionOptions::Ssh { .. } => IconName::Server,
563                                        RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
564                                    })
565                                    .color(Color::Muted)
566                                    .into_any_element()
567                                }
568                            })
569                        })
570                        .child({
571                            let mut highlighted = highlighted_match.clone();
572                            if !self.render_paths {
573                                highlighted.paths.clear();
574                            }
575                            highlighted.render(window, cx)
576                        }),
577                )
578                .map(|el| {
579                    let delete_button = div()
580                        .child(
581                            IconButton::new("delete", IconName::Close)
582                                .icon_size(IconSize::Small)
583                                .on_click(cx.listener(move |this, _event, window, cx| {
584                                    cx.stop_propagation();
585                                    window.prevent_default();
586
587                                    this.delegate.delete_recent_project(ix, window, cx)
588                                }))
589                                .tooltip(Tooltip::text("Delete from Recent Projects...")),
590                        )
591                        .into_any_element();
592
593                    if self.selected_index() == ix {
594                        el.end_slot::<AnyElement>(delete_button)
595                    } else {
596                        el.end_hover_slot::<AnyElement>(delete_button)
597                    }
598                })
599                .tooltip(move |_, cx| {
600                    let tooltip_highlighted_location = highlighted_match.clone();
601                    cx.new(|_| MatchTooltip {
602                        highlighted_location: tooltip_highlighted_location,
603                    })
604                    .into()
605                }),
606        )
607    }
608
609    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
610        Some(
611            h_flex()
612                .w_full()
613                .p_2()
614                .gap_2()
615                .justify_end()
616                .border_t_1()
617                .border_color(cx.theme().colors().border_variant)
618                .child(
619                    Button::new("remote", "Open Remote Folder")
620                        .key_binding(KeyBinding::for_action(
621                            &OpenRemote {
622                                from_existing_connection: false,
623                                create_new_window: false,
624                            },
625                            cx,
626                        ))
627                        .on_click(|_, window, cx| {
628                            window.dispatch_action(
629                                OpenRemote {
630                                    from_existing_connection: false,
631                                    create_new_window: false,
632                                }
633                                .boxed_clone(),
634                                cx,
635                            )
636                        }),
637                )
638                .child(
639                    Button::new("local", "Open Local Folder")
640                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
641                        .on_click(|_, window, cx| {
642                            window.dispatch_action(workspace::Open.boxed_clone(), cx)
643                        }),
644                )
645                .into_any(),
646        )
647    }
648}
649
650// Compute the highlighted text for the name and path
651fn highlights_for_path(
652    path: &Path,
653    match_positions: &Vec<usize>,
654    path_start_offset: usize,
655) -> (Option<HighlightedMatch>, HighlightedMatch) {
656    let path_string = path.to_string_lossy();
657    let path_text = path_string.to_string();
658    let path_byte_len = path_text.len();
659    // Get the subset of match highlight positions that line up with the given path.
660    // Also adjusts them to start at the path start
661    let path_positions = match_positions
662        .iter()
663        .copied()
664        .skip_while(|position| *position < path_start_offset)
665        .take_while(|position| *position < path_start_offset + path_byte_len)
666        .map(|position| position - path_start_offset)
667        .collect::<Vec<_>>();
668
669    // Again subset the highlight positions to just those that line up with the file_name
670    // again adjusted to the start of the file_name
671    let file_name_text_and_positions = path.file_name().map(|file_name| {
672        let file_name_text = file_name.to_string_lossy().into_owned();
673        let file_name_start_byte = path_byte_len - file_name_text.len();
674        let highlight_positions = path_positions
675            .iter()
676            .copied()
677            .skip_while(|position| *position < file_name_start_byte)
678            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
679            .map(|position| position - file_name_start_byte)
680            .collect::<Vec<_>>();
681        HighlightedMatch {
682            text: file_name_text,
683            highlight_positions,
684            color: Color::Default,
685        }
686    });
687
688    (
689        file_name_text_and_positions,
690        HighlightedMatch {
691            text: path_text,
692            highlight_positions: path_positions,
693            color: Color::Default,
694        },
695    )
696}
697impl RecentProjectsDelegate {
698    fn delete_recent_project(
699        &self,
700        ix: usize,
701        window: &mut Window,
702        cx: &mut Context<Picker<Self>>,
703    ) {
704        if let Some(selected_match) = self.matches.get(ix) {
705            let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
706            cx.spawn_in(window, async move |this, cx| {
707                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
708                let workspaces = WORKSPACE_DB
709                    .recent_workspaces_on_disk()
710                    .await
711                    .unwrap_or_default();
712                this.update_in(cx, move |picker, window, cx| {
713                    picker.delegate.set_workspaces(workspaces);
714                    picker
715                        .delegate
716                        .set_selected_index(ix.saturating_sub(1), window, cx);
717                    picker.delegate.reset_selected_match_index = false;
718                    picker.update_matches(picker.query(cx), window, cx);
719                    // After deleting a project, we want to update the history manager to reflect the change.
720                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
721                    if let Some(history_manager) = HistoryManager::global(cx) {
722                        history_manager
723                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
724                    }
725                })
726            })
727            .detach();
728        }
729    }
730
731    fn is_current_workspace(
732        &self,
733        workspace_id: WorkspaceId,
734        cx: &mut Context<Picker<Self>>,
735    ) -> bool {
736        if let Some(workspace) = self.workspace.upgrade() {
737            let workspace = workspace.read(cx);
738            if Some(workspace_id) == workspace.database_id() {
739                return true;
740            }
741        }
742
743        false
744    }
745}
746struct MatchTooltip {
747    highlighted_location: HighlightedMatchWithPaths,
748}
749
750impl Render for MatchTooltip {
751    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
752        tooltip_container(cx, |div, _| {
753            self.highlighted_location.render_paths_children(div)
754        })
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use std::path::PathBuf;
761
762    use dap::debugger_settings::DebuggerSettings;
763    use editor::Editor;
764    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
765    use project::Project;
766    use serde_json::json;
767    use settings::SettingsStore;
768    use util::path;
769    use workspace::{AppState, open_paths};
770
771    use super::*;
772
773    #[gpui::test]
774    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
775        let app_state = init_test(cx);
776
777        cx.update(|cx| {
778            SettingsStore::update_global(cx, |store, cx| {
779                store.update_user_settings(cx, |settings| {
780                    settings
781                        .session
782                        .get_or_insert_default()
783                        .restore_unsaved_buffers = Some(false)
784                });
785            });
786        });
787
788        app_state
789            .fs
790            .as_fake()
791            .insert_tree(
792                path!("/dir"),
793                json!({
794                    "main.ts": "a"
795                }),
796            )
797            .await;
798        cx.update(|cx| {
799            open_paths(
800                &[PathBuf::from(path!("/dir/main.ts"))],
801                app_state,
802                workspace::OpenOptions::default(),
803                cx,
804            )
805        })
806        .await
807        .unwrap();
808        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
809
810        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
811        workspace
812            .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
813            .unwrap();
814
815        let editor = workspace
816            .read_with(cx, |workspace, cx| {
817                workspace
818                    .active_item(cx)
819                    .unwrap()
820                    .downcast::<Editor>()
821                    .unwrap()
822            })
823            .unwrap();
824        workspace
825            .update(cx, |_, window, cx| {
826                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
827            })
828            .unwrap();
829        workspace
830            .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
831            .unwrap();
832
833        let recent_projects_picker = open_recent_projects(&workspace, cx);
834        workspace
835            .update(cx, |_, _, cx| {
836                recent_projects_picker.update(cx, |picker, cx| {
837                    assert_eq!(picker.query(cx), "");
838                    let delegate = &mut picker.delegate;
839                    delegate.matches = vec![StringMatch {
840                        candidate_id: 0,
841                        score: 1.0,
842                        positions: Vec::new(),
843                        string: "fake candidate".to_string(),
844                    }];
845                    delegate.set_workspaces(vec![(
846                        WorkspaceId::default(),
847                        SerializedWorkspaceLocation::Local,
848                        PathList::new(&[path!("/test/path")]),
849                    )]);
850                });
851            })
852            .unwrap();
853
854        assert!(
855            !cx.has_pending_prompt(),
856            "Should have no pending prompt on dirty project before opening the new recent project"
857        );
858        cx.dispatch_action(*workspace, menu::Confirm);
859        workspace
860            .update(cx, |workspace, _, cx| {
861                assert!(
862                    workspace.active_modal::<RecentProjects>(cx).is_none(),
863                    "Should remove the modal after selecting new recent project"
864                )
865            })
866            .unwrap();
867        assert!(
868            cx.has_pending_prompt(),
869            "Dirty workspace should prompt before opening the new recent project"
870        );
871        cx.simulate_prompt_answer("Cancel");
872        assert!(
873            !cx.has_pending_prompt(),
874            "Should have no pending prompt after cancelling"
875        );
876        workspace
877            .update(cx, |workspace, _, _| {
878                assert!(
879                    workspace.is_edited(),
880                    "Should be in the same dirty project after cancelling"
881                )
882            })
883            .unwrap();
884    }
885
886    fn open_recent_projects(
887        workspace: &WindowHandle<Workspace>,
888        cx: &mut TestAppContext,
889    ) -> Entity<Picker<RecentProjectsDelegate>> {
890        cx.dispatch_action(
891            (*workspace).into(),
892            OpenRecent {
893                create_new_window: false,
894            },
895        );
896        workspace
897            .update(cx, |workspace, _, cx| {
898                workspace
899                    .active_modal::<RecentProjects>(cx)
900                    .unwrap()
901                    .read(cx)
902                    .picker
903                    .clone()
904            })
905            .unwrap()
906    }
907
908    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
909        cx.update(|cx| {
910            let state = AppState::test(cx);
911            language::init(cx);
912            crate::init(cx);
913            editor::init(cx);
914            workspace::init_settings(cx);
915            DebuggerSettings::register(cx);
916            Project::init_settings(cx);
917            state
918        })
919    }
920}