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