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