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