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}