1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5mod ssh_config;
6
7use std::{
8 path::{Path, PathBuf},
9 sync::Arc,
10};
11
12use fs::Fs;
13
14#[cfg(target_os = "windows")]
15mod wsl_picker;
16
17use remote::RemoteConnectionOptions;
18pub use remote_connection::{RemoteConnectionModal, connect};
19pub use remote_connections::open_remote_project;
20
21use disconnected_overlay::DisconnectedOverlay;
22use fuzzy::{StringMatch, StringMatchCandidate};
23use gpui::{
24 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
25 Subscription, Task, WeakEntity, Window, actions, px,
26};
27
28use picker::{
29 Picker, PickerDelegate,
30 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
31};
32use project::{Worktree, git_store::Repository};
33pub use remote_connections::RemoteSettings;
34pub use remote_servers::RemoteServerProjects;
35use settings::{Settings, WorktreeId};
36use ui_input::ErasedEditor;
37
38use dev_container::{DevContainerContext, find_devcontainer_configs};
39use ui::{
40 ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
41 PopoverMenuHandle, TintColor, Tooltip, prelude::*,
42};
43use util::{ResultExt, paths::PathExt};
44use workspace::{
45 HistoryManager, ModalView, MultiWorkspace, OpenOptions, OpenVisible, PathList,
46 SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId,
47 notifications::DetachAndPromptErr, with_active_or_new_workspace,
48};
49use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
50
51actions!(recent_projects, [ToggleActionsMenu]);
52
53#[derive(Clone, Debug)]
54pub struct RecentProjectEntry {
55 pub name: SharedString,
56 pub full_path: SharedString,
57 pub paths: Vec<PathBuf>,
58 pub workspace_id: WorkspaceId,
59}
60
61#[derive(Clone, Debug)]
62struct OpenFolderEntry {
63 worktree_id: WorktreeId,
64 name: SharedString,
65 path: PathBuf,
66 branch: Option<SharedString>,
67 is_active: bool,
68}
69
70#[derive(Clone, Debug)]
71enum ProjectPickerEntry {
72 Header(SharedString),
73 OpenFolder { index: usize, positions: Vec<usize> },
74 RecentProject(StringMatch),
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum ProjectPickerStyle {
79 Modal,
80 Popover,
81}
82
83pub async fn get_recent_projects(
84 current_workspace_id: Option<WorkspaceId>,
85 limit: Option<usize>,
86 fs: Arc<dyn fs::Fs>,
87) -> Vec<RecentProjectEntry> {
88 let workspaces = WORKSPACE_DB
89 .recent_workspaces_on_disk(fs.as_ref())
90 .await
91 .unwrap_or_default();
92
93 let entries: Vec<RecentProjectEntry> = workspaces
94 .into_iter()
95 .filter(|(id, _, _)| Some(*id) != current_workspace_id)
96 .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
97 .map(|(workspace_id, _, path_list)| {
98 let paths: Vec<PathBuf> = path_list.paths().to_vec();
99 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
100
101 let name = if ordered_paths.len() == 1 {
102 ordered_paths[0]
103 .file_name()
104 .map(|n| n.to_string_lossy().to_string())
105 .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
106 } else {
107 ordered_paths
108 .iter()
109 .filter_map(|p| p.file_name())
110 .map(|n| n.to_string_lossy().to_string())
111 .collect::<Vec<_>>()
112 .join(", ")
113 };
114
115 let full_path = ordered_paths
116 .iter()
117 .map(|p| p.to_string_lossy().to_string())
118 .collect::<Vec<_>>()
119 .join("\n");
120
121 RecentProjectEntry {
122 name: SharedString::from(name),
123 full_path: SharedString::from(full_path),
124 paths,
125 workspace_id,
126 }
127 })
128 .collect();
129
130 match limit {
131 Some(n) => entries.into_iter().take(n).collect(),
132 None => entries,
133 }
134}
135
136pub async fn delete_recent_project(workspace_id: WorkspaceId) {
137 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
138}
139
140fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
141 let project = workspace.project().read(cx);
142 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
143
144 if visible_worktrees.len() <= 1 {
145 return Vec::new();
146 }
147
148 let active_worktree_id = workspace.active_worktree_override().or_else(|| {
149 if let Some(repo) = project.active_repository(cx) {
150 let repo = repo.read(cx);
151 let repo_path = &repo.work_directory_abs_path;
152 for worktree in project.visible_worktrees(cx) {
153 let worktree_path = worktree.read(cx).abs_path();
154 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
155 return Some(worktree.read(cx).id());
156 }
157 }
158 }
159 project
160 .visible_worktrees(cx)
161 .next()
162 .map(|wt| wt.read(cx).id())
163 });
164
165 let git_store = project.git_store().read(cx);
166 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
167
168 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
169 .into_iter()
170 .map(|worktree| {
171 let worktree_ref = worktree.read(cx);
172 let worktree_id = worktree_ref.id();
173 let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
174 let path = worktree_ref.abs_path().to_path_buf();
175 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
176 let is_active = active_worktree_id == Some(worktree_id);
177 OpenFolderEntry {
178 worktree_id,
179 name,
180 path,
181 branch,
182 is_active,
183 }
184 })
185 .collect();
186
187 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
188 entries
189}
190
191fn get_branch_for_worktree(
192 worktree: &Worktree,
193 repositories: &[Entity<Repository>],
194 cx: &App,
195) -> Option<SharedString> {
196 let worktree_abs_path = worktree.abs_path();
197 for repo in repositories {
198 let repo = repo.read(cx);
199 if repo.work_directory_abs_path == worktree_abs_path
200 || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
201 {
202 if let Some(branch) = &repo.branch {
203 return Some(SharedString::from(branch.name().to_string()));
204 }
205 }
206 }
207 None
208}
209
210pub fn init(cx: &mut App) {
211 #[cfg(target_os = "windows")]
212 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
213 let create_new_window = open_wsl.create_new_window;
214 with_active_or_new_workspace(cx, move |workspace, window, cx| {
215 use gpui::PathPromptOptions;
216 use project::DirectoryLister;
217
218 let paths = workspace.prompt_for_open_path(
219 PathPromptOptions {
220 files: true,
221 directories: true,
222 multiple: false,
223 prompt: None,
224 },
225 DirectoryLister::Local(
226 workspace.project().clone(),
227 workspace.app_state().fs.clone(),
228 ),
229 window,
230 cx,
231 );
232
233 cx.spawn_in(window, async move |workspace, cx| {
234 use util::paths::SanitizedPath;
235
236 let Some(paths) = paths.await.log_err().flatten() else {
237 return;
238 };
239
240 let paths = paths
241 .into_iter()
242 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
243 .collect::<Vec<_>>();
244
245 if paths.is_empty() {
246 let message = indoc::indoc! { r#"
247 Invalid path specified when trying to open a folder inside WSL.
248
249 Please note that Zed currently does not support opening network share folders inside wsl.
250 "#};
251
252 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
253 return;
254 }
255
256 workspace.update_in(cx, |workspace, window, cx| {
257 workspace.toggle_modal(window, cx, |window, cx| {
258 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
259 });
260 }).log_err();
261 })
262 .detach();
263 });
264 });
265
266 #[cfg(target_os = "windows")]
267 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
268 let create_new_window = open_wsl.create_new_window;
269 with_active_or_new_workspace(cx, move |workspace, window, cx| {
270 let handle = cx.entity().downgrade();
271 let fs = workspace.project().read(cx).fs().clone();
272 workspace.toggle_modal(window, cx, |window, cx| {
273 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
274 });
275 });
276 });
277
278 #[cfg(target_os = "windows")]
279 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
280 let open_wsl = open_wsl.clone();
281 with_active_or_new_workspace(cx, move |workspace, window, cx| {
282 let fs = workspace.project().read(cx).fs().clone();
283 add_wsl_distro(fs, &open_wsl.distro, cx);
284 let open_options = OpenOptions {
285 replace_window: window.window_handle().downcast::<MultiWorkspace>(),
286 ..Default::default()
287 };
288
289 let app_state = workspace.app_state().clone();
290
291 cx.spawn_in(window, async move |_, cx| {
292 open_remote_project(
293 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
294 open_wsl.paths,
295 app_state,
296 open_options,
297 cx,
298 )
299 .await
300 })
301 .detach();
302 });
303 });
304
305 cx.on_action(|open_recent: &OpenRecent, cx| {
306 let create_new_window = open_recent.create_new_window;
307 with_active_or_new_workspace(cx, move |workspace, window, cx| {
308 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
309 let focus_handle = workspace.focus_handle(cx);
310 RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
311 return;
312 };
313
314 recent_projects.update(cx, |recent_projects, cx| {
315 recent_projects
316 .picker
317 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
318 });
319 });
320 });
321 cx.on_action(|open_remote: &OpenRemote, cx| {
322 let from_existing_connection = open_remote.from_existing_connection;
323 let create_new_window = open_remote.create_new_window;
324 with_active_or_new_workspace(cx, move |workspace, window, cx| {
325 if from_existing_connection {
326 cx.propagate();
327 return;
328 }
329 let handle = cx.entity().downgrade();
330 let fs = workspace.project().read(cx).fs().clone();
331 workspace.toggle_modal(window, cx, |window, cx| {
332 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
333 })
334 });
335 });
336
337 cx.observe_new(DisconnectedOverlay::register).detach();
338
339 cx.on_action(|_: &OpenDevContainer, cx| {
340 with_active_or_new_workspace(cx, move |workspace, window, cx| {
341 if !workspace.project().read(cx).is_local() {
342 cx.spawn_in(window, async move |_, cx| {
343 cx.prompt(
344 gpui::PromptLevel::Critical,
345 "Cannot open Dev Container from remote project",
346 None,
347 &["Ok"],
348 )
349 .await
350 .ok();
351 })
352 .detach();
353 return;
354 }
355
356 let fs = workspace.project().read(cx).fs().clone();
357 let configs = find_devcontainer_configs(workspace, cx);
358 let app_state = workspace.app_state().clone();
359 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
360 let handle = cx.entity().downgrade();
361 workspace.toggle_modal(window, cx, |window, cx| {
362 RemoteServerProjects::new_dev_container(
363 fs,
364 configs,
365 app_state,
366 dev_container_context,
367 window,
368 handle,
369 cx,
370 )
371 });
372 });
373 });
374
375 // Subscribe to worktree additions to suggest opening the project in a dev container
376 cx.observe_new(
377 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
378 let Some(window) = window else {
379 return;
380 };
381 cx.subscribe_in(
382 workspace.project(),
383 window,
384 move |_, project, event, window, cx| {
385 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
386 event
387 {
388 dev_container_suggest::suggest_on_worktree_updated(
389 *worktree_id,
390 updated_entries,
391 project,
392 window,
393 cx,
394 );
395 }
396 },
397 )
398 .detach();
399 },
400 )
401 .detach();
402}
403
404#[cfg(target_os = "windows")]
405pub fn add_wsl_distro(
406 fs: Arc<dyn project::Fs>,
407 connection_options: &remote::WslConnectionOptions,
408 cx: &App,
409) {
410 use gpui::ReadGlobal;
411 use settings::SettingsStore;
412
413 let distro_name = connection_options.distro_name.clone();
414 let user = connection_options.user.clone();
415 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
416 let connections = setting
417 .remote
418 .wsl_connections
419 .get_or_insert(Default::default());
420
421 if !connections
422 .iter()
423 .any(|conn| conn.distro_name == distro_name && conn.user == user)
424 {
425 use std::collections::BTreeSet;
426
427 connections.push(settings::WslConnection {
428 distro_name,
429 user,
430 projects: BTreeSet::new(),
431 })
432 }
433 });
434}
435
436pub struct RecentProjects {
437 pub picker: Entity<Picker<RecentProjectsDelegate>>,
438 rem_width: f32,
439 _subscription: Subscription,
440}
441
442impl ModalView for RecentProjects {
443 fn on_before_dismiss(
444 &mut self,
445 window: &mut Window,
446 cx: &mut Context<Self>,
447 ) -> workspace::DismissDecision {
448 let submenu_focused = self.picker.update(cx, |picker, cx| {
449 picker.delegate.actions_menu_handle.is_focused(window, cx)
450 });
451 workspace::DismissDecision::Dismiss(!submenu_focused)
452 }
453}
454
455impl RecentProjects {
456 fn new(
457 delegate: RecentProjectsDelegate,
458 fs: Option<Arc<dyn Fs>>,
459 rem_width: f32,
460 window: &mut Window,
461 cx: &mut Context<Self>,
462 ) -> Self {
463 let picker = cx.new(|cx| {
464 Picker::list(delegate, window, cx)
465 .list_measure_all()
466 .show_scrollbar(true)
467 });
468
469 let picker_focus_handle = picker.focus_handle(cx);
470 picker.update(cx, |picker, _| {
471 picker.delegate.focus_handle = picker_focus_handle;
472 });
473
474 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
475 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
476 // out workspace locations once the future runs to completion.
477 cx.spawn_in(window, async move |this, cx| {
478 let Some(fs) = fs else { return };
479 let workspaces = WORKSPACE_DB
480 .recent_workspaces_on_disk(fs.as_ref())
481 .await
482 .log_err()
483 .unwrap_or_default();
484 this.update_in(cx, move |this, window, cx| {
485 this.picker.update(cx, move |picker, cx| {
486 picker.delegate.set_workspaces(workspaces);
487 picker.update_matches(picker.query(cx), window, cx)
488 })
489 })
490 .ok();
491 })
492 .detach();
493 Self {
494 picker,
495 rem_width,
496 _subscription,
497 }
498 }
499
500 pub fn open(
501 workspace: &mut Workspace,
502 create_new_window: bool,
503 window: &mut Window,
504 focus_handle: FocusHandle,
505 cx: &mut Context<Workspace>,
506 ) {
507 let weak = cx.entity().downgrade();
508 let open_folders = get_open_folders(workspace, cx);
509 let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
510 let fs = Some(workspace.app_state().fs.clone());
511 workspace.toggle_modal(window, cx, |window, cx| {
512 let delegate = RecentProjectsDelegate::new(
513 weak,
514 create_new_window,
515 focus_handle,
516 open_folders,
517 project_connection_options,
518 ProjectPickerStyle::Modal,
519 );
520
521 Self::new(delegate, fs, 34., window, cx)
522 })
523 }
524
525 pub fn popover(
526 workspace: WeakEntity<Workspace>,
527 create_new_window: bool,
528 focus_handle: FocusHandle,
529 window: &mut Window,
530 cx: &mut App,
531 ) -> Entity<Self> {
532 let (open_folders, project_connection_options, fs) = workspace
533 .upgrade()
534 .map(|workspace| {
535 let workspace = workspace.read(cx);
536 (
537 get_open_folders(workspace, cx),
538 workspace.project().read(cx).remote_connection_options(cx),
539 Some(workspace.app_state().fs.clone()),
540 )
541 })
542 .unwrap_or_else(|| (Vec::new(), None, None));
543
544 cx.new(|cx| {
545 let delegate = RecentProjectsDelegate::new(
546 workspace,
547 create_new_window,
548 focus_handle,
549 open_folders,
550 project_connection_options,
551 ProjectPickerStyle::Popover,
552 );
553 let list = Self::new(delegate, fs, 20., window, cx);
554 list.picker.focus_handle(cx).focus(window, cx);
555 list
556 })
557 }
558
559 fn handle_toggle_open_menu(
560 &mut self,
561 _: &ToggleActionsMenu,
562 window: &mut Window,
563 cx: &mut Context<Self>,
564 ) {
565 self.picker.update(cx, |picker, cx| {
566 let menu_handle = &picker.delegate.actions_menu_handle;
567 if menu_handle.is_deployed() {
568 menu_handle.hide(cx);
569 } else {
570 menu_handle.show(window, cx);
571 }
572 });
573 }
574}
575
576impl EventEmitter<DismissEvent> for RecentProjects {}
577
578impl Focusable for RecentProjects {
579 fn focus_handle(&self, cx: &App) -> FocusHandle {
580 self.picker.focus_handle(cx)
581 }
582}
583
584impl Render for RecentProjects {
585 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
586 v_flex()
587 .key_context("RecentProjects")
588 .on_action(cx.listener(Self::handle_toggle_open_menu))
589 .w(rems(self.rem_width))
590 .child(self.picker.clone())
591 }
592}
593
594pub struct RecentProjectsDelegate {
595 workspace: WeakEntity<Workspace>,
596 open_folders: Vec<OpenFolderEntry>,
597 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
598 filtered_entries: Vec<ProjectPickerEntry>,
599 selected_index: usize,
600 render_paths: bool,
601 create_new_window: bool,
602 // Flag to reset index when there is a new query vs not reset index when user delete an item
603 reset_selected_match_index: bool,
604 has_any_non_local_projects: bool,
605 project_connection_options: Option<RemoteConnectionOptions>,
606 focus_handle: FocusHandle,
607 style: ProjectPickerStyle,
608 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
609}
610
611impl RecentProjectsDelegate {
612 fn new(
613 workspace: WeakEntity<Workspace>,
614 create_new_window: bool,
615 focus_handle: FocusHandle,
616 open_folders: Vec<OpenFolderEntry>,
617 project_connection_options: Option<RemoteConnectionOptions>,
618 style: ProjectPickerStyle,
619 ) -> Self {
620 let render_paths = style == ProjectPickerStyle::Modal;
621 Self {
622 workspace,
623 open_folders,
624 workspaces: Vec::new(),
625 filtered_entries: Vec::new(),
626 selected_index: 0,
627 create_new_window,
628 render_paths,
629 reset_selected_match_index: true,
630 has_any_non_local_projects: project_connection_options.is_some(),
631 project_connection_options,
632 focus_handle,
633 style,
634 actions_menu_handle: PopoverMenuHandle::default(),
635 }
636 }
637
638 pub fn set_workspaces(
639 &mut self,
640 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
641 ) {
642 self.workspaces = workspaces;
643 let has_non_local_recent = !self
644 .workspaces
645 .iter()
646 .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
647 self.has_any_non_local_projects =
648 self.project_connection_options.is_some() || has_non_local_recent;
649 }
650}
651impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
652impl PickerDelegate for RecentProjectsDelegate {
653 type ListItem = AnyElement;
654
655 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
656 "Search projects…".into()
657 }
658
659 fn render_editor(
660 &self,
661 editor: &Arc<dyn ErasedEditor>,
662 window: &mut Window,
663 cx: &mut Context<Picker<Self>>,
664 ) -> Div {
665 let focus_handle = self.focus_handle.clone();
666
667 h_flex()
668 .flex_none()
669 .h_9()
670 .pl_2p5()
671 .pr_1p5()
672 .justify_between()
673 .border_b_1()
674 .border_color(cx.theme().colors().border_variant)
675 .child(editor.render(window, cx))
676 .child(
677 IconButton::new("add_folder", IconName::Plus)
678 .icon_size(IconSize::Small)
679 .tooltip(move |_, cx| {
680 Tooltip::for_action_in(
681 "Add Project to Workspace",
682 &workspace::AddFolderToProject,
683 &focus_handle,
684 cx,
685 )
686 })
687 .on_click(|_, window, cx| {
688 window.dispatch_action(workspace::AddFolderToProject.boxed_clone(), cx)
689 }),
690 )
691 }
692
693 fn match_count(&self) -> usize {
694 self.filtered_entries.len()
695 }
696
697 fn selected_index(&self) -> usize {
698 self.selected_index
699 }
700
701 fn set_selected_index(
702 &mut self,
703 ix: usize,
704 _window: &mut Window,
705 _cx: &mut Context<Picker<Self>>,
706 ) {
707 self.selected_index = ix;
708 }
709
710 fn can_select(
711 &mut self,
712 ix: usize,
713 _window: &mut Window,
714 _cx: &mut Context<Picker<Self>>,
715 ) -> bool {
716 matches!(
717 self.filtered_entries.get(ix),
718 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_))
719 )
720 }
721
722 fn update_matches(
723 &mut self,
724 query: String,
725 _: &mut Window,
726 cx: &mut Context<Picker<Self>>,
727 ) -> gpui::Task<()> {
728 let query = query.trim_start();
729 let smart_case = query.chars().any(|c| c.is_uppercase());
730 let is_empty_query = query.is_empty();
731
732 let folder_matches = if self.open_folders.is_empty() {
733 Vec::new()
734 } else {
735 let candidates: Vec<_> = self
736 .open_folders
737 .iter()
738 .enumerate()
739 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
740 .collect();
741
742 smol::block_on(fuzzy::match_strings(
743 &candidates,
744 query,
745 smart_case,
746 true,
747 100,
748 &Default::default(),
749 cx.background_executor().clone(),
750 ))
751 };
752
753 let recent_candidates: Vec<_> = self
754 .workspaces
755 .iter()
756 .enumerate()
757 .filter(|(_, (id, _, paths))| self.is_valid_recent_candidate(*id, paths, cx))
758 .map(|(id, (_, _, paths))| {
759 let combined_string = paths
760 .ordered_paths()
761 .map(|path| path.compact().to_string_lossy().into_owned())
762 .collect::<Vec<_>>()
763 .join("");
764 StringMatchCandidate::new(id, &combined_string)
765 })
766 .collect();
767
768 let mut recent_matches = smol::block_on(fuzzy::match_strings(
769 &recent_candidates,
770 query,
771 smart_case,
772 true,
773 100,
774 &Default::default(),
775 cx.background_executor().clone(),
776 ));
777 recent_matches.sort_unstable_by(|a, b| {
778 b.score
779 .partial_cmp(&a.score)
780 .unwrap_or(std::cmp::Ordering::Equal)
781 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
782 });
783
784 let mut entries = Vec::new();
785
786 if !self.open_folders.is_empty() {
787 let matched_folders: Vec<_> = if is_empty_query {
788 (0..self.open_folders.len())
789 .map(|i| (i, Vec::new()))
790 .collect()
791 } else {
792 folder_matches
793 .iter()
794 .map(|m| (m.candidate_id, m.positions.clone()))
795 .collect()
796 };
797
798 for (index, positions) in matched_folders {
799 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
800 }
801 }
802
803 let has_recent_to_show = if is_empty_query {
804 !recent_candidates.is_empty()
805 } else {
806 !recent_matches.is_empty()
807 };
808
809 if has_recent_to_show {
810 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
811
812 if is_empty_query {
813 for (id, (workspace_id, _, paths)) in self.workspaces.iter().enumerate() {
814 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
815 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
816 candidate_id: id,
817 score: 0.0,
818 positions: Vec::new(),
819 string: String::new(),
820 }));
821 }
822 }
823 } else {
824 for m in recent_matches {
825 entries.push(ProjectPickerEntry::RecentProject(m));
826 }
827 }
828 }
829
830 self.filtered_entries = entries;
831
832 if self.reset_selected_match_index {
833 self.selected_index = self
834 .filtered_entries
835 .iter()
836 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
837 .unwrap_or(0);
838 }
839 self.reset_selected_match_index = true;
840 Task::ready(())
841 }
842
843 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
844 match self.filtered_entries.get(self.selected_index) {
845 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
846 let Some(folder) = self.open_folders.get(*index) else {
847 return;
848 };
849 let worktree_id = folder.worktree_id;
850 if let Some(workspace) = self.workspace.upgrade() {
851 workspace.update(cx, |workspace, cx| {
852 workspace.set_active_worktree_override(Some(worktree_id), cx);
853 });
854 }
855 cx.emit(DismissEvent);
856 }
857 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
858 let Some(workspace) = self.workspace.upgrade() else {
859 return;
860 };
861 let Some((
862 candidate_workspace_id,
863 candidate_workspace_location,
864 candidate_workspace_paths,
865 )) = self.workspaces.get(selected_match.candidate_id)
866 else {
867 return;
868 };
869
870 let replace_current_window = self.create_new_window == secondary;
871 let candidate_workspace_id = *candidate_workspace_id;
872 let candidate_workspace_location = candidate_workspace_location.clone();
873 let candidate_workspace_paths = candidate_workspace_paths.clone();
874
875 workspace.update(cx, |workspace, cx| {
876 if workspace.database_id() == Some(candidate_workspace_id) {
877 return;
878 }
879 match candidate_workspace_location {
880 SerializedWorkspaceLocation::Local => {
881 let paths = candidate_workspace_paths.paths().to_vec();
882 if replace_current_window {
883 if let Some(handle) =
884 window.window_handle().downcast::<MultiWorkspace>()
885 {
886 cx.defer(move |cx| {
887 if let Some(task) = handle
888 .update(cx, |multi_workspace, window, cx| {
889 multi_workspace.open_project(paths, window, cx)
890 })
891 .log_err()
892 {
893 task.detach_and_log_err(cx);
894 }
895 });
896 }
897 return;
898 } else {
899 workspace.open_workspace_for_paths(false, paths, window, cx)
900 }
901 }
902 SerializedWorkspaceLocation::Remote(mut connection) => {
903 let app_state = workspace.app_state().clone();
904 let replace_window = if replace_current_window {
905 window.window_handle().downcast::<MultiWorkspace>()
906 } else {
907 None
908 };
909 let open_options = OpenOptions {
910 replace_window,
911 ..Default::default()
912 };
913 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
914 RemoteSettings::get_global(cx)
915 .fill_connection_options_from_settings(connection);
916 };
917 let paths = candidate_workspace_paths.paths().to_vec();
918 cx.spawn_in(window, async move |_, cx| {
919 open_remote_project(
920 connection.clone(),
921 paths,
922 app_state,
923 open_options,
924 cx,
925 )
926 .await
927 })
928 }
929 }
930 .detach_and_prompt_err(
931 "Failed to open project",
932 window,
933 cx,
934 |_, _, _| None,
935 );
936 });
937 cx.emit(DismissEvent);
938 }
939 _ => {}
940 }
941 }
942
943 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
944
945 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
946 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
947 "Recently opened projects will show up here".into()
948 } else {
949 "No matches".into()
950 };
951 Some(text)
952 }
953
954 fn render_match(
955 &self,
956 ix: usize,
957 selected: bool,
958 window: &mut Window,
959 cx: &mut Context<Picker<Self>>,
960 ) -> Option<Self::ListItem> {
961 match self.filtered_entries.get(ix)? {
962 ProjectPickerEntry::Header(title) => Some(
963 v_flex()
964 .w_full()
965 .gap_1()
966 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
967 .child(ListSubHeader::new(title.clone()).inset(true))
968 .into_any_element(),
969 ),
970 ProjectPickerEntry::OpenFolder { index, positions } => {
971 let folder = self.open_folders.get(*index)?;
972 let name = folder.name.clone();
973 let path = folder.path.compact();
974 let branch = folder.branch.clone();
975 let is_active = folder.is_active;
976 let worktree_id = folder.worktree_id;
977 let positions = positions.clone();
978 let show_path = self.style == ProjectPickerStyle::Modal;
979
980 let secondary_actions = h_flex()
981 .gap_1()
982 .child(
983 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
984 .icon_size(IconSize::Small)
985 .tooltip(Tooltip::text("Remove Folder from Workspace"))
986 .on_click(cx.listener(move |picker, _, window, cx| {
987 let Some(workspace) = picker.delegate.workspace.upgrade() else {
988 return;
989 };
990 workspace.update(cx, |workspace, cx| {
991 let project = workspace.project().clone();
992 project.update(cx, |project, cx| {
993 project.remove_worktree(worktree_id, cx);
994 });
995 });
996 picker.delegate.open_folders =
997 get_open_folders(workspace.read(cx), cx);
998 let query = picker.query(cx);
999 picker.update_matches(query, window, cx);
1000 })),
1001 )
1002 .into_any_element();
1003
1004 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1005
1006 Some(
1007 ListItem::new(ix)
1008 .toggle_state(selected)
1009 .inset(true)
1010 .spacing(ListItemSpacing::Sparse)
1011 .child(
1012 h_flex()
1013 .id("open_folder_item")
1014 .gap_3()
1015 .flex_grow()
1016 .when(self.has_any_non_local_projects, |this| {
1017 this.child(Icon::new(icon).color(Color::Muted))
1018 })
1019 .child(
1020 v_flex()
1021 .child(
1022 h_flex()
1023 .gap_1()
1024 .child({
1025 let highlighted = HighlightedMatch {
1026 text: name.to_string(),
1027 highlight_positions: positions,
1028 color: Color::Default,
1029 };
1030 highlighted.render(window, cx)
1031 })
1032 .when_some(branch, |this, branch| {
1033 this.child(
1034 Label::new(branch).color(Color::Muted),
1035 )
1036 })
1037 .when(is_active, |this| {
1038 this.child(
1039 Icon::new(IconName::Check)
1040 .size(IconSize::Small)
1041 .color(Color::Accent),
1042 )
1043 }),
1044 )
1045 .when(show_path, |this| {
1046 this.child(
1047 Label::new(path.to_string_lossy().to_string())
1048 .size(LabelSize::Small)
1049 .color(Color::Muted),
1050 )
1051 }),
1052 )
1053 .when(!show_path, |this| {
1054 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1055 }),
1056 )
1057 .map(|el| {
1058 if self.selected_index == ix {
1059 el.end_slot(secondary_actions)
1060 } else {
1061 el.end_hover_slot(secondary_actions)
1062 }
1063 })
1064 .into_any_element(),
1065 )
1066 }
1067 ProjectPickerEntry::RecentProject(hit) => {
1068 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1069 let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
1070 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1071 let paths_to_add = paths.paths().to_vec();
1072 let tooltip_path: SharedString = paths
1073 .ordered_paths()
1074 .map(|p| p.compact().to_string_lossy().to_string())
1075 .collect::<Vec<_>>()
1076 .join("\n")
1077 .into();
1078
1079 let mut path_start_offset = 0;
1080 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1081 .ordered_paths()
1082 .map(|p| p.compact())
1083 .map(|path| {
1084 let highlighted_text =
1085 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1086 path_start_offset += highlighted_text.1.text.len();
1087 highlighted_text
1088 })
1089 .unzip();
1090
1091 let prefix = match &location {
1092 SerializedWorkspaceLocation::Remote(options) => {
1093 Some(SharedString::from(options.display_name()))
1094 }
1095 _ => None,
1096 };
1097
1098 let highlighted_match = HighlightedMatchWithPaths {
1099 prefix,
1100 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1101 paths,
1102 };
1103
1104 let focus_handle = self.focus_handle.clone();
1105
1106 let secondary_actions = h_flex()
1107 .gap_px()
1108 .when(is_local, |this| {
1109 this.child(
1110 IconButton::new("add_to_workspace", IconName::Plus)
1111 .icon_size(IconSize::Small)
1112 .tooltip(Tooltip::text("Add Project to Workspace"))
1113 .on_click({
1114 let paths_to_add = paths_to_add.clone();
1115 cx.listener(move |picker, _event, window, cx| {
1116 cx.stop_propagation();
1117 window.prevent_default();
1118 picker.delegate.add_project_to_workspace(
1119 paths_to_add.clone(),
1120 window,
1121 cx,
1122 );
1123 })
1124 }),
1125 )
1126 })
1127 .when(popover_style, |this| {
1128 this.child(
1129 IconButton::new("open_new_window", IconName::ArrowUpRight)
1130 .icon_size(IconSize::XSmall)
1131 .tooltip({
1132 move |_, cx| {
1133 Tooltip::for_action_in(
1134 "Open Project in New Window",
1135 &menu::SecondaryConfirm,
1136 &focus_handle,
1137 cx,
1138 )
1139 }
1140 })
1141 .on_click(cx.listener(move |this, _event, window, cx| {
1142 cx.stop_propagation();
1143 window.prevent_default();
1144 this.delegate.set_selected_index(ix, window, cx);
1145 this.delegate.confirm(true, window, cx);
1146 })),
1147 )
1148 })
1149 .child(
1150 IconButton::new("delete", IconName::Close)
1151 .icon_size(IconSize::Small)
1152 .tooltip(Tooltip::text("Delete from Recent Projects"))
1153 .on_click(cx.listener(move |this, _event, window, cx| {
1154 cx.stop_propagation();
1155 window.prevent_default();
1156 this.delegate.delete_recent_project(ix, window, cx)
1157 })),
1158 )
1159 .into_any_element();
1160
1161 let icon = icon_for_remote_connection(match location {
1162 SerializedWorkspaceLocation::Local => None,
1163 SerializedWorkspaceLocation::Remote(options) => Some(options),
1164 });
1165
1166 Some(
1167 ListItem::new(ix)
1168 .toggle_state(selected)
1169 .inset(true)
1170 .spacing(ListItemSpacing::Sparse)
1171 .child(
1172 h_flex()
1173 .id("project_info_container")
1174 .gap_3()
1175 .flex_grow()
1176 .when(self.has_any_non_local_projects, |this| {
1177 this.child(Icon::new(icon).color(Color::Muted))
1178 })
1179 .child({
1180 let mut highlighted = highlighted_match;
1181 if !self.render_paths {
1182 highlighted.paths.clear();
1183 }
1184 highlighted.render(window, cx)
1185 })
1186 .tooltip(Tooltip::text(tooltip_path)),
1187 )
1188 .map(|el| {
1189 if self.selected_index == ix {
1190 el.end_slot(secondary_actions)
1191 } else {
1192 el.end_hover_slot(secondary_actions)
1193 }
1194 })
1195 .into_any_element(),
1196 )
1197 }
1198 }
1199 }
1200
1201 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1202 let focus_handle = self.focus_handle.clone();
1203 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1204 let open_folder_section = matches!(
1205 self.filtered_entries.get(self.selected_index)?,
1206 ProjectPickerEntry::OpenFolder { .. }
1207 );
1208
1209 if popover_style {
1210 return Some(
1211 v_flex()
1212 .flex_1()
1213 .p_1p5()
1214 .gap_1()
1215 .border_t_1()
1216 .border_color(cx.theme().colors().border_variant)
1217 .child(
1218 Button::new("open_local_folder", "Open Local Project")
1219 .key_binding(KeyBinding::for_action_in(
1220 &workspace::Open,
1221 &focus_handle,
1222 cx,
1223 ))
1224 .on_click(|_, window, cx| {
1225 window.dispatch_action(workspace::Open.boxed_clone(), cx)
1226 }),
1227 )
1228 .child(
1229 Button::new("open_remote_folder", "Open Remote Project")
1230 .key_binding(KeyBinding::for_action(
1231 &OpenRemote {
1232 from_existing_connection: false,
1233 create_new_window: false,
1234 },
1235 cx,
1236 ))
1237 .on_click(|_, window, cx| {
1238 window.dispatch_action(
1239 OpenRemote {
1240 from_existing_connection: false,
1241 create_new_window: false,
1242 }
1243 .boxed_clone(),
1244 cx,
1245 )
1246 }),
1247 )
1248 .into_any(),
1249 );
1250 }
1251
1252 Some(
1253 h_flex()
1254 .flex_1()
1255 .p_1p5()
1256 .gap_1()
1257 .justify_end()
1258 .border_t_1()
1259 .border_color(cx.theme().colors().border_variant)
1260 .map(|this| {
1261 if open_folder_section {
1262 this.child(
1263 Button::new("activate", "Activate")
1264 .key_binding(KeyBinding::for_action_in(
1265 &menu::Confirm,
1266 &focus_handle,
1267 cx,
1268 ))
1269 .on_click(|_, window, cx| {
1270 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1271 }),
1272 )
1273 } else {
1274 this.child(
1275 Button::new("open_new_window", "New Window")
1276 .key_binding(KeyBinding::for_action_in(
1277 &menu::SecondaryConfirm,
1278 &focus_handle,
1279 cx,
1280 ))
1281 .on_click(|_, window, cx| {
1282 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1283 }),
1284 )
1285 .child(
1286 Button::new("open_here", "Open")
1287 .key_binding(KeyBinding::for_action_in(
1288 &menu::Confirm,
1289 &focus_handle,
1290 cx,
1291 ))
1292 .on_click(|_, window, cx| {
1293 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1294 }),
1295 )
1296 }
1297 })
1298 .child(Divider::vertical())
1299 .child(
1300 PopoverMenu::new("actions-menu-popover")
1301 .with_handle(self.actions_menu_handle.clone())
1302 .anchor(gpui::Corner::BottomRight)
1303 .offset(gpui::Point {
1304 x: px(0.0),
1305 y: px(-2.0),
1306 })
1307 .trigger(
1308 Button::new("actions-trigger", "Actions…")
1309 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1310 .key_binding(KeyBinding::for_action_in(
1311 &ToggleActionsMenu,
1312 &focus_handle,
1313 cx,
1314 )),
1315 )
1316 .menu({
1317 let focus_handle = focus_handle.clone();
1318
1319 move |window, cx| {
1320 Some(ContextMenu::build(window, cx, {
1321 let focus_handle = focus_handle.clone();
1322 move |menu, _, _| {
1323 menu.context(focus_handle)
1324 .action(
1325 "Open Local Project",
1326 workspace::Open.boxed_clone(),
1327 )
1328 .action(
1329 "Open Remote Project",
1330 OpenRemote {
1331 from_existing_connection: false,
1332 create_new_window: false,
1333 }
1334 .boxed_clone(),
1335 )
1336 }
1337 }))
1338 }
1339 }),
1340 )
1341 .into_any(),
1342 )
1343 }
1344}
1345
1346fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1347 match options {
1348 None => IconName::Screen,
1349 Some(options) => match options {
1350 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1351 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1352 RemoteConnectionOptions::Docker(_) => IconName::Box,
1353 #[cfg(any(test, feature = "test-support"))]
1354 RemoteConnectionOptions::Mock(_) => IconName::Server,
1355 },
1356 }
1357}
1358
1359// Compute the highlighted text for the name and path
1360fn highlights_for_path(
1361 path: &Path,
1362 match_positions: &Vec<usize>,
1363 path_start_offset: usize,
1364) -> (Option<HighlightedMatch>, HighlightedMatch) {
1365 let path_string = path.to_string_lossy();
1366 let path_text = path_string.to_string();
1367 let path_byte_len = path_text.len();
1368 // Get the subset of match highlight positions that line up with the given path.
1369 // Also adjusts them to start at the path start
1370 let path_positions = match_positions
1371 .iter()
1372 .copied()
1373 .skip_while(|position| *position < path_start_offset)
1374 .take_while(|position| *position < path_start_offset + path_byte_len)
1375 .map(|position| position - path_start_offset)
1376 .collect::<Vec<_>>();
1377
1378 // Again subset the highlight positions to just those that line up with the file_name
1379 // again adjusted to the start of the file_name
1380 let file_name_text_and_positions = path.file_name().map(|file_name| {
1381 let file_name_text = file_name.to_string_lossy().into_owned();
1382 let file_name_start_byte = path_byte_len - file_name_text.len();
1383 let highlight_positions = path_positions
1384 .iter()
1385 .copied()
1386 .skip_while(|position| *position < file_name_start_byte)
1387 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1388 .map(|position| position - file_name_start_byte)
1389 .collect::<Vec<_>>();
1390 HighlightedMatch {
1391 text: file_name_text,
1392 highlight_positions,
1393 color: Color::Default,
1394 }
1395 });
1396
1397 (
1398 file_name_text_and_positions,
1399 HighlightedMatch {
1400 text: path_text,
1401 highlight_positions: path_positions,
1402 color: Color::Default,
1403 },
1404 )
1405}
1406impl RecentProjectsDelegate {
1407 fn add_project_to_workspace(
1408 &mut self,
1409 paths: Vec<PathBuf>,
1410 window: &mut Window,
1411 cx: &mut Context<Picker<Self>>,
1412 ) {
1413 let Some(workspace) = self.workspace.upgrade() else {
1414 return;
1415 };
1416 let open_paths_task = workspace.update(cx, |workspace, cx| {
1417 workspace.open_paths(
1418 paths,
1419 OpenOptions {
1420 visible: Some(OpenVisible::All),
1421 ..Default::default()
1422 },
1423 None,
1424 window,
1425 cx,
1426 )
1427 });
1428 cx.spawn_in(window, async move |picker, cx| {
1429 let _result = open_paths_task.await;
1430 picker
1431 .update_in(cx, |picker, window, cx| {
1432 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1433 return;
1434 };
1435 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1436 let query = picker.query(cx);
1437 picker.update_matches(query, window, cx);
1438 })
1439 .ok();
1440 })
1441 .detach();
1442 }
1443
1444 fn delete_recent_project(
1445 &self,
1446 ix: usize,
1447 window: &mut Window,
1448 cx: &mut Context<Picker<Self>>,
1449 ) {
1450 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1451 self.filtered_entries.get(ix)
1452 {
1453 let (workspace_id, _, _) = &self.workspaces[selected_match.candidate_id];
1454 let workspace_id = *workspace_id;
1455 let fs = self
1456 .workspace
1457 .upgrade()
1458 .map(|ws| ws.read(cx).app_state().fs.clone());
1459 cx.spawn_in(window, async move |this, cx| {
1460 WORKSPACE_DB
1461 .delete_workspace_by_id(workspace_id)
1462 .await
1463 .log_err();
1464 let Some(fs) = fs else { return };
1465 let workspaces = WORKSPACE_DB
1466 .recent_workspaces_on_disk(fs.as_ref())
1467 .await
1468 .unwrap_or_default();
1469 this.update_in(cx, move |picker, window, cx| {
1470 picker.delegate.set_workspaces(workspaces);
1471 picker
1472 .delegate
1473 .set_selected_index(ix.saturating_sub(1), window, cx);
1474 picker.delegate.reset_selected_match_index = false;
1475 picker.update_matches(picker.query(cx), window, cx);
1476 // After deleting a project, we want to update the history manager to reflect the change.
1477 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1478 if let Some(history_manager) = HistoryManager::global(cx) {
1479 history_manager
1480 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1481 }
1482 })
1483 .ok();
1484 })
1485 .detach();
1486 }
1487 }
1488
1489 fn is_current_workspace(
1490 &self,
1491 workspace_id: WorkspaceId,
1492 cx: &mut Context<Picker<Self>>,
1493 ) -> bool {
1494 if let Some(workspace) = self.workspace.upgrade() {
1495 let workspace = workspace.read(cx);
1496 if Some(workspace_id) == workspace.database_id() {
1497 return true;
1498 }
1499 }
1500
1501 false
1502 }
1503
1504 fn is_open_folder(&self, paths: &PathList) -> bool {
1505 if self.open_folders.is_empty() {
1506 return false;
1507 }
1508
1509 for workspace_path in paths.paths() {
1510 for open_folder in &self.open_folders {
1511 if workspace_path == &open_folder.path {
1512 return true;
1513 }
1514 }
1515 }
1516
1517 false
1518 }
1519
1520 fn is_valid_recent_candidate(
1521 &self,
1522 workspace_id: WorkspaceId,
1523 paths: &PathList,
1524 cx: &mut Context<Picker<Self>>,
1525 ) -> bool {
1526 !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
1527 }
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use std::path::PathBuf;
1533
1534 use editor::Editor;
1535 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1536
1537 use serde_json::json;
1538 use settings::SettingsStore;
1539 use util::path;
1540 use workspace::{AppState, open_paths};
1541
1542 use super::*;
1543
1544 #[gpui::test]
1545 async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1546 let app_state = init_test(cx);
1547
1548 cx.update(|cx| {
1549 SettingsStore::update_global(cx, |store, cx| {
1550 store.update_user_settings(cx, |settings| {
1551 settings
1552 .session
1553 .get_or_insert_default()
1554 .restore_unsaved_buffers = Some(false)
1555 });
1556 });
1557 });
1558
1559 app_state
1560 .fs
1561 .as_fake()
1562 .insert_tree(
1563 path!("/dir"),
1564 json!({
1565 "main.ts": "a"
1566 }),
1567 )
1568 .await;
1569 app_state
1570 .fs
1571 .as_fake()
1572 .insert_tree(path!("/test/path"), json!({}))
1573 .await;
1574 cx.update(|cx| {
1575 open_paths(
1576 &[PathBuf::from(path!("/dir/main.ts"))],
1577 app_state,
1578 workspace::OpenOptions::default(),
1579 cx,
1580 )
1581 })
1582 .await
1583 .unwrap();
1584 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1585
1586 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1587 multi_workspace
1588 .update(cx, |multi_workspace, _, cx| {
1589 assert!(!multi_workspace.workspace().read(cx).is_edited())
1590 })
1591 .unwrap();
1592
1593 let editor = multi_workspace
1594 .read_with(cx, |multi_workspace, cx| {
1595 multi_workspace
1596 .workspace()
1597 .read(cx)
1598 .active_item(cx)
1599 .unwrap()
1600 .downcast::<Editor>()
1601 .unwrap()
1602 })
1603 .unwrap();
1604 multi_workspace
1605 .update(cx, |_, window, cx| {
1606 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1607 })
1608 .unwrap();
1609 multi_workspace
1610 .update(cx, |multi_workspace, _, cx| {
1611 assert!(
1612 multi_workspace.workspace().read(cx).is_edited(),
1613 "After inserting more text into the editor without saving, we should have a dirty project"
1614 )
1615 })
1616 .unwrap();
1617
1618 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1619 multi_workspace
1620 .update(cx, |_, _, cx| {
1621 recent_projects_picker.update(cx, |picker, cx| {
1622 assert_eq!(picker.query(cx), "");
1623 let delegate = &mut picker.delegate;
1624 delegate.set_workspaces(vec![(
1625 WorkspaceId::default(),
1626 SerializedWorkspaceLocation::Local,
1627 PathList::new(&[path!("/test/path")]),
1628 )]);
1629 delegate.filtered_entries =
1630 vec![ProjectPickerEntry::RecentProject(StringMatch {
1631 candidate_id: 0,
1632 score: 1.0,
1633 positions: Vec::new(),
1634 string: "fake candidate".to_string(),
1635 })];
1636 });
1637 })
1638 .unwrap();
1639
1640 assert!(
1641 !cx.has_pending_prompt(),
1642 "Should have no pending prompt on dirty project before opening the new recent project"
1643 );
1644 let dirty_workspace = multi_workspace
1645 .read_with(cx, |multi_workspace, _cx| {
1646 multi_workspace.workspace().clone()
1647 })
1648 .unwrap();
1649
1650 cx.dispatch_action(*multi_workspace, menu::Confirm);
1651 cx.run_until_parked();
1652
1653 multi_workspace
1654 .update(cx, |multi_workspace, _, cx| {
1655 assert!(
1656 multi_workspace
1657 .workspace()
1658 .read(cx)
1659 .active_modal::<RecentProjects>(cx)
1660 .is_none(),
1661 "Should remove the modal after selecting new recent project"
1662 );
1663
1664 assert!(
1665 multi_workspace.workspaces().len() >= 2,
1666 "Should have at least 2 workspaces: the dirty one and the newly opened one"
1667 );
1668
1669 assert!(
1670 multi_workspace.workspaces().contains(&dirty_workspace),
1671 "The original dirty workspace should still be present"
1672 );
1673
1674 assert!(
1675 dirty_workspace.read(cx).is_edited(),
1676 "The original workspace should still be dirty"
1677 );
1678 })
1679 .unwrap();
1680
1681 assert!(
1682 !cx.has_pending_prompt(),
1683 "No save prompt in multi-workspace mode — dirty workspace survives in background"
1684 );
1685 }
1686
1687 fn open_recent_projects(
1688 multi_workspace: &WindowHandle<MultiWorkspace>,
1689 cx: &mut TestAppContext,
1690 ) -> Entity<Picker<RecentProjectsDelegate>> {
1691 cx.dispatch_action(
1692 (*multi_workspace).into(),
1693 OpenRecent {
1694 create_new_window: false,
1695 },
1696 );
1697 multi_workspace
1698 .update(cx, |multi_workspace, _, cx| {
1699 multi_workspace
1700 .workspace()
1701 .read(cx)
1702 .active_modal::<RecentProjects>(cx)
1703 .unwrap()
1704 .read(cx)
1705 .picker
1706 .clone()
1707 })
1708 .unwrap()
1709 }
1710
1711 #[gpui::test]
1712 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
1713 let app_state = init_test(cx);
1714
1715 app_state
1716 .fs
1717 .as_fake()
1718 .insert_tree(
1719 path!("/project"),
1720 json!({
1721 ".devcontainer": {
1722 "devcontainer.json": "{}"
1723 },
1724 "src": {
1725 "main.rs": "fn main() {}"
1726 }
1727 }),
1728 )
1729 .await;
1730
1731 cx.update(|cx| {
1732 open_paths(
1733 &[PathBuf::from(path!("/project"))],
1734 app_state,
1735 workspace::OpenOptions::default(),
1736 cx,
1737 )
1738 })
1739 .await
1740 .unwrap();
1741
1742 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1743 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1744
1745 cx.run_until_parked();
1746
1747 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
1748 // -> Workspace::update -> toggle_modal -> new_dev_container.
1749 // Before the fix, this panicked with "cannot read workspace::Workspace while
1750 // it is already being updated" because new_dev_container and open_dev_container
1751 // tried to read the Workspace entity through a WeakEntity handle while it was
1752 // already leased by the outer update.
1753 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1754
1755 multi_workspace
1756 .update(cx, |multi_workspace, _, cx| {
1757 let modal = multi_workspace
1758 .workspace()
1759 .read(cx)
1760 .active_modal::<RemoteServerProjects>(cx);
1761 assert!(
1762 modal.is_some(),
1763 "Dev container modal should be open after dispatching OpenDevContainer"
1764 );
1765 })
1766 .unwrap();
1767 }
1768
1769 #[gpui::test]
1770 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
1771 let app_state = init_test(cx);
1772
1773 app_state
1774 .fs
1775 .as_fake()
1776 .insert_tree(
1777 path!("/project"),
1778 json!({
1779 ".devcontainer": {
1780 "rust": {
1781 "devcontainer.json": "{}"
1782 },
1783 "python": {
1784 "devcontainer.json": "{}"
1785 }
1786 },
1787 "src": {
1788 "main.rs": "fn main() {}"
1789 }
1790 }),
1791 )
1792 .await;
1793
1794 cx.update(|cx| {
1795 open_paths(
1796 &[PathBuf::from(path!("/project"))],
1797 app_state,
1798 workspace::OpenOptions::default(),
1799 cx,
1800 )
1801 })
1802 .await
1803 .unwrap();
1804
1805 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1806 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1807
1808 cx.run_until_parked();
1809
1810 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1811
1812 multi_workspace
1813 .update(cx, |multi_workspace, _, cx| {
1814 let modal = multi_workspace
1815 .workspace()
1816 .read(cx)
1817 .active_modal::<RemoteServerProjects>(cx);
1818 assert!(
1819 modal.is_some(),
1820 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
1821 );
1822 })
1823 .unwrap();
1824 }
1825
1826 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1827 cx.update(|cx| {
1828 let state = AppState::test(cx);
1829 crate::init(cx);
1830 editor::init(cx);
1831 state
1832 })
1833 }
1834}