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