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