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;
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, 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 connection_options = SshConnectionOptions {
388 host: ssh_project.host.clone(),
389 username: ssh_project.user.clone(),
390 port: ssh_project.port,
391 password: None,
392 };
393
394 let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
395
396 cx.spawn(|_, mut cx| async move {
397 open_ssh_project(connection_options, paths, app_state, open_options, &mut cx).await
398 })
399 }
400 }
401 }
402 })
403 .detach_and_log_err(cx);
404 cx.emit(DismissEvent);
405 }
406 }
407
408 fn dismissed(&mut self, _: &mut ViewContext<Picker<Self>>) {}
409
410 fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
411 if self.workspaces.is_empty() {
412 "Recently opened projects will show up here".into()
413 } else {
414 "No matches".into()
415 }
416 }
417
418 fn render_match(
419 &self,
420 ix: usize,
421 selected: bool,
422 cx: &mut ViewContext<Picker<Self>>,
423 ) -> Option<Self::ListItem> {
424 let hit = self.matches.get(ix)?;
425
426 let (_, location) = self.workspaces.get(hit.candidate_id)?;
427
428 let dev_server_status =
429 if let SerializedWorkspaceLocation::DevServer(dev_server_project) = location {
430 let store = dev_server_projects::Store::global(cx).read(cx);
431 Some(
432 store
433 .dev_server_project(dev_server_project.id)
434 .and_then(|p| store.dev_server(p.dev_server_id))
435 .map(|s| s.status)
436 .unwrap_or_default(),
437 )
438 } else {
439 None
440 };
441
442 let mut path_start_offset = 0;
443 let paths = match location {
444 SerializedWorkspaceLocation::Local(paths, order) => Arc::new(
445 order
446 .order()
447 .iter()
448 .filter_map(|i| paths.paths().get(*i).cloned())
449 .map(|path| path.compact())
450 .collect(),
451 ),
452 SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
453 SerializedWorkspaceLocation::DevServer(dev_server_project) => {
454 Arc::new(vec![PathBuf::from(format!(
455 "{}:{}",
456 dev_server_project.dev_server_name,
457 dev_server_project.paths.join(", ")
458 ))])
459 }
460 };
461
462 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
463 .iter()
464 .map(|path| {
465 let highlighted_text =
466 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
467
468 path_start_offset += highlighted_text.1.char_count;
469 highlighted_text
470 })
471 .unzip();
472
473 let highlighted_match = HighlightedMatchWithPaths {
474 match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", ").color(
475 if matches!(dev_server_status, Some(DevServerStatus::Offline)) {
476 Color::Disabled
477 } else {
478 Color::Default
479 },
480 ),
481 paths,
482 };
483
484 Some(
485 ListItem::new(ix)
486 .selected(selected)
487 .inset(true)
488 .spacing(ListItemSpacing::Sparse)
489 .child(
490 h_flex()
491 .flex_grow()
492 .gap_3()
493 .when(self.has_any_non_local_projects, |this| {
494 this.child(match location {
495 SerializedWorkspaceLocation::Local(_, _) => {
496 Icon::new(IconName::Screen)
497 .color(Color::Muted)
498 .into_any_element()
499 }
500 SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
501 .color(Color::Muted)
502 .into_any_element(),
503 SerializedWorkspaceLocation::DevServer(_) => {
504 let indicator_color = match dev_server_status {
505 Some(DevServerStatus::Online) => Color::Created,
506 Some(DevServerStatus::Offline) => Color::Hidden,
507 _ => unreachable!(),
508 };
509 IconWithIndicator::new(
510 Icon::new(IconName::Server).color(Color::Muted),
511 Some(Indicator::dot()),
512 )
513 .indicator_color(indicator_color)
514 .indicator_border_color(if selected {
515 Some(cx.theme().colors().element_selected)
516 } else {
517 None
518 })
519 .into_any_element()
520 }
521 })
522 })
523 .child({
524 let mut highlighted = highlighted_match.clone();
525 if !self.render_paths {
526 highlighted.paths.clear();
527 }
528 highlighted.render(cx)
529 }),
530 )
531 .map(|el| {
532 let delete_button = div()
533 .child(
534 IconButton::new("delete", IconName::Close)
535 .icon_size(IconSize::Small)
536 .on_click(cx.listener(move |this, _event, cx| {
537 cx.stop_propagation();
538 cx.prevent_default();
539
540 this.delegate.delete_recent_project(ix, cx)
541 }))
542 .tooltip(|cx| Tooltip::text("Delete from Recent Projects...", cx)),
543 )
544 .into_any_element();
545
546 if self.selected_index() == ix {
547 el.end_slot::<AnyElement>(delete_button)
548 } else {
549 el.end_hover_slot::<AnyElement>(delete_button)
550 }
551 })
552 .tooltip(move |cx| {
553 let tooltip_highlighted_location = highlighted_match.clone();
554 cx.new_view(move |_| MatchTooltip {
555 highlighted_location: tooltip_highlighted_location,
556 })
557 .into()
558 }),
559 )
560 }
561
562 fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
563 Some(
564 h_flex()
565 .border_t_1()
566 .py_2()
567 .pr_2()
568 .border_color(cx.theme().colors().border_variant)
569 .justify_end()
570 .gap_4()
571 .child(
572 ButtonLike::new("remote")
573 .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| {
574 button.child(key)
575 })
576 .child(Label::new("Open Remote Folder…").color(Color::Muted))
577 .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())),
578 )
579 .child(
580 ButtonLike::new("local")
581 .when_some(
582 KeyBinding::for_action(&workspace::Open, cx),
583 |button, key| button.child(key),
584 )
585 .child(Label::new("Open Local Folder…").color(Color::Muted))
586 .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())),
587 )
588 .into_any(),
589 )
590 }
591}
592
593fn open_dev_server_project(
594 replace_current_window: bool,
595 dev_server_project_id: DevServerProjectId,
596 project_id: ProjectId,
597 cx: &mut ViewContext<Workspace>,
598) -> Task<anyhow::Result<()>> {
599 if let Some(app_state) = AppState::global(cx).upgrade() {
600 let handle = if replace_current_window {
601 cx.window_handle().downcast::<Workspace>()
602 } else {
603 None
604 };
605
606 if let Some(handle) = handle {
607 cx.spawn(move |workspace, mut cx| async move {
608 let continue_replacing = workspace
609 .update(&mut cx, |workspace, cx| {
610 workspace.prepare_to_close(CloseIntent::ReplaceWindow, cx)
611 })?
612 .await?;
613 if continue_replacing {
614 workspace
615 .update(&mut cx, |_workspace, cx| {
616 workspace::join_dev_server_project(
617 dev_server_project_id,
618 project_id,
619 app_state,
620 Some(handle),
621 cx,
622 )
623 })?
624 .await?;
625 }
626 Ok(())
627 })
628 } else {
629 let task = workspace::join_dev_server_project(
630 dev_server_project_id,
631 project_id,
632 app_state,
633 None,
634 cx,
635 );
636 cx.spawn(|_, _| async move {
637 task.await?;
638 Ok(())
639 })
640 }
641 } else {
642 Task::ready(Err(anyhow::anyhow!("App state not found")))
643 }
644}
645
646// Compute the highlighted text for the name and path
647fn highlights_for_path(
648 path: &Path,
649 match_positions: &Vec<usize>,
650 path_start_offset: usize,
651) -> (Option<HighlightedText>, HighlightedText) {
652 let path_string = path.to_string_lossy();
653 let path_char_count = path_string.chars().count();
654 // Get the subset of match highlight positions that line up with the given path.
655 // Also adjusts them to start at the path start
656 let path_positions = match_positions
657 .iter()
658 .copied()
659 .skip_while(|position| *position < path_start_offset)
660 .take_while(|position| *position < path_start_offset + path_char_count)
661 .map(|position| position - path_start_offset)
662 .collect::<Vec<_>>();
663
664 // Again subset the highlight positions to just those that line up with the file_name
665 // again adjusted to the start of the file_name
666 let file_name_text_and_positions = path.file_name().map(|file_name| {
667 let text = file_name.to_string_lossy();
668 let char_count = text.chars().count();
669 let file_name_start = path_char_count - char_count;
670 let highlight_positions = path_positions
671 .iter()
672 .copied()
673 .skip_while(|position| *position < file_name_start)
674 .take_while(|position| *position < file_name_start + char_count)
675 .map(|position| position - file_name_start)
676 .collect::<Vec<_>>();
677 HighlightedText {
678 text: text.to_string(),
679 highlight_positions,
680 char_count,
681 color: Color::Default,
682 }
683 });
684
685 (
686 file_name_text_and_positions,
687 HighlightedText {
688 text: path_string.to_string(),
689 highlight_positions: path_positions,
690 char_count: path_char_count,
691 color: Color::Default,
692 },
693 )
694}
695impl RecentProjectsDelegate {
696 fn delete_recent_project(&self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
697 if let Some(selected_match) = self.matches.get(ix) {
698 let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
699 cx.spawn(move |this, mut cx| async move {
700 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
701 let workspaces = WORKSPACE_DB
702 .recent_workspaces_on_disk()
703 .await
704 .unwrap_or_default();
705 this.update(&mut cx, move |picker, cx| {
706 picker.delegate.set_workspaces(workspaces);
707 picker.delegate.set_selected_index(ix.saturating_sub(1), cx);
708 picker.delegate.reset_selected_match_index = false;
709 picker.update_matches(picker.query(cx), cx)
710 })
711 })
712 .detach();
713 }
714 }
715
716 fn is_current_workspace(
717 &self,
718 workspace_id: WorkspaceId,
719 cx: &mut ViewContext<Picker<Self>>,
720 ) -> bool {
721 if let Some(workspace) = self.workspace.upgrade() {
722 let workspace = workspace.read(cx);
723 if Some(workspace_id) == workspace.database_id() {
724 return true;
725 }
726 }
727
728 false
729 }
730}
731struct MatchTooltip {
732 highlighted_location: HighlightedMatchWithPaths,
733}
734
735impl Render for MatchTooltip {
736 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
737 tooltip_container(cx, |div, _| {
738 self.highlighted_location.render_paths_children(div)
739 })
740 }
741}
742
743#[cfg(test)]
744mod tests {
745 use std::path::PathBuf;
746
747 use editor::Editor;
748 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
749 use project::{project_settings::ProjectSettings, Project};
750 use serde_json::json;
751 use settings::SettingsStore;
752 use workspace::{open_paths, AppState};
753
754 use super::*;
755
756 #[gpui::test]
757 async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
758 let app_state = init_test(cx);
759
760 cx.update(|cx| {
761 SettingsStore::update_global(cx, |store, cx| {
762 store.update_user_settings::<ProjectSettings>(cx, |settings| {
763 settings.session.restore_unsaved_buffers = false
764 });
765 });
766 });
767
768 app_state
769 .fs
770 .as_fake()
771 .insert_tree(
772 "/dir",
773 json!({
774 "main.ts": "a"
775 }),
776 )
777 .await;
778 cx.update(|cx| {
779 open_paths(
780 &[PathBuf::from("/dir/main.ts")],
781 app_state,
782 workspace::OpenOptions::default(),
783 cx,
784 )
785 })
786 .await
787 .unwrap();
788 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
789
790 let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
791 workspace
792 .update(cx, |workspace, _| assert!(!workspace.is_edited()))
793 .unwrap();
794
795 let editor = workspace
796 .read_with(cx, |workspace, cx| {
797 workspace
798 .active_item(cx)
799 .unwrap()
800 .downcast::<Editor>()
801 .unwrap()
802 })
803 .unwrap();
804 workspace
805 .update(cx, |_, cx| {
806 editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
807 })
808 .unwrap();
809 workspace
810 .update(cx, |workspace, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
811 .unwrap();
812
813 let recent_projects_picker = open_recent_projects(&workspace, cx);
814 workspace
815 .update(cx, |_, cx| {
816 recent_projects_picker.update(cx, |picker, cx| {
817 assert_eq!(picker.query(cx), "");
818 let delegate = &mut picker.delegate;
819 delegate.matches = vec![StringMatch {
820 candidate_id: 0,
821 score: 1.0,
822 positions: Vec::new(),
823 string: "fake candidate".to_string(),
824 }];
825 delegate.set_workspaces(vec![(
826 WorkspaceId::default(),
827 SerializedWorkspaceLocation::from_local_paths(vec!["/test/path/"]),
828 )]);
829 });
830 })
831 .unwrap();
832
833 assert!(
834 !cx.has_pending_prompt(),
835 "Should have no pending prompt on dirty project before opening the new recent project"
836 );
837 cx.dispatch_action(*workspace, menu::Confirm);
838 workspace
839 .update(cx, |workspace, cx| {
840 assert!(
841 workspace.active_modal::<RecentProjects>(cx).is_none(),
842 "Should remove the modal after selecting new recent project"
843 )
844 })
845 .unwrap();
846 assert!(
847 cx.has_pending_prompt(),
848 "Dirty workspace should prompt before opening the new recent project"
849 );
850 // Cancel
851 cx.simulate_prompt_answer(0);
852 assert!(
853 !cx.has_pending_prompt(),
854 "Should have no pending prompt after cancelling"
855 );
856 workspace
857 .update(cx, |workspace, _| {
858 assert!(
859 workspace.is_edited(),
860 "Should be in the same dirty project after cancelling"
861 )
862 })
863 .unwrap();
864 }
865
866 fn open_recent_projects(
867 workspace: &WindowHandle<Workspace>,
868 cx: &mut TestAppContext,
869 ) -> View<Picker<RecentProjectsDelegate>> {
870 cx.dispatch_action(
871 (*workspace).into(),
872 OpenRecent {
873 create_new_window: false,
874 },
875 );
876 workspace
877 .update(cx, |workspace, cx| {
878 workspace
879 .active_modal::<RecentProjects>(cx)
880 .unwrap()
881 .read(cx)
882 .picker
883 .clone()
884 })
885 .unwrap()
886 }
887
888 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
889 cx.update(|cx| {
890 let state = AppState::test(cx);
891 language::init(cx);
892 crate::init(cx);
893 editor::init(cx);
894 workspace::init_settings(cx);
895 Project::init_settings(cx);
896 state
897 })
898 }
899}