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