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