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