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