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