recent_projects.rs

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