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