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