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