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(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
754 matches!(
755 self.filtered_entries.get(ix),
756 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_))
757 )
758 }
759
760 fn update_matches(
761 &mut self,
762 query: String,
763 _: &mut Window,
764 cx: &mut Context<Picker<Self>>,
765 ) -> gpui::Task<()> {
766 let query = query.trim_start();
767 let smart_case = query.chars().any(|c| c.is_uppercase());
768 let is_empty_query = query.is_empty();
769
770 let folder_matches = if self.open_folders.is_empty() {
771 Vec::new()
772 } else {
773 let candidates: Vec<_> = self
774 .open_folders
775 .iter()
776 .enumerate()
777 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
778 .collect();
779
780 smol::block_on(fuzzy::match_strings(
781 &candidates,
782 query,
783 smart_case,
784 true,
785 100,
786 &Default::default(),
787 cx.background_executor().clone(),
788 ))
789 };
790
791 let recent_candidates: Vec<_> = self
792 .workspaces
793 .iter()
794 .enumerate()
795 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
796 .map(|(id, (_, _, paths, _))| {
797 let combined_string = paths
798 .ordered_paths()
799 .map(|path| path.compact().to_string_lossy().into_owned())
800 .collect::<Vec<_>>()
801 .join("");
802 StringMatchCandidate::new(id, &combined_string)
803 })
804 .collect();
805
806 let mut recent_matches = smol::block_on(fuzzy::match_strings(
807 &recent_candidates,
808 query,
809 smart_case,
810 true,
811 100,
812 &Default::default(),
813 cx.background_executor().clone(),
814 ));
815 recent_matches.sort_unstable_by(|a, b| {
816 b.score
817 .partial_cmp(&a.score)
818 .unwrap_or(std::cmp::Ordering::Equal)
819 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
820 });
821
822 let mut entries = Vec::new();
823
824 if !self.open_folders.is_empty() {
825 let matched_folders: Vec<_> = if is_empty_query {
826 (0..self.open_folders.len())
827 .map(|i| (i, Vec::new()))
828 .collect()
829 } else {
830 folder_matches
831 .iter()
832 .map(|m| (m.candidate_id, m.positions.clone()))
833 .collect()
834 };
835
836 for (index, positions) in matched_folders {
837 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
838 }
839 }
840
841 let has_recent_to_show = if is_empty_query {
842 !recent_candidates.is_empty()
843 } else {
844 !recent_matches.is_empty()
845 };
846
847 if has_recent_to_show {
848 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
849
850 if is_empty_query {
851 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
852 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
853 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
854 candidate_id: id,
855 score: 0.0,
856 positions: Vec::new(),
857 string: String::new(),
858 }));
859 }
860 }
861 } else {
862 for m in recent_matches {
863 entries.push(ProjectPickerEntry::RecentProject(m));
864 }
865 }
866 }
867
868 self.filtered_entries = entries;
869
870 if self.reset_selected_match_index {
871 self.selected_index = self
872 .filtered_entries
873 .iter()
874 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
875 .unwrap_or(0);
876 }
877 self.reset_selected_match_index = true;
878 Task::ready(())
879 }
880
881 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
882 match self.filtered_entries.get(self.selected_index) {
883 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
884 let Some(folder) = self.open_folders.get(*index) else {
885 return;
886 };
887 let worktree_id = folder.worktree_id;
888 if let Some(workspace) = self.workspace.upgrade() {
889 workspace.update(cx, |workspace, cx| {
890 workspace.set_active_worktree_override(Some(worktree_id), cx);
891 });
892 }
893 cx.emit(DismissEvent);
894 }
895 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
896 let Some(workspace) = self.workspace.upgrade() else {
897 return;
898 };
899 let Some((
900 candidate_workspace_id,
901 candidate_workspace_location,
902 candidate_workspace_paths,
903 _,
904 )) = self.workspaces.get(selected_match.candidate_id)
905 else {
906 return;
907 };
908
909 let replace_current_window = self.create_new_window == secondary;
910 let candidate_workspace_id = *candidate_workspace_id;
911 let candidate_workspace_location = candidate_workspace_location.clone();
912 let candidate_workspace_paths = candidate_workspace_paths.clone();
913
914 workspace.update(cx, |workspace, cx| {
915 if workspace.database_id() == Some(candidate_workspace_id) {
916 return;
917 }
918 match candidate_workspace_location {
919 SerializedWorkspaceLocation::Local => {
920 let paths = candidate_workspace_paths.paths().to_vec();
921 if replace_current_window {
922 if let Some(handle) =
923 window.window_handle().downcast::<MultiWorkspace>()
924 {
925 cx.defer(move |cx| {
926 if let Some(task) = handle
927 .update(cx, |multi_workspace, window, cx| {
928 multi_workspace.open_project(paths, window, cx)
929 })
930 .log_err()
931 {
932 task.detach_and_log_err(cx);
933 }
934 });
935 }
936 return;
937 } else {
938 workspace.open_workspace_for_paths(false, paths, window, cx)
939 }
940 }
941 SerializedWorkspaceLocation::Remote(mut connection) => {
942 let app_state = workspace.app_state().clone();
943 let replace_window = if replace_current_window {
944 window.window_handle().downcast::<MultiWorkspace>()
945 } else {
946 None
947 };
948 let open_options = OpenOptions {
949 replace_window,
950 ..Default::default()
951 };
952 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
953 RemoteSettings::get_global(cx)
954 .fill_connection_options_from_settings(connection);
955 };
956 let paths = candidate_workspace_paths.paths().to_vec();
957 cx.spawn_in(window, async move |_, cx| {
958 open_remote_project(
959 connection.clone(),
960 paths,
961 app_state,
962 open_options,
963 cx,
964 )
965 .await
966 })
967 }
968 }
969 .detach_and_prompt_err(
970 "Failed to open project",
971 window,
972 cx,
973 |_, _, _| None,
974 );
975 });
976 cx.emit(DismissEvent);
977 }
978 _ => {}
979 }
980 }
981
982 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
983
984 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
985 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
986 "Recently opened projects will show up here".into()
987 } else {
988 "No matches".into()
989 };
990 Some(text)
991 }
992
993 fn render_match(
994 &self,
995 ix: usize,
996 selected: bool,
997 window: &mut Window,
998 cx: &mut Context<Picker<Self>>,
999 ) -> Option<Self::ListItem> {
1000 match self.filtered_entries.get(ix)? {
1001 ProjectPickerEntry::Header(title) => Some(
1002 v_flex()
1003 .w_full()
1004 .gap_1()
1005 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1006 .child(ListSubHeader::new(title.clone()).inset(true))
1007 .into_any_element(),
1008 ),
1009 ProjectPickerEntry::OpenFolder { index, positions } => {
1010 let folder = self.open_folders.get(*index)?;
1011 let name = folder.name.clone();
1012 let path = folder.path.compact();
1013 let branch = folder.branch.clone();
1014 let is_active = folder.is_active;
1015 let worktree_id = folder.worktree_id;
1016 let positions = positions.clone();
1017 let show_path = self.style == ProjectPickerStyle::Modal;
1018
1019 let secondary_actions = h_flex()
1020 .gap_1()
1021 .child(
1022 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1023 .icon_size(IconSize::Small)
1024 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1025 .on_click(cx.listener(move |picker, _, window, cx| {
1026 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1027 return;
1028 };
1029 workspace.update(cx, |workspace, cx| {
1030 let project = workspace.project().clone();
1031 project.update(cx, |project, cx| {
1032 project.remove_worktree(worktree_id, cx);
1033 });
1034 });
1035 picker.delegate.open_folders =
1036 get_open_folders(workspace.read(cx), cx);
1037 let query = picker.query(cx);
1038 picker.update_matches(query, window, cx);
1039 })),
1040 )
1041 .into_any_element();
1042
1043 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1044
1045 Some(
1046 ListItem::new(ix)
1047 .toggle_state(selected)
1048 .inset(true)
1049 .spacing(ListItemSpacing::Sparse)
1050 .child(
1051 h_flex()
1052 .id("open_folder_item")
1053 .gap_3()
1054 .flex_grow()
1055 .when(self.has_any_non_local_projects, |this| {
1056 this.child(Icon::new(icon).color(Color::Muted))
1057 })
1058 .child(
1059 v_flex()
1060 .child(
1061 h_flex()
1062 .gap_1()
1063 .child({
1064 let highlighted = HighlightedMatch {
1065 text: name.to_string(),
1066 highlight_positions: positions,
1067 color: Color::Default,
1068 };
1069 highlighted.render(window, cx)
1070 })
1071 .when_some(branch, |this, branch| {
1072 this.child(
1073 Label::new(branch).color(Color::Muted),
1074 )
1075 })
1076 .when(is_active, |this| {
1077 this.child(
1078 Icon::new(IconName::Check)
1079 .size(IconSize::Small)
1080 .color(Color::Accent),
1081 )
1082 }),
1083 )
1084 .when(show_path, |this| {
1085 this.child(
1086 Label::new(path.to_string_lossy().to_string())
1087 .size(LabelSize::Small)
1088 .color(Color::Muted),
1089 )
1090 }),
1091 )
1092 .when(!show_path, |this| {
1093 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1094 }),
1095 )
1096 .map(|el| {
1097 if self.selected_index == ix {
1098 el.end_slot(secondary_actions)
1099 } else {
1100 el.end_hover_slot(secondary_actions)
1101 }
1102 })
1103 .into_any_element(),
1104 )
1105 }
1106 ProjectPickerEntry::RecentProject(hit) => {
1107 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1108 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1109 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1110 let paths_to_add = paths.paths().to_vec();
1111 let tooltip_path: SharedString = paths
1112 .ordered_paths()
1113 .map(|p| p.compact().to_string_lossy().to_string())
1114 .collect::<Vec<_>>()
1115 .join("\n")
1116 .into();
1117
1118 let mut path_start_offset = 0;
1119 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1120 .ordered_paths()
1121 .map(|p| p.compact())
1122 .map(|path| {
1123 let highlighted_text =
1124 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1125 path_start_offset += highlighted_text.1.text.len();
1126 highlighted_text
1127 })
1128 .unzip();
1129
1130 let prefix = match &location {
1131 SerializedWorkspaceLocation::Remote(options) => {
1132 Some(SharedString::from(options.display_name()))
1133 }
1134 _ => None,
1135 };
1136
1137 let highlighted_match = HighlightedMatchWithPaths {
1138 prefix,
1139 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1140 paths,
1141 };
1142
1143 let focus_handle = self.focus_handle.clone();
1144
1145 let secondary_actions = h_flex()
1146 .gap_px()
1147 .when(is_local, |this| {
1148 this.child(
1149 IconButton::new("add_to_workspace", IconName::Plus)
1150 .icon_size(IconSize::Small)
1151 .tooltip(Tooltip::text("Add Project to Workspace"))
1152 .on_click({
1153 let paths_to_add = paths_to_add.clone();
1154 cx.listener(move |picker, _event, window, cx| {
1155 cx.stop_propagation();
1156 window.prevent_default();
1157 picker.delegate.add_project_to_workspace(
1158 paths_to_add.clone(),
1159 window,
1160 cx,
1161 );
1162 })
1163 }),
1164 )
1165 })
1166 .when(popover_style, |this| {
1167 this.child(
1168 IconButton::new("open_new_window", IconName::ArrowUpRight)
1169 .icon_size(IconSize::XSmall)
1170 .tooltip({
1171 move |_, cx| {
1172 Tooltip::for_action_in(
1173 "Open Project in New Window",
1174 &menu::SecondaryConfirm,
1175 &focus_handle,
1176 cx,
1177 )
1178 }
1179 })
1180 .on_click(cx.listener(move |this, _event, window, cx| {
1181 cx.stop_propagation();
1182 window.prevent_default();
1183 this.delegate.set_selected_index(ix, window, cx);
1184 this.delegate.confirm(true, window, cx);
1185 })),
1186 )
1187 })
1188 .child(
1189 IconButton::new("delete", IconName::Close)
1190 .icon_size(IconSize::Small)
1191 .tooltip(Tooltip::text("Delete from Recent Projects"))
1192 .on_click(cx.listener(move |this, _event, window, cx| {
1193 cx.stop_propagation();
1194 window.prevent_default();
1195 this.delegate.delete_recent_project(ix, window, cx)
1196 })),
1197 )
1198 .into_any_element();
1199
1200 let icon = icon_for_remote_connection(match location {
1201 SerializedWorkspaceLocation::Local => None,
1202 SerializedWorkspaceLocation::Remote(options) => Some(options),
1203 });
1204
1205 Some(
1206 ListItem::new(ix)
1207 .toggle_state(selected)
1208 .inset(true)
1209 .spacing(ListItemSpacing::Sparse)
1210 .child(
1211 h_flex()
1212 .id("project_info_container")
1213 .gap_3()
1214 .flex_grow()
1215 .when(self.has_any_non_local_projects, |this| {
1216 this.child(Icon::new(icon).color(Color::Muted))
1217 })
1218 .child({
1219 let mut highlighted = highlighted_match;
1220 if !self.render_paths {
1221 highlighted.paths.clear();
1222 }
1223 highlighted.render(window, cx)
1224 })
1225 .tooltip(Tooltip::text(tooltip_path)),
1226 )
1227 .map(|el| {
1228 if self.selected_index == ix {
1229 el.end_slot(secondary_actions)
1230 } else {
1231 el.end_hover_slot(secondary_actions)
1232 }
1233 })
1234 .into_any_element(),
1235 )
1236 }
1237 }
1238 }
1239
1240 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1241 let focus_handle = self.focus_handle.clone();
1242 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1243 let open_folder_section = matches!(
1244 self.filtered_entries.get(self.selected_index)?,
1245 ProjectPickerEntry::OpenFolder { .. }
1246 );
1247
1248 if popover_style {
1249 return Some(
1250 v_flex()
1251 .flex_1()
1252 .p_1p5()
1253 .gap_1()
1254 .border_t_1()
1255 .border_color(cx.theme().colors().border_variant)
1256 .child({
1257 let open_action = workspace::Open {
1258 create_new_window: self.create_new_window,
1259 };
1260 Button::new("open_local_folder", "Open Local Project")
1261 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1262 .on_click(move |_, window, cx| {
1263 window.dispatch_action(open_action.boxed_clone(), cx)
1264 })
1265 })
1266 .child(
1267 Button::new("open_remote_folder", "Open Remote Project")
1268 .key_binding(KeyBinding::for_action(
1269 &OpenRemote {
1270 from_existing_connection: false,
1271 create_new_window: false,
1272 },
1273 cx,
1274 ))
1275 .on_click(|_, window, cx| {
1276 window.dispatch_action(
1277 OpenRemote {
1278 from_existing_connection: false,
1279 create_new_window: false,
1280 }
1281 .boxed_clone(),
1282 cx,
1283 )
1284 }),
1285 )
1286 .into_any(),
1287 );
1288 }
1289
1290 Some(
1291 h_flex()
1292 .flex_1()
1293 .p_1p5()
1294 .gap_1()
1295 .justify_end()
1296 .border_t_1()
1297 .border_color(cx.theme().colors().border_variant)
1298 .map(|this| {
1299 if open_folder_section {
1300 this.child(
1301 Button::new("activate", "Activate")
1302 .key_binding(KeyBinding::for_action_in(
1303 &menu::Confirm,
1304 &focus_handle,
1305 cx,
1306 ))
1307 .on_click(|_, window, cx| {
1308 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1309 }),
1310 )
1311 } else {
1312 this.child(
1313 Button::new("open_new_window", "New Window")
1314 .key_binding(KeyBinding::for_action_in(
1315 &menu::SecondaryConfirm,
1316 &focus_handle,
1317 cx,
1318 ))
1319 .on_click(|_, window, cx| {
1320 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1321 }),
1322 )
1323 .child(
1324 Button::new("open_here", "Open")
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 }
1335 })
1336 .child(Divider::vertical())
1337 .child(
1338 PopoverMenu::new("actions-menu-popover")
1339 .with_handle(self.actions_menu_handle.clone())
1340 .anchor(gpui::Corner::BottomRight)
1341 .offset(gpui::Point {
1342 x: px(0.0),
1343 y: px(-2.0),
1344 })
1345 .trigger(
1346 Button::new("actions-trigger", "Actions…")
1347 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1348 .key_binding(KeyBinding::for_action_in(
1349 &ToggleActionsMenu,
1350 &focus_handle,
1351 cx,
1352 )),
1353 )
1354 .menu({
1355 let focus_handle = focus_handle.clone();
1356 let create_new_window = self.create_new_window;
1357
1358 move |window, cx| {
1359 Some(ContextMenu::build(window, cx, {
1360 let focus_handle = focus_handle.clone();
1361 move |menu, _, _| {
1362 menu.context(focus_handle)
1363 .action(
1364 "Open Local Project",
1365 workspace::Open { create_new_window }.boxed_clone(),
1366 )
1367 .action(
1368 "Open Remote Project",
1369 OpenRemote {
1370 from_existing_connection: false,
1371 create_new_window: false,
1372 }
1373 .boxed_clone(),
1374 )
1375 }
1376 }))
1377 }
1378 }),
1379 )
1380 .into_any(),
1381 )
1382 }
1383}
1384
1385fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1386 match options {
1387 None => IconName::Screen,
1388 Some(options) => match options {
1389 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1390 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1391 RemoteConnectionOptions::Docker(_) => IconName::Box,
1392 #[cfg(any(test, feature = "test-support"))]
1393 RemoteConnectionOptions::Mock(_) => IconName::Server,
1394 },
1395 }
1396}
1397
1398// Compute the highlighted text for the name and path
1399fn highlights_for_path(
1400 path: &Path,
1401 match_positions: &Vec<usize>,
1402 path_start_offset: usize,
1403) -> (Option<HighlightedMatch>, HighlightedMatch) {
1404 let path_string = path.to_string_lossy();
1405 let path_text = path_string.to_string();
1406 let path_byte_len = path_text.len();
1407 // Get the subset of match highlight positions that line up with the given path.
1408 // Also adjusts them to start at the path start
1409 let path_positions = match_positions
1410 .iter()
1411 .copied()
1412 .skip_while(|position| *position < path_start_offset)
1413 .take_while(|position| *position < path_start_offset + path_byte_len)
1414 .map(|position| position - path_start_offset)
1415 .collect::<Vec<_>>();
1416
1417 // Again subset the highlight positions to just those that line up with the file_name
1418 // again adjusted to the start of the file_name
1419 let file_name_text_and_positions = path.file_name().map(|file_name| {
1420 let file_name_text = file_name.to_string_lossy().into_owned();
1421 let file_name_start_byte = path_byte_len - file_name_text.len();
1422 let highlight_positions = path_positions
1423 .iter()
1424 .copied()
1425 .skip_while(|position| *position < file_name_start_byte)
1426 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1427 .map(|position| position - file_name_start_byte)
1428 .collect::<Vec<_>>();
1429 HighlightedMatch {
1430 text: file_name_text,
1431 highlight_positions,
1432 color: Color::Default,
1433 }
1434 });
1435
1436 (
1437 file_name_text_and_positions,
1438 HighlightedMatch {
1439 text: path_text,
1440 highlight_positions: path_positions,
1441 color: Color::Default,
1442 },
1443 )
1444}
1445impl RecentProjectsDelegate {
1446 fn add_project_to_workspace(
1447 &mut self,
1448 paths: Vec<PathBuf>,
1449 window: &mut Window,
1450 cx: &mut Context<Picker<Self>>,
1451 ) {
1452 let Some(workspace) = self.workspace.upgrade() else {
1453 return;
1454 };
1455 let open_paths_task = workspace.update(cx, |workspace, cx| {
1456 workspace.open_paths(
1457 paths,
1458 OpenOptions {
1459 visible: Some(OpenVisible::All),
1460 ..Default::default()
1461 },
1462 None,
1463 window,
1464 cx,
1465 )
1466 });
1467 cx.spawn_in(window, async move |picker, cx| {
1468 let _result = open_paths_task.await;
1469 picker
1470 .update_in(cx, |picker, window, cx| {
1471 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1472 return;
1473 };
1474 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1475 let query = picker.query(cx);
1476 picker.update_matches(query, window, cx);
1477 })
1478 .ok();
1479 })
1480 .detach();
1481 }
1482
1483 fn delete_recent_project(
1484 &self,
1485 ix: usize,
1486 window: &mut Window,
1487 cx: &mut Context<Picker<Self>>,
1488 ) {
1489 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1490 self.filtered_entries.get(ix)
1491 {
1492 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1493 let workspace_id = *workspace_id;
1494 let fs = self
1495 .workspace
1496 .upgrade()
1497 .map(|ws| ws.read(cx).app_state().fs.clone());
1498 cx.spawn_in(window, async move |this, cx| {
1499 WORKSPACE_DB
1500 .delete_workspace_by_id(workspace_id)
1501 .await
1502 .log_err();
1503 let Some(fs) = fs else { return };
1504 let workspaces = WORKSPACE_DB
1505 .recent_workspaces_on_disk(fs.as_ref())
1506 .await
1507 .unwrap_or_default();
1508 this.update_in(cx, move |picker, window, cx| {
1509 picker.delegate.set_workspaces(workspaces);
1510 picker
1511 .delegate
1512 .set_selected_index(ix.saturating_sub(1), window, cx);
1513 picker.delegate.reset_selected_match_index = false;
1514 picker.update_matches(picker.query(cx), window, cx);
1515 // After deleting a project, we want to update the history manager to reflect the change.
1516 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1517 if let Some(history_manager) = HistoryManager::global(cx) {
1518 history_manager
1519 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1520 }
1521 })
1522 .ok();
1523 })
1524 .detach();
1525 }
1526 }
1527
1528 fn is_current_workspace(
1529 &self,
1530 workspace_id: WorkspaceId,
1531 cx: &mut Context<Picker<Self>>,
1532 ) -> bool {
1533 if let Some(workspace) = self.workspace.upgrade() {
1534 let workspace = workspace.read(cx);
1535 if Some(workspace_id) == workspace.database_id() {
1536 return true;
1537 }
1538 }
1539
1540 false
1541 }
1542
1543 fn is_open_folder(&self, paths: &PathList) -> bool {
1544 if self.open_folders.is_empty() {
1545 return false;
1546 }
1547
1548 for workspace_path in paths.paths() {
1549 for open_folder in &self.open_folders {
1550 if workspace_path == &open_folder.path {
1551 return true;
1552 }
1553 }
1554 }
1555
1556 false
1557 }
1558
1559 fn is_valid_recent_candidate(
1560 &self,
1561 workspace_id: WorkspaceId,
1562 paths: &PathList,
1563 cx: &mut Context<Picker<Self>>,
1564 ) -> bool {
1565 !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
1566 }
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571 use std::path::PathBuf;
1572
1573 use editor::Editor;
1574 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1575
1576 use serde_json::json;
1577 use settings::SettingsStore;
1578 use util::path;
1579 use workspace::{AppState, open_paths};
1580
1581 use super::*;
1582
1583 #[gpui::test]
1584 async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1585 let app_state = init_test(cx);
1586
1587 cx.update(|cx| {
1588 SettingsStore::update_global(cx, |store, cx| {
1589 store.update_user_settings(cx, |settings| {
1590 settings
1591 .session
1592 .get_or_insert_default()
1593 .restore_unsaved_buffers = Some(false)
1594 });
1595 });
1596 });
1597
1598 app_state
1599 .fs
1600 .as_fake()
1601 .insert_tree(
1602 path!("/dir"),
1603 json!({
1604 "main.ts": "a"
1605 }),
1606 )
1607 .await;
1608 app_state
1609 .fs
1610 .as_fake()
1611 .insert_tree(path!("/test/path"), json!({}))
1612 .await;
1613 cx.update(|cx| {
1614 open_paths(
1615 &[PathBuf::from(path!("/dir/main.ts"))],
1616 app_state,
1617 workspace::OpenOptions::default(),
1618 cx,
1619 )
1620 })
1621 .await
1622 .unwrap();
1623 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1624
1625 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1626 multi_workspace
1627 .update(cx, |multi_workspace, _, cx| {
1628 assert!(!multi_workspace.workspace().read(cx).is_edited())
1629 })
1630 .unwrap();
1631
1632 let editor = multi_workspace
1633 .read_with(cx, |multi_workspace, cx| {
1634 multi_workspace
1635 .workspace()
1636 .read(cx)
1637 .active_item(cx)
1638 .unwrap()
1639 .downcast::<Editor>()
1640 .unwrap()
1641 })
1642 .unwrap();
1643 multi_workspace
1644 .update(cx, |_, window, cx| {
1645 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1646 })
1647 .unwrap();
1648 multi_workspace
1649 .update(cx, |multi_workspace, _, cx| {
1650 assert!(
1651 multi_workspace.workspace().read(cx).is_edited(),
1652 "After inserting more text into the editor without saving, we should have a dirty project"
1653 )
1654 })
1655 .unwrap();
1656
1657 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1658 multi_workspace
1659 .update(cx, |_, _, cx| {
1660 recent_projects_picker.update(cx, |picker, cx| {
1661 assert_eq!(picker.query(cx), "");
1662 let delegate = &mut picker.delegate;
1663 delegate.set_workspaces(vec![(
1664 WorkspaceId::default(),
1665 SerializedWorkspaceLocation::Local,
1666 PathList::new(&[path!("/test/path")]),
1667 Utc::now(),
1668 )]);
1669 delegate.filtered_entries =
1670 vec![ProjectPickerEntry::RecentProject(StringMatch {
1671 candidate_id: 0,
1672 score: 1.0,
1673 positions: Vec::new(),
1674 string: "fake candidate".to_string(),
1675 })];
1676 });
1677 })
1678 .unwrap();
1679
1680 assert!(
1681 !cx.has_pending_prompt(),
1682 "Should have no pending prompt on dirty project before opening the new recent project"
1683 );
1684 let dirty_workspace = multi_workspace
1685 .read_with(cx, |multi_workspace, _cx| {
1686 multi_workspace.workspace().clone()
1687 })
1688 .unwrap();
1689
1690 cx.dispatch_action(*multi_workspace, menu::Confirm);
1691 cx.run_until_parked();
1692
1693 multi_workspace
1694 .update(cx, |multi_workspace, _, cx| {
1695 assert!(
1696 multi_workspace
1697 .workspace()
1698 .read(cx)
1699 .active_modal::<RecentProjects>(cx)
1700 .is_none(),
1701 "Should remove the modal after selecting new recent project"
1702 );
1703
1704 assert!(
1705 multi_workspace.workspaces().len() >= 2,
1706 "Should have at least 2 workspaces: the dirty one and the newly opened one"
1707 );
1708
1709 assert!(
1710 multi_workspace.workspaces().contains(&dirty_workspace),
1711 "The original dirty workspace should still be present"
1712 );
1713
1714 assert!(
1715 dirty_workspace.read(cx).is_edited(),
1716 "The original workspace should still be dirty"
1717 );
1718 })
1719 .unwrap();
1720
1721 assert!(
1722 !cx.has_pending_prompt(),
1723 "No save prompt in multi-workspace mode — dirty workspace survives in background"
1724 );
1725 }
1726
1727 fn open_recent_projects(
1728 multi_workspace: &WindowHandle<MultiWorkspace>,
1729 cx: &mut TestAppContext,
1730 ) -> Entity<Picker<RecentProjectsDelegate>> {
1731 cx.dispatch_action(
1732 (*multi_workspace).into(),
1733 OpenRecent {
1734 create_new_window: false,
1735 },
1736 );
1737 multi_workspace
1738 .update(cx, |multi_workspace, _, cx| {
1739 multi_workspace
1740 .workspace()
1741 .read(cx)
1742 .active_modal::<RecentProjects>(cx)
1743 .unwrap()
1744 .read(cx)
1745 .picker
1746 .clone()
1747 })
1748 .unwrap()
1749 }
1750
1751 #[gpui::test]
1752 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
1753 let app_state = init_test(cx);
1754
1755 app_state
1756 .fs
1757 .as_fake()
1758 .insert_tree(
1759 path!("/project"),
1760 json!({
1761 ".devcontainer": {
1762 "devcontainer.json": "{}"
1763 },
1764 "src": {
1765 "main.rs": "fn main() {}"
1766 }
1767 }),
1768 )
1769 .await;
1770
1771 cx.update(|cx| {
1772 open_paths(
1773 &[PathBuf::from(path!("/project"))],
1774 app_state,
1775 workspace::OpenOptions::default(),
1776 cx,
1777 )
1778 })
1779 .await
1780 .unwrap();
1781
1782 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1783 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1784
1785 cx.run_until_parked();
1786
1787 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
1788 // -> Workspace::update -> toggle_modal -> new_dev_container.
1789 // Before the fix, this panicked with "cannot read workspace::Workspace while
1790 // it is already being updated" because new_dev_container and open_dev_container
1791 // tried to read the Workspace entity through a WeakEntity handle while it was
1792 // already leased by the outer update.
1793 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1794
1795 multi_workspace
1796 .update(cx, |multi_workspace, _, cx| {
1797 let modal = multi_workspace
1798 .workspace()
1799 .read(cx)
1800 .active_modal::<RemoteServerProjects>(cx);
1801 assert!(
1802 modal.is_some(),
1803 "Dev container modal should be open after dispatching OpenDevContainer"
1804 );
1805 })
1806 .unwrap();
1807 }
1808
1809 #[gpui::test]
1810 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
1811 let app_state = init_test(cx);
1812
1813 app_state
1814 .fs
1815 .as_fake()
1816 .insert_tree(
1817 path!("/project"),
1818 json!({
1819 ".devcontainer": {
1820 "rust": {
1821 "devcontainer.json": "{}"
1822 },
1823 "python": {
1824 "devcontainer.json": "{}"
1825 }
1826 },
1827 "src": {
1828 "main.rs": "fn main() {}"
1829 }
1830 }),
1831 )
1832 .await;
1833
1834 cx.update(|cx| {
1835 open_paths(
1836 &[PathBuf::from(path!("/project"))],
1837 app_state,
1838 workspace::OpenOptions::default(),
1839 cx,
1840 )
1841 })
1842 .await
1843 .unwrap();
1844
1845 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1846 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1847
1848 cx.run_until_parked();
1849
1850 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1851
1852 multi_workspace
1853 .update(cx, |multi_workspace, _, cx| {
1854 let modal = multi_workspace
1855 .workspace()
1856 .read(cx)
1857 .active_modal::<RemoteServerProjects>(cx);
1858 assert!(
1859 modal.is_some(),
1860 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
1861 );
1862 })
1863 .unwrap();
1864 }
1865
1866 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1867 cx.update(|cx| {
1868 let state = AppState::test(cx);
1869 crate::init(cx);
1870 editor::init(cx);
1871 state
1872 })
1873 }
1874}