1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5mod ssh_config;
6
7use std::{
8 collections::HashSet,
9 path::{Path, PathBuf},
10 sync::Arc,
11};
12
13use chrono::{DateTime, Utc};
14
15use fs::Fs;
16
17#[cfg(target_os = "windows")]
18mod wsl_picker;
19
20use remote::RemoteConnectionOptions;
21pub use remote_connection::{RemoteConnectionModal, connect};
22pub use remote_connections::{navigate_to_positions, open_remote_project};
23
24use disconnected_overlay::DisconnectedOverlay;
25use fuzzy::{StringMatch, StringMatchCandidate};
26use gpui::{
27 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
28 Subscription, Task, WeakEntity, Window, actions, px,
29};
30
31use picker::{
32 Picker, PickerDelegate,
33 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
34};
35use project::{Worktree, git_store::Repository};
36pub use remote_connections::RemoteSettings;
37pub use remote_servers::RemoteServerProjects;
38use settings::{Settings, WorktreeId};
39use ui_input::ErasedEditor;
40
41use dev_container::{DevContainerContext, find_devcontainer_configs};
42use ui::{
43 ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
44 PopoverMenuHandle, TintColor, Tooltip, prelude::*,
45};
46use util::{ResultExt, paths::PathExt};
47use workspace::{
48 HistoryManager, ModalView, MultiWorkspace, OpenOptions, OpenVisible, PathList,
49 SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
50 notifications::DetachAndPromptErr, with_active_or_new_workspace,
51};
52use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
53
54actions!(recent_projects, [ToggleActionsMenu]);
55
56#[derive(Clone, Debug)]
57pub struct RecentProjectEntry {
58 pub name: SharedString,
59 pub full_path: SharedString,
60 pub paths: Vec<PathBuf>,
61 pub workspace_id: WorkspaceId,
62 pub timestamp: DateTime<Utc>,
63}
64
65#[derive(Clone, Debug)]
66struct OpenFolderEntry {
67 worktree_id: WorktreeId,
68 name: SharedString,
69 path: PathBuf,
70 branch: Option<SharedString>,
71 is_active: bool,
72}
73
74#[derive(Clone, Debug)]
75enum ProjectPickerEntry {
76 Header(SharedString),
77 OpenFolder { index: usize, positions: Vec<usize> },
78 RecentProject(StringMatch),
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum ProjectPickerStyle {
83 Modal,
84 Popover,
85}
86
87pub async fn get_recent_projects(
88 current_workspace_id: Option<WorkspaceId>,
89 limit: Option<usize>,
90 fs: Arc<dyn fs::Fs>,
91 db: &WorkspaceDb,
92) -> Vec<RecentProjectEntry> {
93 let workspaces = db
94 .recent_workspaces_on_disk(fs.as_ref())
95 .await
96 .unwrap_or_default();
97
98 let entries: Vec<RecentProjectEntry> = workspaces
99 .into_iter()
100 .filter(|(id, _, _, _)| Some(*id) != current_workspace_id)
101 .filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local))
102 .map(|(workspace_id, _, path_list, timestamp)| {
103 let paths: Vec<PathBuf> = path_list.paths().to_vec();
104 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
105
106 let name = if ordered_paths.len() == 1 {
107 ordered_paths[0]
108 .file_name()
109 .map(|n| n.to_string_lossy().to_string())
110 .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
111 } else {
112 ordered_paths
113 .iter()
114 .filter_map(|p| p.file_name())
115 .map(|n| n.to_string_lossy().to_string())
116 .collect::<Vec<_>>()
117 .join(", ")
118 };
119
120 let full_path = ordered_paths
121 .iter()
122 .map(|p| p.to_string_lossy().to_string())
123 .collect::<Vec<_>>()
124 .join("\n");
125
126 RecentProjectEntry {
127 name: SharedString::from(name),
128 full_path: SharedString::from(full_path),
129 paths,
130 workspace_id,
131 timestamp,
132 }
133 })
134 .collect();
135
136 match limit {
137 Some(n) => entries.into_iter().take(n).collect(),
138 None => entries,
139 }
140}
141
142pub async fn delete_recent_project(workspace_id: WorkspaceId, db: &WorkspaceDb) {
143 let _ = db.delete_workspace_by_id(workspace_id).await;
144}
145
146fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
147 let project = workspace.project().read(cx);
148 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
149
150 if visible_worktrees.len() <= 1 {
151 return Vec::new();
152 }
153
154 let active_worktree_id = workspace.active_worktree_override().or_else(|| {
155 if let Some(repo) = project.active_repository(cx) {
156 let repo = repo.read(cx);
157 let repo_path = &repo.work_directory_abs_path;
158 for worktree in project.visible_worktrees(cx) {
159 let worktree_path = worktree.read(cx).abs_path();
160 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
161 return Some(worktree.read(cx).id());
162 }
163 }
164 }
165 project
166 .visible_worktrees(cx)
167 .next()
168 .map(|wt| wt.read(cx).id())
169 });
170
171 let git_store = project.git_store().read(cx);
172 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
173
174 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
175 .into_iter()
176 .map(|worktree| {
177 let worktree_ref = worktree.read(cx);
178 let worktree_id = worktree_ref.id();
179 let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
180 let path = worktree_ref.abs_path().to_path_buf();
181 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
182 let is_active = active_worktree_id == Some(worktree_id);
183 OpenFolderEntry {
184 worktree_id,
185 name,
186 path,
187 branch,
188 is_active,
189 }
190 })
191 .collect();
192
193 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
194 entries
195}
196
197fn get_branch_for_worktree(
198 worktree: &Worktree,
199 repositories: &[Entity<Repository>],
200 cx: &App,
201) -> Option<SharedString> {
202 let worktree_abs_path = worktree.abs_path();
203 for repo in repositories {
204 let repo = repo.read(cx);
205 if repo.work_directory_abs_path == worktree_abs_path
206 || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
207 {
208 if let Some(branch) = &repo.branch {
209 return Some(SharedString::from(branch.name().to_string()));
210 }
211 }
212 }
213 None
214}
215
216pub fn init(cx: &mut App) {
217 #[cfg(target_os = "windows")]
218 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
219 let create_new_window = open_wsl.create_new_window;
220 with_active_or_new_workspace(cx, move |workspace, window, cx| {
221 use gpui::PathPromptOptions;
222 use project::DirectoryLister;
223
224 let paths = workspace.prompt_for_open_path(
225 PathPromptOptions {
226 files: true,
227 directories: true,
228 multiple: false,
229 prompt: None,
230 },
231 DirectoryLister::Local(
232 workspace.project().clone(),
233 workspace.app_state().fs.clone(),
234 ),
235 window,
236 cx,
237 );
238
239 let app_state = workspace.app_state().clone();
240 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
241
242 cx.spawn_in(window, async move |workspace, cx| {
243 use util::paths::SanitizedPath;
244
245 let Some(paths) = paths.await.log_err().flatten() else {
246 return;
247 };
248
249 let wsl_path = paths
250 .iter()
251 .find_map(util::paths::WslPath::from_path);
252
253 if let Some(util::paths::WslPath { distro, path }) = wsl_path {
254 use remote::WslConnectionOptions;
255
256 let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
257 distro_name: distro.to_string(),
258 user: None,
259 });
260
261 let replace_window = match create_new_window {
262 false => window_handle,
263 true => None,
264 };
265
266 let open_options = workspace::OpenOptions {
267 replace_window,
268 ..Default::default()
269 };
270
271 open_remote_project(connection_options, vec![path.into()], app_state, open_options, cx).await.log_err();
272 return;
273 }
274
275 let paths = paths
276 .into_iter()
277 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
278 .collect::<Vec<_>>();
279
280 if paths.is_empty() {
281 let message = indoc::indoc! { r#"
282 Invalid path specified when trying to open a folder inside WSL.
283
284 Please note that Zed currently does not support opening network share folders inside wsl.
285 "#};
286
287 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
288 return;
289 }
290
291 workspace.update_in(cx, |workspace, window, cx| {
292 workspace.toggle_modal(window, cx, |window, cx| {
293 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
294 });
295 }).log_err();
296 })
297 .detach();
298 });
299 });
300
301 #[cfg(target_os = "windows")]
302 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
303 let create_new_window = open_wsl.create_new_window;
304 with_active_or_new_workspace(cx, move |workspace, window, cx| {
305 let handle = cx.entity().downgrade();
306 let fs = workspace.project().read(cx).fs().clone();
307 workspace.toggle_modal(window, cx, |window, cx| {
308 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
309 });
310 });
311 });
312
313 #[cfg(target_os = "windows")]
314 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
315 let open_wsl = open_wsl.clone();
316 with_active_or_new_workspace(cx, move |workspace, window, cx| {
317 let fs = workspace.project().read(cx).fs().clone();
318 add_wsl_distro(fs, &open_wsl.distro, cx);
319 let open_options = OpenOptions {
320 replace_window: window.window_handle().downcast::<MultiWorkspace>(),
321 ..Default::default()
322 };
323
324 let app_state = workspace.app_state().clone();
325
326 cx.spawn_in(window, async move |_, cx| {
327 open_remote_project(
328 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
329 open_wsl.paths,
330 app_state,
331 open_options,
332 cx,
333 )
334 .await
335 })
336 .detach();
337 });
338 });
339
340 cx.on_action(|open_recent: &OpenRecent, cx| {
341 let create_new_window = open_recent.create_new_window;
342 with_active_or_new_workspace(cx, move |workspace, window, cx| {
343 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
344 let focus_handle = workspace.focus_handle(cx);
345 RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
346 return;
347 };
348
349 recent_projects.update(cx, |recent_projects, cx| {
350 recent_projects
351 .picker
352 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
353 });
354 });
355 });
356 cx.on_action(|open_remote: &OpenRemote, cx| {
357 let from_existing_connection = open_remote.from_existing_connection;
358 let create_new_window = open_remote.create_new_window;
359 with_active_or_new_workspace(cx, move |workspace, window, cx| {
360 if from_existing_connection {
361 cx.propagate();
362 return;
363 }
364 let handle = cx.entity().downgrade();
365 let fs = workspace.project().read(cx).fs().clone();
366 workspace.toggle_modal(window, cx, |window, cx| {
367 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
368 })
369 });
370 });
371
372 cx.observe_new(DisconnectedOverlay::register).detach();
373
374 cx.on_action(|_: &OpenDevContainer, cx| {
375 with_active_or_new_workspace(cx, move |workspace, window, cx| {
376 if !workspace.project().read(cx).is_local() {
377 cx.spawn_in(window, async move |_, cx| {
378 cx.prompt(
379 gpui::PromptLevel::Critical,
380 "Cannot open Dev Container from remote project",
381 None,
382 &["Ok"],
383 )
384 .await
385 .ok();
386 })
387 .detach();
388 return;
389 }
390
391 let fs = workspace.project().read(cx).fs().clone();
392 let configs = find_devcontainer_configs(workspace, cx);
393 let app_state = workspace.app_state().clone();
394 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
395 let handle = cx.entity().downgrade();
396 workspace.toggle_modal(window, cx, |window, cx| {
397 RemoteServerProjects::new_dev_container(
398 fs,
399 configs,
400 app_state,
401 dev_container_context,
402 window,
403 handle,
404 cx,
405 )
406 });
407 });
408 });
409
410 // Subscribe to worktree additions to suggest opening the project in a dev container
411 cx.observe_new(
412 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
413 let Some(window) = window else {
414 return;
415 };
416 cx.subscribe_in(
417 workspace.project(),
418 window,
419 move |_, project, event, window, cx| {
420 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
421 event
422 {
423 dev_container_suggest::suggest_on_worktree_updated(
424 *worktree_id,
425 updated_entries,
426 project,
427 window,
428 cx,
429 );
430 }
431 },
432 )
433 .detach();
434 },
435 )
436 .detach();
437}
438
439#[cfg(target_os = "windows")]
440pub fn add_wsl_distro(
441 fs: Arc<dyn project::Fs>,
442 connection_options: &remote::WslConnectionOptions,
443 cx: &App,
444) {
445 use gpui::ReadGlobal;
446 use settings::SettingsStore;
447
448 let distro_name = connection_options.distro_name.clone();
449 let user = connection_options.user.clone();
450 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
451 let connections = setting
452 .remote
453 .wsl_connections
454 .get_or_insert(Default::default());
455
456 if !connections
457 .iter()
458 .any(|conn| conn.distro_name == distro_name && conn.user == user)
459 {
460 use std::collections::BTreeSet;
461
462 connections.push(settings::WslConnection {
463 distro_name,
464 user,
465 projects: BTreeSet::new(),
466 })
467 }
468 });
469}
470
471pub struct RecentProjects {
472 pub picker: Entity<Picker<RecentProjectsDelegate>>,
473 rem_width: f32,
474 _subscription: Subscription,
475}
476
477impl ModalView for RecentProjects {
478 fn on_before_dismiss(
479 &mut self,
480 window: &mut Window,
481 cx: &mut Context<Self>,
482 ) -> workspace::DismissDecision {
483 let submenu_focused = self.picker.update(cx, |picker, cx| {
484 picker.delegate.actions_menu_handle.is_focused(window, cx)
485 });
486 workspace::DismissDecision::Dismiss(!submenu_focused)
487 }
488}
489
490impl RecentProjects {
491 fn new(
492 delegate: RecentProjectsDelegate,
493 fs: Option<Arc<dyn Fs>>,
494 rem_width: f32,
495 window: &mut Window,
496 cx: &mut Context<Self>,
497 ) -> Self {
498 let picker = cx.new(|cx| {
499 Picker::list(delegate, window, cx)
500 .list_measure_all()
501 .show_scrollbar(true)
502 });
503
504 let picker_focus_handle = picker.focus_handle(cx);
505 picker.update(cx, |picker, _| {
506 picker.delegate.focus_handle = picker_focus_handle;
507 });
508
509 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
510 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
511 // out workspace locations once the future runs to completion.
512 let db = WorkspaceDb::global(cx);
513 cx.spawn_in(window, async move |this, cx| {
514 let Some(fs) = fs else { return };
515 let workspaces = db
516 .recent_workspaces_on_disk(fs.as_ref())
517 .await
518 .log_err()
519 .unwrap_or_default();
520 this.update_in(cx, move |this, window, cx| {
521 this.picker.update(cx, move |picker, cx| {
522 picker.delegate.set_workspaces(workspaces);
523 picker.update_matches(picker.query(cx), window, cx)
524 })
525 })
526 .ok();
527 })
528 .detach();
529 Self {
530 picker,
531 rem_width,
532 _subscription,
533 }
534 }
535
536 pub fn open(
537 workspace: &mut Workspace,
538 create_new_window: bool,
539 window: &mut Window,
540 focus_handle: FocusHandle,
541 cx: &mut Context<Workspace>,
542 ) {
543 let weak = cx.entity().downgrade();
544 let open_folders = get_open_folders(workspace, cx);
545 let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
546 let fs = Some(workspace.app_state().fs.clone());
547 workspace.toggle_modal(window, cx, |window, cx| {
548 let delegate = RecentProjectsDelegate::new(
549 weak,
550 create_new_window,
551 focus_handle,
552 open_folders,
553 HashSet::new(),
554 project_connection_options,
555 ProjectPickerStyle::Modal,
556 );
557
558 Self::new(delegate, fs, 34., window, cx)
559 })
560 }
561
562 pub fn popover(
563 workspace: WeakEntity<Workspace>,
564 excluded_workspace_ids: HashSet<WorkspaceId>,
565 create_new_window: bool,
566 focus_handle: FocusHandle,
567 window: &mut Window,
568 cx: &mut App,
569 ) -> Entity<Self> {
570 let (open_folders, project_connection_options, fs) = workspace
571 .upgrade()
572 .map(|workspace| {
573 let workspace = workspace.read(cx);
574 (
575 get_open_folders(workspace, cx),
576 workspace.project().read(cx).remote_connection_options(cx),
577 Some(workspace.app_state().fs.clone()),
578 )
579 })
580 .unwrap_or_else(|| (Vec::new(), None, None));
581
582 cx.new(|cx| {
583 let delegate = RecentProjectsDelegate::new(
584 workspace,
585 create_new_window,
586 focus_handle,
587 open_folders,
588 excluded_workspace_ids,
589 project_connection_options,
590 ProjectPickerStyle::Popover,
591 );
592 let list = Self::new(delegate, fs, 20., window, cx);
593 list.picker.focus_handle(cx).focus(window, cx);
594 list
595 })
596 }
597
598 fn handle_toggle_open_menu(
599 &mut self,
600 _: &ToggleActionsMenu,
601 window: &mut Window,
602 cx: &mut Context<Self>,
603 ) {
604 self.picker.update(cx, |picker, cx| {
605 let menu_handle = &picker.delegate.actions_menu_handle;
606 if menu_handle.is_deployed() {
607 menu_handle.hide(cx);
608 } else {
609 menu_handle.show(window, cx);
610 }
611 });
612 }
613}
614
615impl EventEmitter<DismissEvent> for RecentProjects {}
616
617impl Focusable for RecentProjects {
618 fn focus_handle(&self, cx: &App) -> FocusHandle {
619 self.picker.focus_handle(cx)
620 }
621}
622
623impl Render for RecentProjects {
624 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
625 v_flex()
626 .key_context("RecentProjects")
627 .on_action(cx.listener(Self::handle_toggle_open_menu))
628 .w(rems(self.rem_width))
629 .child(self.picker.clone())
630 }
631}
632
633pub struct RecentProjectsDelegate {
634 workspace: WeakEntity<Workspace>,
635 open_folders: Vec<OpenFolderEntry>,
636 excluded_workspace_ids: HashSet<WorkspaceId>,
637 workspaces: Vec<(
638 WorkspaceId,
639 SerializedWorkspaceLocation,
640 PathList,
641 DateTime<Utc>,
642 )>,
643 filtered_entries: Vec<ProjectPickerEntry>,
644 selected_index: usize,
645 render_paths: bool,
646 create_new_window: bool,
647 // Flag to reset index when there is a new query vs not reset index when user delete an item
648 reset_selected_match_index: bool,
649 has_any_non_local_projects: bool,
650 project_connection_options: Option<RemoteConnectionOptions>,
651 focus_handle: FocusHandle,
652 style: ProjectPickerStyle,
653 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
654}
655
656impl RecentProjectsDelegate {
657 fn new(
658 workspace: WeakEntity<Workspace>,
659 create_new_window: bool,
660 focus_handle: FocusHandle,
661 open_folders: Vec<OpenFolderEntry>,
662 excluded_workspace_ids: HashSet<WorkspaceId>,
663 project_connection_options: Option<RemoteConnectionOptions>,
664 style: ProjectPickerStyle,
665 ) -> Self {
666 let render_paths = style == ProjectPickerStyle::Modal;
667 Self {
668 workspace,
669 open_folders,
670 excluded_workspace_ids,
671 workspaces: Vec::new(),
672 filtered_entries: Vec::new(),
673 selected_index: 0,
674 create_new_window,
675 render_paths,
676 reset_selected_match_index: true,
677 has_any_non_local_projects: project_connection_options.is_some(),
678 project_connection_options,
679 focus_handle,
680 style,
681 actions_menu_handle: PopoverMenuHandle::default(),
682 }
683 }
684
685 pub fn set_workspaces(
686 &mut self,
687 workspaces: Vec<(
688 WorkspaceId,
689 SerializedWorkspaceLocation,
690 PathList,
691 DateTime<Utc>,
692 )>,
693 ) {
694 self.workspaces = workspaces;
695 let has_non_local_recent = !self
696 .workspaces
697 .iter()
698 .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
699 self.has_any_non_local_projects =
700 self.project_connection_options.is_some() || has_non_local_recent;
701 }
702}
703impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
704impl PickerDelegate for RecentProjectsDelegate {
705 type ListItem = AnyElement;
706
707 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
708 "Search projects…".into()
709 }
710
711 fn render_editor(
712 &self,
713 editor: &Arc<dyn ErasedEditor>,
714 window: &mut Window,
715 cx: &mut Context<Picker<Self>>,
716 ) -> Div {
717 h_flex()
718 .flex_none()
719 .h_9()
720 .px_2p5()
721 .justify_between()
722 .border_b_1()
723 .border_color(cx.theme().colors().border_variant)
724 .child(editor.render(window, cx))
725 }
726
727 fn match_count(&self) -> usize {
728 self.filtered_entries.len()
729 }
730
731 fn selected_index(&self) -> usize {
732 self.selected_index
733 }
734
735 fn set_selected_index(
736 &mut self,
737 ix: usize,
738 _window: &mut Window,
739 _cx: &mut Context<Picker<Self>>,
740 ) {
741 self.selected_index = ix;
742 }
743
744 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> 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 _,
895 )) = self.workspaces.get(selected_match.candidate_id)
896 else {
897 return;
898 };
899
900 let replace_current_window = self.create_new_window == secondary;
901 let candidate_workspace_id = *candidate_workspace_id;
902 let candidate_workspace_location = candidate_workspace_location.clone();
903 let candidate_workspace_paths = candidate_workspace_paths.clone();
904
905 workspace.update(cx, |workspace, cx| {
906 if workspace.database_id() == Some(candidate_workspace_id) {
907 return;
908 }
909 match candidate_workspace_location {
910 SerializedWorkspaceLocation::Local => {
911 let paths = candidate_workspace_paths.paths().to_vec();
912 if replace_current_window {
913 if let Some(handle) =
914 window.window_handle().downcast::<MultiWorkspace>()
915 {
916 cx.defer(move |cx| {
917 if let Some(task) = handle
918 .update(cx, |multi_workspace, window, cx| {
919 multi_workspace.open_project(paths, window, cx)
920 })
921 .log_err()
922 {
923 task.detach_and_log_err(cx);
924 }
925 });
926 }
927 return;
928 } else {
929 workspace
930 .open_workspace_for_paths(false, paths, window, cx)
931 .detach_and_prompt_err(
932 "Failed to open project",
933 window,
934 cx,
935 |_, _, _| None,
936 );
937 }
938 }
939 SerializedWorkspaceLocation::Remote(mut connection) => {
940 let app_state = workspace.app_state().clone();
941 let replace_window = if replace_current_window {
942 window.window_handle().downcast::<MultiWorkspace>()
943 } else {
944 None
945 };
946 let open_options = OpenOptions {
947 replace_window,
948 ..Default::default()
949 };
950 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
951 RemoteSettings::get_global(cx)
952 .fill_connection_options_from_settings(connection);
953 };
954 let paths = candidate_workspace_paths.paths().to_vec();
955 cx.spawn_in(window, async move |_, cx| {
956 open_remote_project(
957 connection.clone(),
958 paths,
959 app_state,
960 open_options,
961 cx,
962 )
963 .await
964 })
965 .detach_and_prompt_err(
966 "Failed to open project",
967 window,
968 cx,
969 |_, _, _| None,
970 );
971 }
972 }
973 });
974 cx.emit(DismissEvent);
975 }
976 _ => {}
977 }
978 }
979
980 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
981
982 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
983 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
984 "Recently opened projects will show up here".into()
985 } else {
986 "No matches".into()
987 };
988 Some(text)
989 }
990
991 fn render_match(
992 &self,
993 ix: usize,
994 selected: bool,
995 window: &mut Window,
996 cx: &mut Context<Picker<Self>>,
997 ) -> Option<Self::ListItem> {
998 match self.filtered_entries.get(ix)? {
999 ProjectPickerEntry::Header(title) => Some(
1000 v_flex()
1001 .w_full()
1002 .gap_1()
1003 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1004 .child(ListSubHeader::new(title.clone()).inset(true))
1005 .into_any_element(),
1006 ),
1007 ProjectPickerEntry::OpenFolder { index, positions } => {
1008 let folder = self.open_folders.get(*index)?;
1009 let name = folder.name.clone();
1010 let path = folder.path.compact();
1011 let branch = folder.branch.clone();
1012 let is_active = folder.is_active;
1013 let worktree_id = folder.worktree_id;
1014 let positions = positions.clone();
1015 let show_path = self.style == ProjectPickerStyle::Modal;
1016
1017 let secondary_actions = h_flex()
1018 .gap_1()
1019 .child(
1020 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1021 .icon_size(IconSize::Small)
1022 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1023 .on_click(cx.listener(move |picker, _, window, cx| {
1024 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1025 return;
1026 };
1027 workspace.update(cx, |workspace, cx| {
1028 let project = workspace.project().clone();
1029 project.update(cx, |project, cx| {
1030 project.remove_worktree(worktree_id, cx);
1031 });
1032 });
1033 picker.delegate.open_folders =
1034 get_open_folders(workspace.read(cx), cx);
1035 let query = picker.query(cx);
1036 picker.update_matches(query, window, cx);
1037 })),
1038 )
1039 .into_any_element();
1040
1041 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1042
1043 Some(
1044 ListItem::new(ix)
1045 .toggle_state(selected)
1046 .inset(true)
1047 .spacing(ListItemSpacing::Sparse)
1048 .child(
1049 h_flex()
1050 .id("open_folder_item")
1051 .gap_3()
1052 .flex_grow()
1053 .when(self.has_any_non_local_projects, |this| {
1054 this.child(Icon::new(icon).color(Color::Muted))
1055 })
1056 .child(
1057 v_flex()
1058 .child(
1059 h_flex()
1060 .gap_1()
1061 .child({
1062 let highlighted = HighlightedMatch {
1063 text: name.to_string(),
1064 highlight_positions: positions,
1065 color: Color::Default,
1066 };
1067 highlighted.render(window, cx)
1068 })
1069 .when_some(branch, |this, branch| {
1070 this.child(
1071 Label::new(branch).color(Color::Muted),
1072 )
1073 })
1074 .when(is_active, |this| {
1075 this.child(
1076 Icon::new(IconName::Check)
1077 .size(IconSize::Small)
1078 .color(Color::Accent),
1079 )
1080 }),
1081 )
1082 .when(show_path, |this| {
1083 this.child(
1084 Label::new(path.to_string_lossy().to_string())
1085 .size(LabelSize::Small)
1086 .color(Color::Muted),
1087 )
1088 }),
1089 )
1090 .when(!show_path, |this| {
1091 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1092 }),
1093 )
1094 .map(|el| {
1095 if self.selected_index == ix {
1096 el.end_slot(secondary_actions)
1097 } else {
1098 el.end_hover_slot(secondary_actions)
1099 }
1100 })
1101 .into_any_element(),
1102 )
1103 }
1104 ProjectPickerEntry::RecentProject(hit) => {
1105 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1106 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1107 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1108 let paths_to_add = paths.paths().to_vec();
1109 let ordered_paths: Vec<_> = paths
1110 .ordered_paths()
1111 .map(|p| p.compact().to_string_lossy().to_string())
1112 .collect();
1113 let tooltip_path: SharedString = match &location {
1114 SerializedWorkspaceLocation::Remote(options) => {
1115 let host = options.display_name();
1116 if ordered_paths.len() == 1 {
1117 format!("{} ({})", ordered_paths[0], host).into()
1118 } else {
1119 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1120 }
1121 }
1122 _ => ordered_paths.join("\n").into(),
1123 };
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::FolderPlus)
1157 .icon_size(IconSize::Small)
1158 .tooltip(Tooltip::text("Add Project to this 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 let db = WorkspaceDb::global(cx);
1506 cx.spawn_in(window, async move |this, cx| {
1507 db.delete_workspace_by_id(workspace_id).await.log_err();
1508 let Some(fs) = fs else { return };
1509 let workspaces = db
1510 .recent_workspaces_on_disk(fs.as_ref())
1511 .await
1512 .unwrap_or_default();
1513 this.update_in(cx, move |picker, window, cx| {
1514 picker.delegate.set_workspaces(workspaces);
1515 picker
1516 .delegate
1517 .set_selected_index(ix.saturating_sub(1), window, cx);
1518 picker.delegate.reset_selected_match_index = false;
1519 picker.update_matches(picker.query(cx), window, cx);
1520 // After deleting a project, we want to update the history manager to reflect the change.
1521 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1522 if let Some(history_manager) = HistoryManager::global(cx) {
1523 history_manager
1524 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1525 }
1526 })
1527 .ok();
1528 })
1529 .detach();
1530 }
1531 }
1532
1533 fn is_current_workspace(
1534 &self,
1535 workspace_id: WorkspaceId,
1536 cx: &mut Context<Picker<Self>>,
1537 ) -> bool {
1538 if self.excluded_workspace_ids.contains(&workspace_id) {
1539 return true;
1540 }
1541
1542 if let Some(workspace) = self.workspace.upgrade() {
1543 let workspace = workspace.read(cx);
1544 if Some(workspace_id) == workspace.database_id() {
1545 return true;
1546 }
1547 }
1548
1549 false
1550 }
1551
1552 fn is_open_folder(&self, paths: &PathList) -> bool {
1553 if self.open_folders.is_empty() {
1554 return false;
1555 }
1556
1557 for workspace_path in paths.paths() {
1558 for open_folder in &self.open_folders {
1559 if workspace_path == &open_folder.path {
1560 return true;
1561 }
1562 }
1563 }
1564
1565 false
1566 }
1567
1568 fn is_valid_recent_candidate(
1569 &self,
1570 workspace_id: WorkspaceId,
1571 paths: &PathList,
1572 cx: &mut Context<Picker<Self>>,
1573 ) -> bool {
1574 !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
1575 }
1576}
1577
1578#[cfg(test)]
1579mod tests {
1580 use std::path::PathBuf;
1581
1582 use editor::Editor;
1583 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1584
1585 use serde_json::json;
1586 use settings::SettingsStore;
1587 use util::path;
1588 use workspace::{AppState, open_paths};
1589
1590 use super::*;
1591
1592 #[gpui::test]
1593 async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1594 let app_state = init_test(cx);
1595
1596 cx.update(|cx| {
1597 SettingsStore::update_global(cx, |store, cx| {
1598 store.update_user_settings(cx, |settings| {
1599 settings
1600 .session
1601 .get_or_insert_default()
1602 .restore_unsaved_buffers = Some(false)
1603 });
1604 });
1605 });
1606
1607 app_state
1608 .fs
1609 .as_fake()
1610 .insert_tree(
1611 path!("/dir"),
1612 json!({
1613 "main.ts": "a"
1614 }),
1615 )
1616 .await;
1617 app_state
1618 .fs
1619 .as_fake()
1620 .insert_tree(path!("/test/path"), json!({}))
1621 .await;
1622 cx.update(|cx| {
1623 open_paths(
1624 &[PathBuf::from(path!("/dir/main.ts"))],
1625 app_state,
1626 workspace::OpenOptions::default(),
1627 cx,
1628 )
1629 })
1630 .await
1631 .unwrap();
1632 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1633
1634 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1635 multi_workspace
1636 .update(cx, |multi_workspace, _, cx| {
1637 assert!(!multi_workspace.workspace().read(cx).is_edited())
1638 })
1639 .unwrap();
1640
1641 let editor = multi_workspace
1642 .read_with(cx, |multi_workspace, cx| {
1643 multi_workspace
1644 .workspace()
1645 .read(cx)
1646 .active_item(cx)
1647 .unwrap()
1648 .downcast::<Editor>()
1649 .unwrap()
1650 })
1651 .unwrap();
1652 multi_workspace
1653 .update(cx, |_, window, cx| {
1654 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1655 })
1656 .unwrap();
1657 multi_workspace
1658 .update(cx, |multi_workspace, _, cx| {
1659 assert!(
1660 multi_workspace.workspace().read(cx).is_edited(),
1661 "After inserting more text into the editor without saving, we should have a dirty project"
1662 )
1663 })
1664 .unwrap();
1665
1666 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1667 multi_workspace
1668 .update(cx, |_, _, cx| {
1669 recent_projects_picker.update(cx, |picker, cx| {
1670 assert_eq!(picker.query(cx), "");
1671 let delegate = &mut picker.delegate;
1672 delegate.set_workspaces(vec![(
1673 WorkspaceId::default(),
1674 SerializedWorkspaceLocation::Local,
1675 PathList::new(&[path!("/test/path")]),
1676 Utc::now(),
1677 )]);
1678 delegate.filtered_entries =
1679 vec![ProjectPickerEntry::RecentProject(StringMatch {
1680 candidate_id: 0,
1681 score: 1.0,
1682 positions: Vec::new(),
1683 string: "fake candidate".to_string(),
1684 })];
1685 });
1686 })
1687 .unwrap();
1688
1689 assert!(
1690 !cx.has_pending_prompt(),
1691 "Should have no pending prompt on dirty project before opening the new recent project"
1692 );
1693 let dirty_workspace = multi_workspace
1694 .read_with(cx, |multi_workspace, _cx| {
1695 multi_workspace.workspace().clone()
1696 })
1697 .unwrap();
1698
1699 cx.dispatch_action(*multi_workspace, menu::Confirm);
1700 cx.run_until_parked();
1701
1702 multi_workspace
1703 .update(cx, |multi_workspace, _, cx| {
1704 assert!(
1705 multi_workspace
1706 .workspace()
1707 .read(cx)
1708 .active_modal::<RecentProjects>(cx)
1709 .is_none(),
1710 "Should remove the modal after selecting new recent project"
1711 );
1712
1713 assert!(
1714 multi_workspace.workspaces().len() >= 2,
1715 "Should have at least 2 workspaces: the dirty one and the newly opened one"
1716 );
1717
1718 assert!(
1719 multi_workspace.workspaces().contains(&dirty_workspace),
1720 "The original dirty workspace should still be present"
1721 );
1722
1723 assert!(
1724 dirty_workspace.read(cx).is_edited(),
1725 "The original workspace should still be dirty"
1726 );
1727 })
1728 .unwrap();
1729
1730 assert!(
1731 !cx.has_pending_prompt(),
1732 "No save prompt in multi-workspace mode — dirty workspace survives in background"
1733 );
1734 }
1735
1736 fn open_recent_projects(
1737 multi_workspace: &WindowHandle<MultiWorkspace>,
1738 cx: &mut TestAppContext,
1739 ) -> Entity<Picker<RecentProjectsDelegate>> {
1740 cx.dispatch_action(
1741 (*multi_workspace).into(),
1742 OpenRecent {
1743 create_new_window: false,
1744 },
1745 );
1746 multi_workspace
1747 .update(cx, |multi_workspace, _, cx| {
1748 multi_workspace
1749 .workspace()
1750 .read(cx)
1751 .active_modal::<RecentProjects>(cx)
1752 .unwrap()
1753 .read(cx)
1754 .picker
1755 .clone()
1756 })
1757 .unwrap()
1758 }
1759
1760 #[gpui::test]
1761 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
1762 let app_state = init_test(cx);
1763
1764 app_state
1765 .fs
1766 .as_fake()
1767 .insert_tree(
1768 path!("/project"),
1769 json!({
1770 ".devcontainer": {
1771 "devcontainer.json": "{}"
1772 },
1773 "src": {
1774 "main.rs": "fn main() {}"
1775 }
1776 }),
1777 )
1778 .await;
1779
1780 cx.update(|cx| {
1781 open_paths(
1782 &[PathBuf::from(path!("/project"))],
1783 app_state,
1784 workspace::OpenOptions::default(),
1785 cx,
1786 )
1787 })
1788 .await
1789 .unwrap();
1790
1791 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1792 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1793
1794 cx.run_until_parked();
1795
1796 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
1797 // -> Workspace::update -> toggle_modal -> new_dev_container.
1798 // Before the fix, this panicked with "cannot read workspace::Workspace while
1799 // it is already being updated" because new_dev_container and open_dev_container
1800 // tried to read the Workspace entity through a WeakEntity handle while it was
1801 // already leased by the outer update.
1802 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1803
1804 multi_workspace
1805 .update(cx, |multi_workspace, _, cx| {
1806 let modal = multi_workspace
1807 .workspace()
1808 .read(cx)
1809 .active_modal::<RemoteServerProjects>(cx);
1810 assert!(
1811 modal.is_some(),
1812 "Dev container modal should be open after dispatching OpenDevContainer"
1813 );
1814 })
1815 .unwrap();
1816 }
1817
1818 #[gpui::test]
1819 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
1820 let app_state = init_test(cx);
1821
1822 app_state
1823 .fs
1824 .as_fake()
1825 .insert_tree(
1826 path!("/project"),
1827 json!({
1828 ".devcontainer": {
1829 "rust": {
1830 "devcontainer.json": "{}"
1831 },
1832 "python": {
1833 "devcontainer.json": "{}"
1834 }
1835 },
1836 "src": {
1837 "main.rs": "fn main() {}"
1838 }
1839 }),
1840 )
1841 .await;
1842
1843 cx.update(|cx| {
1844 open_paths(
1845 &[PathBuf::from(path!("/project"))],
1846 app_state,
1847 workspace::OpenOptions::default(),
1848 cx,
1849 )
1850 })
1851 .await
1852 .unwrap();
1853
1854 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1855 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1856
1857 cx.run_until_parked();
1858
1859 cx.dispatch_action(*multi_workspace, OpenDevContainer);
1860
1861 multi_workspace
1862 .update(cx, |multi_workspace, _, cx| {
1863 let modal = multi_workspace
1864 .workspace()
1865 .read(cx)
1866 .active_modal::<RemoteServerProjects>(cx);
1867 assert!(
1868 modal.is_some(),
1869 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
1870 );
1871 })
1872 .unwrap();
1873 }
1874
1875 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1876 cx.update(|cx| {
1877 let state = AppState::test(cx);
1878 crate::init(cx);
1879 editor::init(cx);
1880 state
1881 })
1882 }
1883}