recent_projects.rs

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