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