Skip to repository content3293 lines · 127.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:31:56.822Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
recent_projects.rs
1pub mod disconnected_overlay;
2mod remote_connections;
3mod remote_servers;
4pub mod sidebar_recent_projects;
5mod ssh_config;
6
7use std::{
8 path::{Path, PathBuf},
9 sync::Arc,
10};
11
12use chrono::{DateTime, Utc};
13
14use fs::Fs;
15
16#[cfg(target_os = "windows")]
17mod wsl_picker;
18
19use remote::{RemoteConnectionOptions, same_remote_connection_identity};
20pub use remote_connection::{RemoteConnectionModal, connect, connect_with_modal};
21pub use remote_connections::{navigate_to_positions, open_remote_project};
22
23use disconnected_overlay::DisconnectedOverlay;
24use fuzzy_nucleo::{StringMatch, StringMatchCandidate, match_strings};
25use gpui::{
26 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
27 Subscription, Task, TaskExt, WeakEntity, Window, actions, px,
28};
29
30use picker::{
31 Picker, PickerDelegate, ScrollBehavior,
32 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
33};
34use project::{Worktree, git_store::Repository};
35pub use remote_connections::RemoteSettings;
36pub use remote_servers::RemoteServerProjects;
37use settings::{DefaultOpenBehavior, Settings, WorktreeId};
38use workspace::ProjectGroupKey;
39
40use dev_container::{DevContainerContext, find_devcontainer_configs};
41use ui::{
42 ButtonLike, ContextMenu, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing,
43 ListSubHeader, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
44};
45use util::{ResultExt, paths::PathExt};
46use workspace::{
47 HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, RecentWorkspace,
48 SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
49 notifications::DetachAndPromptErr, with_active_or_new_workspace,
50};
51use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
52
53actions!(
54 recent_projects,
55 [ToggleActionsMenu, RemoveSelected, AddToWorkspace,]
56);
57
58#[derive(Clone, Debug)]
59pub struct RecentProjectEntry {
60 pub name: SharedString,
61 pub full_path: SharedString,
62 pub paths: Vec<PathBuf>,
63 pub workspace_id: WorkspaceId,
64 pub timestamp: DateTime<Utc>,
65}
66
67#[derive(Clone, Debug)]
68struct OpenFolderEntry {
69 worktree_id: WorktreeId,
70 name: SharedString,
71 path: PathBuf,
72 branch: Option<SharedString>,
73 is_active: bool,
74 connection_options: Option<RemoteConnectionOptions>,
75}
76
77#[derive(Clone, Debug)]
78enum ProjectPickerEntry {
79 Header(SharedString),
80 /// A currently open folder from the active workspace's "Current Folders" section.
81 ///
82 /// `index` points into `RecentProjectsDelegate::open_folders`, and `positions` stores the
83 /// fuzzy-match highlight positions for rendering the folder name.
84 OpenFolder {
85 index: usize,
86 positions: Vec<usize>,
87 },
88 /// A project group from the current window's "This Window" section.
89 ///
90 /// These entries come from `RecentProjectsDelegate::window_project_groups`, not from the
91 /// recent-project database. Empty queries list every project group known to the current
92 /// window; non-empty queries list matching project groups. Confirming one activates or loads
93 /// that project group in the current window, while secondary confirm can move local project
94 /// groups to a new window when multiple groups are available.
95 ProjectGroup(StringMatch),
96 /// A workspace from the recent-project database's "Recent Projects" section.
97 ///
98 /// The match's `candidate_id` indexes into `RecentProjectsDelegate::workspaces`. Confirming
99 /// one opens that recent workspace in either the current window or a new window, depending on
100 /// whether the picker was invoked for new-window behavior and whether this was a primary or
101 /// secondary confirm.
102 RecentProject(StringMatch),
103}
104
105fn is_selectable_entry(entry: &ProjectPickerEntry) -> bool {
106 matches!(
107 entry,
108 ProjectPickerEntry::OpenFolder { .. }
109 | ProjectPickerEntry::ProjectGroup(_)
110 | ProjectPickerEntry::RecentProject(_)
111 )
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115enum ProjectPickerStyle {
116 Modal,
117 Popover,
118}
119
120pub async fn get_recent_projects(
121 current_workspace_id: Option<WorkspaceId>,
122 limit: Option<usize>,
123 fs: Arc<dyn fs::Fs>,
124 db: &WorkspaceDb,
125) -> Vec<RecentProjectEntry> {
126 let workspaces = db
127 .recent_project_workspaces(fs.as_ref())
128 .await
129 .unwrap_or_default();
130
131 let filtered: Vec<_> = workspaces
132 .into_iter()
133 .filter(|workspace| Some(workspace.workspace_id) != current_workspace_id)
134 .filter(|workspace| matches!(workspace.location, SerializedWorkspaceLocation::Local))
135 .collect();
136
137 let mut all_paths: Vec<PathBuf> = filtered
138 .iter()
139 .flat_map(|workspace| workspace.identity_paths.paths().iter().cloned())
140 .collect();
141 all_paths.sort_unstable();
142 all_paths.dedup();
143 let path_details =
144 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
145 project::path_suffix(path, detail)
146 });
147 let path_detail_map: std::collections::HashMap<PathBuf, usize> =
148 all_paths.into_iter().zip(path_details).collect();
149
150 let entries: Vec<RecentProjectEntry> = filtered
151 .into_iter()
152 .map(|workspace| {
153 let paths: Vec<PathBuf> = workspace.paths.paths().to_vec();
154 let ordered_paths: Vec<&PathBuf> = workspace.identity_paths.ordered_paths().collect();
155
156 let name = ordered_paths
157 .iter()
158 .map(|p| {
159 let detail = path_detail_map.get(*p).copied().unwrap_or(0);
160 project::path_suffix(p, detail)
161 })
162 .filter(|s| !s.is_empty())
163 .collect::<Vec<_>>()
164 .join(", ");
165
166 let full_path = ordered_paths
167 .iter()
168 .map(|p| p.to_string_lossy().to_string())
169 .collect::<Vec<_>>()
170 .join("\n");
171
172 RecentProjectEntry {
173 name: SharedString::from(name),
174 full_path: SharedString::from(full_path),
175 paths,
176 workspace_id: workspace.workspace_id,
177 timestamp: workspace.timestamp,
178 }
179 })
180 .collect();
181
182 match limit {
183 Some(n) => entries.into_iter().take(n).collect(),
184 None => entries,
185 }
186}
187
188pub async fn delete_recent_project(workspace_id: WorkspaceId, db: &WorkspaceDb) {
189 let _ = db.delete_workspace_by_id(workspace_id).await;
190}
191
192fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
193 let project = workspace.project().read(cx);
194 let connection_options = project.remote_connection_options(cx);
195 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
196
197 if visible_worktrees.len() <= 1 {
198 return Vec::new();
199 }
200
201 let active_worktree_id = if let Some(repo) = project.active_repository(cx) {
202 let repo = repo.read(cx);
203 let repo_path = &repo.work_directory_abs_path;
204 project.visible_worktrees(cx).find_map(|worktree| {
205 let worktree_path = worktree.read(cx).abs_path();
206 (worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()))
207 .then(|| worktree.read(cx).id())
208 })
209 } else {
210 project
211 .visible_worktrees(cx)
212 .next()
213 .map(|wt| wt.read(cx).id())
214 };
215
216 let mut all_paths: Vec<PathBuf> = visible_worktrees
217 .iter()
218 .map(|wt| wt.read(cx).abs_path().to_path_buf())
219 .collect();
220 all_paths.sort_unstable();
221 all_paths.dedup();
222 let path_details =
223 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
224 project::path_suffix(path, detail)
225 });
226 let path_detail_map: std::collections::HashMap<PathBuf, usize> =
227 all_paths.into_iter().zip(path_details).collect();
228
229 let git_store = project.git_store().read(cx);
230 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
231
232 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
233 .into_iter()
234 .map(|worktree| {
235 let worktree_ref = worktree.read(cx);
236 let worktree_id = worktree_ref.id();
237 let path = worktree_ref.abs_path().to_path_buf();
238 let detail = path_detail_map.get(&path).copied().unwrap_or(0);
239 let name = SharedString::from(project::path_suffix(&path, detail));
240 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
241 let is_active = active_worktree_id == Some(worktree_id);
242 OpenFolderEntry {
243 worktree_id,
244 name,
245 path,
246 branch,
247 is_active,
248 connection_options: connection_options.clone(),
249 }
250 })
251 .collect();
252
253 entries.sort_by_key(|entry| entry.name.to_lowercase());
254 entries
255}
256
257fn get_branch_for_worktree(
258 worktree: &Worktree,
259 repositories: &[Entity<Repository>],
260 cx: &App,
261) -> Option<SharedString> {
262 let worktree_abs_path = worktree.abs_path();
263 repositories
264 .iter()
265 .filter(|repo| {
266 let repo_path = &repo.read(cx).work_directory_abs_path;
267 *repo_path == worktree_abs_path || worktree_abs_path.starts_with(repo_path.as_ref())
268 })
269 .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
270 .and_then(|repo| {
271 repo.read(cx)
272 .branch
273 .as_ref()
274 .map(|branch| SharedString::from(branch.name().to_string()))
275 })
276}
277
278pub(crate) fn default_open_in_new_window(cx: &App) -> bool {
279 matches!(
280 workspace::WorkspaceSettings::get_global(cx).default_open_behavior,
281 DefaultOpenBehavior::NewWindow
282 )
283}
284
285pub fn init(cx: &mut App) {
286 #[cfg(target_os = "windows")]
287 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
288 let create_new_window = open_wsl
289 .create_new_window
290 .unwrap_or_else(|| default_open_in_new_window(cx));
291 with_active_or_new_workspace(cx, move |workspace, window, cx| {
292 use gpui::PathPromptOptions;
293 use project::DirectoryLister;
294
295 let paths = workspace.prompt_for_open_path(
296 PathPromptOptions {
297 files: true,
298 directories: true,
299 multiple: false,
300 prompt: None,
301 },
302 DirectoryLister::Local(
303 workspace.project().clone(),
304 workspace.app_state().fs.clone(),
305 ),
306 window,
307 cx,
308 );
309
310 let app_state = workspace.app_state().clone();
311 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
312
313 cx.spawn_in(window, async move |workspace, cx| {
314 use util::paths::SanitizedPath;
315
316 let Some(paths) = paths.await.log_err().flatten() else {
317 return;
318 };
319
320 let wsl_path = paths
321 .iter()
322 .find_map(util::paths::WslPath::from_path);
323
324 if let Some(util::paths::WslPath { distro, path }) = wsl_path {
325 use remote::WslConnectionOptions;
326
327 let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
328 distro_name: distro.to_string(),
329 user: None,
330 });
331
332 let requesting_window = match create_new_window {
333 false => window_handle,
334 true => None,
335 };
336
337 let open_options = workspace::OpenOptions {
338 requesting_window,
339 ..Default::default()
340 };
341
342 open_remote_project(connection_options, vec![path.into()], app_state, open_options, cx).await.log_err();
343 return;
344 }
345
346 let paths = paths
347 .into_iter()
348 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
349 .collect::<Vec<_>>();
350
351 if paths.is_empty() {
352 let message = indoc::indoc! { r#"
353 Invalid path specified when trying to open a folder inside WSL.
354
355 Please note that Omega currently does not support opening network share folders inside wsl.
356 "#};
357
358 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["OK"]).await;
359 return;
360 }
361
362 workspace.update_in(cx, |workspace, window, cx| {
363 workspace.toggle_modal(window, cx, |window, cx| {
364 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
365 });
366 }).log_err();
367 })
368 .detach();
369 });
370 });
371
372 #[cfg(target_os = "windows")]
373 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
374 let create_new_window = open_wsl
375 .create_new_window
376 .unwrap_or_else(|| default_open_in_new_window(cx));
377 with_active_or_new_workspace(cx, move |workspace, window, cx| {
378 let handle = cx.entity().downgrade();
379 let fs = workspace.project().read(cx).fs().clone();
380 workspace.toggle_modal(window, cx, |window, cx| {
381 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
382 });
383 });
384 });
385
386 #[cfg(target_os = "windows")]
387 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
388 let open_wsl = open_wsl.clone();
389 with_active_or_new_workspace(cx, move |workspace, window, cx| {
390 let fs = workspace.project().read(cx).fs().clone();
391 add_wsl_distro(fs, &open_wsl.distro, cx);
392 let requesting_window =
393 match workspace::WorkspaceSettings::get_global(cx).default_open_behavior {
394 DefaultOpenBehavior::ExistingWindow => {
395 window.window_handle().downcast::<MultiWorkspace>()
396 }
397 DefaultOpenBehavior::NewWindow => None,
398 };
399 let open_options = OpenOptions {
400 requesting_window,
401 ..Default::default()
402 };
403
404 let app_state = workspace.app_state().clone();
405
406 cx.spawn_in(window, async move |_, cx| {
407 open_remote_project(
408 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
409 open_wsl.paths,
410 app_state,
411 open_options,
412 cx,
413 )
414 .await
415 })
416 .detach();
417 });
418 });
419
420 cx.on_action(|open_recent: &OpenRecent, cx| {
421 let create_new_window = open_recent.create_new_window;
422
423 match cx
424 .active_window()
425 .and_then(|w| w.downcast::<MultiWorkspace>())
426 {
427 Some(multi_workspace) => {
428 cx.defer(move |cx| {
429 multi_workspace
430 .update(cx, |multi_workspace, window, cx| {
431 let window_project_groups: Vec<ProjectGroupKey> =
432 multi_workspace.project_group_keys();
433
434 let workspace = multi_workspace.workspace().clone();
435 workspace.update(cx, |workspace, cx| {
436 let Some(recent_projects) =
437 workspace.active_modal::<RecentProjects>(cx)
438 else {
439 let focus_handle = workspace.focus_handle(cx);
440 RecentProjects::open(
441 workspace,
442 create_new_window,
443 window_project_groups,
444 window,
445 focus_handle,
446 cx,
447 );
448 return;
449 };
450
451 recent_projects.update(cx, |recent_projects, cx| {
452 recent_projects
453 .picker
454 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
455 });
456 });
457 })
458 .log_err();
459 });
460 }
461 None => {
462 with_active_or_new_workspace(cx, move |workspace, window, cx| {
463 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
464 let focus_handle = workspace.focus_handle(cx);
465 RecentProjects::open(
466 workspace,
467 create_new_window,
468 Vec::new(),
469 window,
470 focus_handle,
471 cx,
472 );
473 return;
474 };
475
476 recent_projects.update(cx, |recent_projects, cx| {
477 recent_projects
478 .picker
479 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
480 });
481 });
482 }
483 }
484 });
485 cx.on_action(|open_remote: &OpenRemote, cx| {
486 let from_existing_connection = open_remote.from_existing_connection;
487 let create_new_window = open_remote
488 .create_new_window
489 .unwrap_or_else(|| default_open_in_new_window(cx));
490 with_active_or_new_workspace(cx, move |workspace, window, cx| {
491 if from_existing_connection {
492 cx.propagate();
493 return;
494 }
495 let handle = cx.entity().downgrade();
496 let fs = workspace.project().read(cx).fs().clone();
497 workspace.toggle_modal(window, cx, |window, cx| {
498 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
499 })
500 });
501 });
502
503 cx.observe_new(DisconnectedOverlay::register).detach();
504
505 cx.on_action(|_: &OpenDevContainer, cx| {
506 with_active_or_new_workspace(cx, move |workspace, window, cx| {
507 if !workspace.project().read(cx).is_local() {
508 cx.spawn_in(window, async move |_, cx| {
509 cx.prompt(
510 gpui::PromptLevel::Critical,
511 "Cannot open Dev Container from remote project",
512 None,
513 &["OK"],
514 )
515 .await
516 .ok();
517 })
518 .detach();
519 return;
520 }
521
522 let fs = workspace.project().read(cx).fs().clone();
523 let configs = find_devcontainer_configs(workspace, cx);
524 let app_state = workspace.app_state().clone();
525 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
526 let handle = cx.entity().downgrade();
527 workspace.toggle_modal(window, cx, |window, cx| {
528 RemoteServerProjects::new_dev_container(
529 fs,
530 configs,
531 app_state,
532 dev_container_context,
533 window,
534 handle,
535 cx,
536 )
537 });
538 });
539 });
540
541}
542
543#[cfg(target_os = "windows")]
544pub fn add_wsl_distro(
545 fs: Arc<dyn project::Fs>,
546 connection_options: &remote::WslConnectionOptions,
547 cx: &App,
548) {
549 use gpui::ReadGlobal;
550 use settings::SettingsStore;
551
552 let distro_name = connection_options.distro_name.clone();
553 let user = connection_options.user.clone();
554 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
555 let connections = setting
556 .remote
557 .wsl_connections
558 .get_or_insert(Default::default());
559
560 if !connections
561 .iter()
562 .any(|conn| conn.distro_name == distro_name && conn.user == user)
563 {
564 use std::collections::BTreeSet;
565
566 connections.push(settings::WslConnection {
567 distro_name,
568 user,
569 projects: BTreeSet::new(),
570 })
571 }
572 });
573}
574
575pub struct RecentProjects {
576 pub picker: Entity<Picker<RecentProjectsDelegate>>,
577 _subscriptions: Vec<Subscription>,
578}
579
580impl ModalView for RecentProjects {
581 fn on_before_dismiss(
582 &mut self,
583 window: &mut Window,
584 cx: &mut Context<Self>,
585 ) -> workspace::DismissDecision {
586 let submenu_focused = self.picker.update(cx, |picker, cx| {
587 picker.delegate.actions_menu_handle.is_focused(window, cx)
588 });
589 workspace::DismissDecision::Dismiss(!submenu_focused)
590 }
591}
592
593impl RecentProjects {
594 fn new(
595 delegate: RecentProjectsDelegate,
596 fs: Option<Arc<dyn Fs>>,
597 rem_width: f32,
598 window: &mut Window,
599 cx: &mut Context<Self>,
600 ) -> Self {
601 let style = delegate.style;
602 let picker = cx.new(|cx| {
603 Picker::list(delegate, window, cx)
604 .list_measure_all()
605 .initial_width(rems(rem_width))
606 .show_scrollbar(true)
607 });
608
609 let picker_focus_handle = picker.focus_handle(cx);
610 picker.update(cx, |picker, _| {
611 picker.delegate.focus_handle = picker_focus_handle;
612 });
613
614 let mut subscriptions = vec![cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent))];
615
616 if style == ProjectPickerStyle::Popover {
617 let picker_focus = picker.focus_handle(cx);
618 subscriptions.push(
619 cx.on_focus_out(&picker_focus, window, |this, _, window, cx| {
620 let submenu_focused = this.picker.update(cx, |picker, cx| {
621 picker.delegate.actions_menu_handle.is_focused(window, cx)
622 });
623 if !submenu_focused {
624 cx.emit(DismissEvent);
625 }
626 }),
627 );
628 }
629 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
630 // out workspace locations once the future runs to completion.
631 let db = WorkspaceDb::global(cx);
632 cx.spawn_in(window, async move |this, cx| {
633 let Some(fs) = fs else { return };
634 let workspaces = db
635 .recent_project_workspaces(fs.as_ref())
636 .await
637 .log_err()
638 .unwrap_or_default();
639 this.update_in(cx, move |this, window, cx| {
640 this.picker.update(cx, move |picker, cx| {
641 picker.delegate.set_workspaces(workspaces);
642 picker.update_matches(picker.query(cx), window, cx)
643 })
644 })
645 .ok();
646 })
647 .detach();
648 Self {
649 picker,
650 _subscriptions: subscriptions,
651 }
652 }
653
654 pub fn open(
655 workspace: &mut Workspace,
656 create_new_window: Option<bool>,
657 window_project_groups: Vec<ProjectGroupKey>,
658 window: &mut Window,
659 focus_handle: FocusHandle,
660 cx: &mut Context<Workspace>,
661 ) {
662 let weak = cx.entity().downgrade();
663 let open_folders = get_open_folders(workspace, cx);
664 let fs = Some(workspace.app_state().fs.clone());
665
666 let create_new_window = create_new_window.unwrap_or_else(|| default_open_in_new_window(cx));
667
668 workspace.toggle_modal(window, cx, |window, cx| {
669 let delegate = RecentProjectsDelegate::new(
670 weak,
671 create_new_window,
672 focus_handle,
673 open_folders,
674 window_project_groups,
675 ProjectPickerStyle::Modal,
676 );
677
678 Self::new(delegate, fs, 42., window, cx)
679 })
680 }
681
682 pub fn popover(
683 workspace: WeakEntity<Workspace>,
684 window_project_groups: Vec<ProjectGroupKey>,
685 create_new_window: Option<bool>,
686 focus_handle: FocusHandle,
687 window: &mut Window,
688 cx: &mut App,
689 ) -> Entity<Self> {
690 let (open_folders, fs) = workspace
691 .upgrade()
692 .map(|workspace| {
693 let workspace = workspace.read(cx);
694 (
695 get_open_folders(workspace, cx),
696 Some(workspace.app_state().fs.clone()),
697 )
698 })
699 .unwrap_or_else(|| (Vec::new(), None));
700
701 let create_new_window = create_new_window.unwrap_or_else(|| default_open_in_new_window(cx));
702
703 cx.new(|cx| {
704 let delegate = RecentProjectsDelegate::new(
705 workspace,
706 create_new_window,
707 focus_handle,
708 open_folders,
709 window_project_groups,
710 ProjectPickerStyle::Popover,
711 );
712 let list = Self::new(delegate, fs, 20., window, cx);
713 list.picker.focus_handle(cx).focus(window, cx);
714 list
715 })
716 }
717
718 fn handle_toggle_open_menu(
719 &mut self,
720 _: &ToggleActionsMenu,
721 window: &mut Window,
722 cx: &mut Context<Self>,
723 ) {
724 self.picker.update(cx, |picker, cx| {
725 let menu_handle = &picker.delegate.actions_menu_handle;
726 if menu_handle.is_deployed() {
727 menu_handle.hide(cx);
728 } else {
729 menu_handle.show(window, cx);
730 }
731 });
732 }
733
734 fn handle_remove_selected(
735 &mut self,
736 _: &RemoveSelected,
737 window: &mut Window,
738 cx: &mut Context<Self>,
739 ) {
740 self.picker.update(cx, |picker, cx| {
741 let ix = picker.delegate.selected_index;
742
743 match picker.delegate.filtered_entries.get(ix) {
744 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
745 if let Some(worktree_id) = picker
746 .delegate
747 .open_folders
748 .get(*index)
749 .map(|f| f.worktree_id)
750 {
751 RecentProjectsDelegate::remove_open_folder(picker, worktree_id, window, cx);
752 }
753 }
754 Some(ProjectPickerEntry::ProjectGroup(hit)) => {
755 if let Some(key) = picker
756 .delegate
757 .window_project_groups
758 .get(hit.candidate_id)
759 .cloned()
760 {
761 if picker.delegate.is_active_project_group(&key, cx) {
762 return;
763 }
764 picker.delegate.remove_project_group(key, window, cx);
765 let query = picker.query(cx);
766 picker.update_matches(query, window, cx);
767 }
768 }
769 Some(ProjectPickerEntry::RecentProject(_)) => {
770 picker.delegate.delete_recent_project(ix, window, cx);
771 }
772 _ => {}
773 }
774 });
775 }
776
777 fn handle_add_to_workspace(
778 &mut self,
779 _: &AddToWorkspace,
780 window: &mut Window,
781 cx: &mut Context<Self>,
782 ) {
783 self.picker.update(cx, |picker, cx| {
784 let ix = picker.delegate.selected_index;
785
786 if let Some(ProjectPickerEntry::RecentProject(hit)) =
787 picker.delegate.filtered_entries.get(ix)
788 {
789 if let Some(workspace) = picker.delegate.workspaces.get(hit.candidate_id) {
790 if matches!(workspace.location, SerializedWorkspaceLocation::Local) {
791 let paths_to_add = workspace.paths.paths().to_vec();
792 picker
793 .delegate
794 .add_paths_to_project(paths_to_add, window, cx);
795 }
796 }
797 }
798 });
799 }
800}
801
802impl EventEmitter<DismissEvent> for RecentProjects {}
803
804impl Focusable for RecentProjects {
805 fn focus_handle(&self, cx: &App) -> FocusHandle {
806 self.picker.focus_handle(cx)
807 }
808}
809
810impl Render for RecentProjects {
811 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
812 v_flex()
813 .key_context("RecentProjects")
814 .on_action(cx.listener(Self::handle_toggle_open_menu))
815 .on_action(cx.listener(Self::handle_remove_selected))
816 .on_action(cx.listener(Self::handle_add_to_workspace))
817 .child(self.picker.clone())
818 }
819}
820
821pub struct RecentProjectsDelegate {
822 workspace: WeakEntity<Workspace>,
823 open_folders: Vec<OpenFolderEntry>,
824 window_project_groups: Vec<ProjectGroupKey>,
825 workspaces: Vec<RecentWorkspace>,
826 filtered_entries: Vec<ProjectPickerEntry>,
827 selected_index: usize,
828 render_paths: bool,
829 create_new_window: bool,
830 snap_selection_to_first_non_header_match: bool,
831 focus_handle: FocusHandle,
832 style: ProjectPickerStyle,
833 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
834}
835
836impl RecentProjectsDelegate {
837 fn new(
838 workspace: WeakEntity<Workspace>,
839 create_new_window: bool,
840 focus_handle: FocusHandle,
841 open_folders: Vec<OpenFolderEntry>,
842 window_project_groups: Vec<ProjectGroupKey>,
843 style: ProjectPickerStyle,
844 ) -> Self {
845 let render_paths = style == ProjectPickerStyle::Modal;
846 Self {
847 workspace,
848 open_folders,
849 window_project_groups,
850 workspaces: Vec::new(),
851 filtered_entries: Vec::new(),
852 selected_index: 0,
853 create_new_window,
854 render_paths,
855 snap_selection_to_first_non_header_match: true,
856 focus_handle,
857 style,
858 actions_menu_handle: PopoverMenuHandle::default(),
859 }
860 }
861
862 pub fn set_workspaces(&mut self, workspaces: Vec<RecentWorkspace>) {
863 self.workspaces = workspaces;
864 }
865
866 fn filtered_entries_include_remote_project(&self) -> bool {
867 self.filtered_entries
868 .iter()
869 .any(|entry| self.entry_is_remote_project(entry))
870 }
871
872 fn entry_is_remote_project(&self, entry: &ProjectPickerEntry) -> bool {
873 match entry {
874 ProjectPickerEntry::Header(_) => false,
875 ProjectPickerEntry::OpenFolder { index, .. } => self
876 .open_folders
877 .get(*index)
878 .is_some_and(|folder| folder.connection_options.is_some()),
879 ProjectPickerEntry::ProjectGroup(hit) => self
880 .window_project_groups
881 .get(hit.candidate_id)
882 .is_some_and(|key| key.host().is_some()),
883 ProjectPickerEntry::RecentProject(hit) => self
884 .workspaces
885 .get(hit.candidate_id)
886 .is_some_and(|workspace| {
887 matches!(workspace.location, SerializedWorkspaceLocation::Remote(_))
888 }),
889 }
890 }
891}
892impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
893impl PickerDelegate for RecentProjectsDelegate {
894 type ListItem = AnyElement;
895
896 fn name() -> &'static str {
897 "recent projects"
898 }
899
900 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
901 "Search projects…".into()
902 }
903
904 fn match_count(&self) -> usize {
905 self.filtered_entries.len()
906 }
907
908 fn selected_index(&self) -> usize {
909 self.selected_index
910 }
911
912 fn set_selected_index(
913 &mut self,
914 ix: usize,
915 _window: &mut Window,
916 _cx: &mut Context<Picker<Self>>,
917 ) {
918 self.selected_index = ix;
919 }
920
921 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
922 matches!(
923 self.filtered_entries.get(ix),
924 Some(
925 ProjectPickerEntry::OpenFolder { .. }
926 | ProjectPickerEntry::ProjectGroup(_)
927 | ProjectPickerEntry::RecentProject(_)
928 )
929 )
930 }
931
932 fn update_matches(
933 &mut self,
934 query: String,
935 _: &mut Window,
936 cx: &mut Context<Picker<Self>>,
937 ) -> gpui::Task<()> {
938 let query = query.trim_start();
939 let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
940 let is_empty_query = query.is_empty();
941
942 let folder_matches = if self.open_folders.is_empty() {
943 Vec::new()
944 } else {
945 let candidates: Vec<_> = self
946 .open_folders
947 .iter()
948 .enumerate()
949 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
950 .collect();
951
952 match_strings(
953 &candidates,
954 query,
955 case,
956 fuzzy_nucleo::LengthPenalty::On,
957 100,
958 )
959 };
960
961 let project_group_candidates: Vec<_> = self
962 .window_project_groups
963 .iter()
964 .enumerate()
965 .map(|(id, key)| {
966 let combined_string = key
967 .path_list()
968 .ordered_paths()
969 .map(|path| path.compact().to_string_lossy().into_owned())
970 .collect::<Vec<_>>()
971 .concat();
972 StringMatchCandidate::new(id, &combined_string)
973 })
974 .collect();
975
976 let project_group_matches = match_strings(
977 &project_group_candidates,
978 query,
979 case,
980 fuzzy_nucleo::LengthPenalty::On,
981 100,
982 );
983
984 // Build candidates for recent projects (not current, not sibling, not open folder)
985 let recent_candidates: Vec<_> = self
986 .workspaces
987 .iter()
988 .enumerate()
989 .filter(|(_, workspace)| self.is_valid_recent_candidate(workspace, cx))
990 .map(|(id, workspace)| {
991 let combined_string = workspace
992 .identity_paths
993 .ordered_paths()
994 .map(|path| path.compact().to_string_lossy().into_owned())
995 .collect::<Vec<_>>()
996 .concat();
997 StringMatchCandidate::new(id, &combined_string)
998 })
999 .collect();
1000
1001 let recent_matches = match_strings(
1002 &recent_candidates,
1003 query,
1004 case,
1005 fuzzy_nucleo::LengthPenalty::On,
1006 100,
1007 );
1008
1009 let mut entries = Vec::new();
1010
1011 if !self.open_folders.is_empty() {
1012 let matched_folders: Vec<_> = if is_empty_query {
1013 (0..self.open_folders.len())
1014 .map(|i| (i, Vec::new()))
1015 .collect()
1016 } else {
1017 folder_matches
1018 .iter()
1019 .map(|m| (m.candidate_id, m.positions.clone()))
1020 .collect()
1021 };
1022
1023 if !matched_folders.is_empty() {
1024 entries.push(ProjectPickerEntry::Header("Current Folders".into()));
1025 for (index, positions) in matched_folders {
1026 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1027 }
1028 }
1029 }
1030
1031 let has_projects_to_show = if is_empty_query {
1032 !project_group_candidates.is_empty()
1033 } else {
1034 !project_group_matches.is_empty()
1035 };
1036
1037 if has_projects_to_show {
1038 entries.push(ProjectPickerEntry::Header("This Window".into()));
1039
1040 if is_empty_query {
1041 for id in 0..self.window_project_groups.len() {
1042 entries.push(ProjectPickerEntry::ProjectGroup(StringMatch {
1043 candidate_id: id,
1044 score: 0.0,
1045 positions: Vec::new(),
1046 string: Default::default(),
1047 }));
1048 }
1049 } else {
1050 for m in project_group_matches {
1051 entries.push(ProjectPickerEntry::ProjectGroup(m));
1052 }
1053 }
1054 }
1055
1056 let has_recent_to_show = if is_empty_query {
1057 !recent_candidates.is_empty()
1058 } else {
1059 !recent_matches.is_empty()
1060 };
1061
1062 if has_recent_to_show {
1063 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1064
1065 if is_empty_query {
1066 for (id, workspace) in self.workspaces.iter().enumerate() {
1067 if self.is_valid_recent_candidate(workspace, cx) {
1068 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1069 candidate_id: id,
1070 score: 0.0,
1071 positions: Vec::new(),
1072 string: Default::default(),
1073 }));
1074 }
1075 }
1076 } else {
1077 for m in recent_matches {
1078 entries.push(ProjectPickerEntry::RecentProject(m));
1079 }
1080 }
1081 }
1082
1083 self.filtered_entries = entries;
1084
1085 if self.snap_selection_to_first_non_header_match {
1086 self.selected_index = self
1087 .filtered_entries
1088 .iter()
1089 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1090 .unwrap_or(0);
1091 }
1092 self.snap_selection_to_first_non_header_match = true;
1093 Task::ready(())
1094 }
1095
1096 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1097 match self.filtered_entries.get(self.selected_index) {
1098 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1099 let Some(folder) = self.open_folders.get(*index) else {
1100 return;
1101 };
1102 let worktree_id = folder.worktree_id;
1103 if let Some(workspace) = self.workspace.upgrade() {
1104 workspace.update(cx, |workspace, cx| {
1105 let git_store = workspace.project().read(cx).git_store().clone();
1106 git_store.update(cx, |git_store, cx| {
1107 git_store.set_active_repo_for_worktree(worktree_id, cx);
1108 });
1109 });
1110 }
1111 cx.emit(DismissEvent);
1112 }
1113 Some(ProjectPickerEntry::ProjectGroup(selected_match)) => {
1114 let Some(key) = self.window_project_groups.get(selected_match.candidate_id) else {
1115 return;
1116 };
1117
1118 if secondary && key.host().is_none() && self.window_project_groups.len() >= 2 {
1119 move_project_group_to_new_window(key, window, cx);
1120 cx.emit(DismissEvent);
1121 return;
1122 }
1123
1124 let key = key.clone();
1125 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1126 cx.defer(move |cx| {
1127 // Try to activate an existing workspace for this project group
1128 // first, so we preserve the actual worktree paths (which may
1129 // differ from the main git worktree paths stored in the key).
1130 if let Some(workspace) = handle
1131 .update(cx, |multi_workspace, _window, cx| {
1132 multi_workspace.last_active_workspace_for_group(&key, cx)
1133 })
1134 .log_err()
1135 .flatten()
1136 {
1137 handle
1138 .update(cx, |multi_workspace, window, cx| {
1139 multi_workspace.activate(workspace, None, window, cx);
1140 })
1141 .log_err();
1142 } else {
1143 let path_list = key.path_list().clone();
1144 let host = key.host();
1145 if let Some(task) = handle
1146 .update(cx, |multi_workspace, window, cx| {
1147 let modal_workspace = multi_workspace.workspace().clone();
1148 multi_workspace.find_or_create_workspace(
1149 path_list,
1150 host,
1151 Some(key.clone()),
1152 move |options, window, cx| {
1153 connect_with_modal(
1154 &modal_workspace,
1155 options,
1156 window,
1157 cx,
1158 )
1159 },
1160 &[],
1161 None,
1162 OpenMode::Activate,
1163 window,
1164 cx,
1165 )
1166 })
1167 .log_err()
1168 {
1169 task.detach_and_log_err(cx);
1170 }
1171 }
1172 });
1173 }
1174 cx.emit(DismissEvent);
1175 }
1176 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1177 let candidate_id = selected_match.candidate_id;
1178 self.open_recent_projects(candidate_id, secondary, window, cx);
1179 }
1180 _ => {}
1181 }
1182 }
1183
1184 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1185
1186 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1187 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1188 "Recently opened projects will show up here".into()
1189 } else {
1190 "No matches".into()
1191 };
1192 Some(text)
1193 }
1194
1195 fn render_match(
1196 &self,
1197 ix: usize,
1198 selected: bool,
1199 window: &mut Window,
1200 cx: &mut Context<Picker<Self>>,
1201 ) -> Option<Self::ListItem> {
1202 match self.filtered_entries.get(ix)? {
1203 ProjectPickerEntry::Header(title) => Some(
1204 v_flex()
1205 .w_full()
1206 .gap_1()
1207 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1208 .child(ListSubHeader::new(title.clone()).inset(true))
1209 .into_any_element(),
1210 ),
1211 ProjectPickerEntry::OpenFolder { index, positions } => {
1212 let folder = self.open_folders.get(*index)?;
1213 let name = folder.name.clone();
1214 let path = folder.path.compact();
1215 let branch = folder.branch.clone();
1216 let is_active = folder.is_active;
1217 let worktree_id = folder.worktree_id;
1218 let positions = positions.clone();
1219 let show_path = self.style == ProjectPickerStyle::Modal;
1220
1221 let secondary_actions = h_flex()
1222 .gap_1()
1223 .child(
1224 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1225 .icon_size(IconSize::Small)
1226 .tooltip({
1227 let focus_handle = self.focus_handle.clone();
1228 move |_, cx| {
1229 Tooltip::for_action_in(
1230 "Remove Folder from Project",
1231 &RemoveSelected,
1232 &focus_handle,
1233 cx,
1234 )
1235 }
1236 })
1237 .on_click(cx.listener(move |picker, _, window, cx| {
1238 RecentProjectsDelegate::remove_open_folder(
1239 picker,
1240 worktree_id,
1241 window,
1242 cx,
1243 );
1244 })),
1245 )
1246 .into_any_element();
1247
1248 let icon = icon_for_remote_connection(folder.connection_options.as_ref());
1249 let show_icon = self.filtered_entries_include_remote_project();
1250
1251 let tooltip_path: SharedString = path.to_string_lossy().to_string().into();
1252 let tooltip_branch = branch.clone();
1253
1254 Some(
1255 ListItem::new(ix)
1256 .toggle_state(selected)
1257 .inset(true)
1258 .spacing(ListItemSpacing::Sparse)
1259 .child(
1260 h_flex()
1261 .id("open_folder_item")
1262 .w_full()
1263 .min_w_0()
1264 .gap_2p5()
1265 .when(show_icon, |this| {
1266 this.child(Icon::new(icon).color(Color::Muted))
1267 })
1268 .child(
1269 v_flex()
1270 .min_w_0()
1271 .child(
1272 h_flex()
1273 .gap_1()
1274 .child(HighlightedLabel::new(
1275 name.to_string(),
1276 positions,
1277 ))
1278 .when_some(branch, |this, branch| {
1279 this.child(
1280 Label::new(branch)
1281 .color(Color::Muted)
1282 .truncate(),
1283 )
1284 })
1285 .when(is_active, |this| {
1286 this.child(
1287 Icon::new(IconName::Check)
1288 .size(IconSize::Small)
1289 .color(Color::Accent),
1290 )
1291 }),
1292 )
1293 .when(show_path, |this| {
1294 this.child(
1295 Label::new(path.to_string_lossy().to_string())
1296 .size(LabelSize::Small)
1297 .color(Color::Muted),
1298 )
1299 }),
1300 )
1301 .when(!show_path, |this| {
1302 this.tooltip(move |_, cx| {
1303 if let Some(branch) = tooltip_branch.clone() {
1304 Tooltip::with_meta(
1305 format!("{}/{}", name, branch),
1306 None,
1307 tooltip_path.clone(),
1308 cx,
1309 )
1310 } else {
1311 Tooltip::simple(tooltip_path.clone(), cx)
1312 }
1313 })
1314 }),
1315 )
1316 .end_slot(secondary_actions)
1317 .show_end_slot_on_hover()
1318 .into_any_element(),
1319 )
1320 }
1321 ProjectPickerEntry::ProjectGroup(hit) => {
1322 let key = self.window_project_groups.get(hit.candidate_id)?;
1323 let is_active = self.is_active_project_group(key, cx);
1324 let paths = key.path_list();
1325 let ordered_paths: Vec<_> = paths
1326 .ordered_paths()
1327 .map(|p| p.compact().to_string_lossy().to_string())
1328 .collect();
1329 let tooltip_path: SharedString = ordered_paths.join("\n").into();
1330 let icon = icon_for_project_group(key);
1331 let show_icon = self.filtered_entries_include_remote_project();
1332
1333 let mut path_start_offset = 0;
1334 let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
1335 .ordered_paths()
1336 .map(|p| p.compact())
1337 .map(|path| {
1338 let highlighted_text =
1339 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1340 path_start_offset += highlighted_text.1.text.len();
1341 highlighted_text
1342 })
1343 .unzip();
1344
1345 let highlighted_match = HighlightedMatchWithPaths {
1346 prefix: None,
1347 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1348 paths: path_highlights,
1349 active: is_active,
1350 };
1351
1352 let project_group_key = key.clone();
1353 let is_local = key.host().is_none();
1354 let has_multiple_groups = self.window_project_groups.len() >= 2;
1355 let secondary_actions = h_flex()
1356 .gap_0p5()
1357 .when(is_local && has_multiple_groups, |this| {
1358 this.child(
1359 IconButton::new("move_to_new_window", IconName::ArrowUpRight)
1360 .icon_size(IconSize::Small)
1361 .tooltip({
1362 let focus_handle = self.focus_handle.clone();
1363 move |_, cx| {
1364 Tooltip::for_action_in(
1365 "Open in New Window",
1366 &menu::SecondaryConfirm,
1367 &focus_handle,
1368 cx,
1369 )
1370 }
1371 })
1372 .on_click({
1373 let project_group_key = project_group_key.clone();
1374 cx.listener(move |_picker, _, window, cx| {
1375 cx.stop_propagation();
1376 window.prevent_default();
1377 move_project_group_to_new_window(
1378 &project_group_key,
1379 window,
1380 cx,
1381 );
1382 cx.emit(DismissEvent);
1383 })
1384 }),
1385 )
1386 })
1387 .when(!is_active, |this| {
1388 this.child(
1389 IconButton::new("remove_open_project", IconName::Close)
1390 .icon_size(IconSize::Small)
1391 .tooltip({
1392 let focus_handle = self.focus_handle.clone();
1393 move |_, cx| {
1394 Tooltip::for_action_in(
1395 "Remove Project from Window",
1396 &RemoveSelected,
1397 &focus_handle,
1398 cx,
1399 )
1400 }
1401 })
1402 .on_click({
1403 let project_group_key = project_group_key.clone();
1404 cx.listener(move |picker, _, window, cx| {
1405 cx.stop_propagation();
1406 window.prevent_default();
1407 picker.delegate.remove_project_group(
1408 project_group_key.clone(),
1409 window,
1410 cx,
1411 );
1412 let query = picker.query(cx);
1413 picker.update_matches(query, window, cx);
1414 })
1415 }),
1416 )
1417 })
1418 .into_any_element();
1419
1420 Some(
1421 ListItem::new(ix)
1422 .inset(true)
1423 .toggle_state(selected)
1424 .spacing(ListItemSpacing::Sparse)
1425 .child(
1426 h_flex()
1427 .id("open_project_info_container")
1428 .w_full()
1429 .min_w_0()
1430 .gap_2p5()
1431 .when(show_icon, |this| {
1432 this.child(Icon::new(icon).color(Color::Muted))
1433 })
1434 .child({
1435 let mut highlighted = highlighted_match;
1436 if !self.render_paths {
1437 highlighted.paths.clear();
1438 }
1439 highlighted.render(window, cx)
1440 })
1441 .tooltip(Tooltip::text(tooltip_path)),
1442 )
1443 .end_slot(secondary_actions)
1444 .show_end_slot_on_hover()
1445 .into_any_element(),
1446 )
1447 }
1448 ProjectPickerEntry::RecentProject(hit) => {
1449 let workspace = self.workspaces.get(hit.candidate_id)?;
1450 let location = &workspace.location;
1451 let raw_paths = &workspace.paths;
1452 let identity_paths = &workspace.identity_paths;
1453 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1454 let paths_to_add = raw_paths.paths().to_vec();
1455 let ordered_paths: Vec<_> = identity_paths
1456 .ordered_paths()
1457 .map(|p| p.compact().to_string_lossy().to_string())
1458 .collect();
1459 let tooltip_path: SharedString = match &location {
1460 SerializedWorkspaceLocation::Remote(options) => {
1461 let host = options.display_name();
1462 if ordered_paths.len() == 1 {
1463 format!("{} ({})", ordered_paths[0], host).into()
1464 } else {
1465 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1466 }
1467 }
1468 _ => ordered_paths.join("\n").into(),
1469 };
1470
1471 let mut path_start_offset = 0;
1472 let (match_labels, paths): (Vec<_>, Vec<_>) = identity_paths
1473 .ordered_paths()
1474 .map(|p| p.compact())
1475 .map(|path| {
1476 let highlighted_text =
1477 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1478 path_start_offset += highlighted_text.1.text.len();
1479 highlighted_text
1480 })
1481 .unzip();
1482
1483 let tooltip_title = if paths.len() > 1 {
1484 "Add Folders to this Project"
1485 } else {
1486 "Add Folder to this Project"
1487 };
1488
1489 let prefix = match &location {
1490 SerializedWorkspaceLocation::Remote(options) => {
1491 Some(SharedString::from(options.display_name()))
1492 }
1493 _ => None,
1494 };
1495
1496 let highlighted_match = HighlightedMatchWithPaths {
1497 prefix,
1498 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1499 paths,
1500 active: false,
1501 };
1502
1503 let focus_handle = self.focus_handle.clone();
1504 let secondary_confirm_tooltip = if self.create_new_window {
1505 "Open Project in This Window"
1506 } else {
1507 "Open Project in New Window"
1508 };
1509 let primary_confirm_tooltip = if self.create_new_window {
1510 "Open Project in New Window"
1511 } else {
1512 "Open Project in This Window"
1513 };
1514 let secondary_confirm_icon = if self.create_new_window {
1515 IconName::ThisWindow
1516 } else {
1517 IconName::ArrowUpRight
1518 };
1519
1520 let secondary_actions = h_flex()
1521 .gap_px()
1522 .when(is_local, |this| {
1523 this.child(
1524 IconButton::new("add_to_workspace", IconName::FolderInclude)
1525 .icon_size(IconSize::Small)
1526 .tooltip({
1527 let focus_handle = self.focus_handle.clone();
1528 move |_, cx| {
1529 Tooltip::with_meta_in(
1530 tooltip_title,
1531 Some(&AddToWorkspace),
1532 "As a multi-root folder",
1533 &focus_handle,
1534 cx,
1535 )
1536 }
1537 })
1538 .on_click({
1539 let paths_to_add = paths_to_add.clone();
1540 cx.listener(move |picker, _event, window, cx| {
1541 cx.stop_propagation();
1542 window.prevent_default();
1543 picker.delegate.add_paths_to_project(
1544 paths_to_add.clone(),
1545 window,
1546 cx,
1547 );
1548 })
1549 }),
1550 )
1551 })
1552 .child(
1553 IconButton::new("alternate_open", secondary_confirm_icon)
1554 .icon_size(IconSize::Small)
1555 .tooltip({
1556 move |_, cx| {
1557 Tooltip::for_action_in(
1558 secondary_confirm_tooltip,
1559 &menu::SecondaryConfirm,
1560 &focus_handle,
1561 cx,
1562 )
1563 }
1564 })
1565 .on_click(cx.listener(move |this, _event, window, cx| {
1566 cx.stop_propagation();
1567 window.prevent_default();
1568 this.delegate.set_selected_index(ix, window, cx);
1569 this.delegate.confirm(true, window, cx);
1570 })),
1571 )
1572 .child(
1573 IconButton::new("delete", IconName::Close)
1574 .icon_size(IconSize::Small)
1575 .tooltip({
1576 let focus_handle = self.focus_handle.clone();
1577 move |_, cx| {
1578 Tooltip::for_action_in(
1579 "Remove from Recent Projects",
1580 &RemoveSelected,
1581 &focus_handle,
1582 cx,
1583 )
1584 }
1585 })
1586 .on_click(cx.listener(move |this, _event, window, cx| {
1587 cx.stop_propagation();
1588 window.prevent_default();
1589 this.delegate.delete_recent_project(ix, window, cx)
1590 })),
1591 )
1592 .into_any_element();
1593
1594 let icon = icon_for_remote_connection(match location {
1595 SerializedWorkspaceLocation::Local => None,
1596 SerializedWorkspaceLocation::Remote(options) => Some(options),
1597 });
1598 let show_icon = self.filtered_entries_include_remote_project();
1599
1600 Some(
1601 ListItem::new(ix)
1602 .toggle_state(selected)
1603 .inset(true)
1604 .spacing(ListItemSpacing::Sparse)
1605 .child(
1606 h_flex()
1607 .id("project_info_container")
1608 .w_full()
1609 .min_w_0()
1610 .gap_2p5()
1611 .flex_grow_1()
1612 .when(show_icon, |this| {
1613 this.child(Icon::new(icon).color(Color::Muted))
1614 })
1615 .child({
1616 let mut highlighted = highlighted_match;
1617 if !self.render_paths {
1618 highlighted.paths.clear();
1619 }
1620 highlighted.render(window, cx)
1621 })
1622 .tooltip(move |_, cx| {
1623 Tooltip::with_meta(
1624 primary_confirm_tooltip,
1625 None,
1626 tooltip_path.clone(),
1627 cx,
1628 )
1629 }),
1630 )
1631 .end_slot(secondary_actions)
1632 .show_end_slot_on_hover()
1633 .into_any_element(),
1634 )
1635 }
1636 }
1637 }
1638
1639 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1640 let focus_handle = self.focus_handle.clone();
1641 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1642
1643 let is_already_open_entry = matches!(
1644 self.filtered_entries.get(self.selected_index),
1645 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
1646 );
1647
1648 let show_move_to_new_window = match self.filtered_entries.get(self.selected_index) {
1649 Some(ProjectPickerEntry::ProjectGroup(hit)) => {
1650 self.window_project_groups.len() >= 2
1651 && self
1652 .window_project_groups
1653 .get(hit.candidate_id)
1654 .is_some_and(|key| key.host().is_none())
1655 }
1656 _ => false,
1657 };
1658
1659 if popover_style {
1660 return Some(
1661 v_flex()
1662 .flex_1()
1663 .p_1p5()
1664 .gap_1()
1665 .border_t_1()
1666 .border_color(cx.theme().colors().border_variant)
1667 .child({
1668 ButtonLike::new("open_local_folder")
1669 .child(
1670 h_flex()
1671 .w_full()
1672 .gap_1()
1673 .justify_between()
1674 .child(Label::new("Open Local Folders"))
1675 .child(KeyBinding::for_action_in(
1676 &workspace::Open {
1677 create_new_window: Some(self.create_new_window),
1678 },
1679 &focus_handle,
1680 cx,
1681 )),
1682 )
1683 .on_click({
1684 let workspace = self.workspace.clone();
1685 let create_new_window = self.create_new_window;
1686 move |_, window, cx| {
1687 open_local_project(
1688 workspace.clone(),
1689 create_new_window,
1690 window,
1691 cx,
1692 );
1693 }
1694 })
1695 })
1696 .child(
1697 ButtonLike::new("open_remote_folder")
1698 .child(
1699 h_flex()
1700 .w_full()
1701 .gap_1()
1702 .justify_between()
1703 .child(Label::new("Open Remote Folder"))
1704 .child(KeyBinding::for_action(
1705 &OpenRemote {
1706 from_existing_connection: false,
1707 create_new_window: Some(self.create_new_window),
1708 },
1709 cx,
1710 )),
1711 )
1712 .on_click({
1713 let create_new_window = self.create_new_window;
1714 move |_, window, cx| {
1715 window.dispatch_action(
1716 OpenRemote {
1717 from_existing_connection: false,
1718 create_new_window: Some(create_new_window),
1719 }
1720 .boxed_clone(),
1721 cx,
1722 )
1723 }
1724 }),
1725 )
1726 .into_any(),
1727 );
1728 }
1729
1730 let selected_entry = self.filtered_entries.get(self.selected_index);
1731
1732 let is_current_workspace_entry =
1733 if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
1734 self.window_project_groups
1735 .get(hit.candidate_id)
1736 .is_some_and(|key| self.is_active_project_group(key, cx))
1737 } else {
1738 false
1739 };
1740
1741 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1742 Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1743 Button::new("remove_selected", "Remove Folder")
1744 .key_binding(KeyBinding::for_action_in(
1745 &RemoveSelected,
1746 &focus_handle,
1747 cx,
1748 ))
1749 .on_click(|_, window, cx| {
1750 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1751 })
1752 .into_any_element(),
1753 ),
1754 Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
1755 Button::new("remove_selected", "Remove from Window")
1756 .key_binding(KeyBinding::for_action_in(
1757 &RemoveSelected,
1758 &focus_handle,
1759 cx,
1760 ))
1761 .on_click(|_, window, cx| {
1762 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1763 })
1764 .into_any_element(),
1765 ),
1766 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1767 Button::new("delete_recent", "Remove")
1768 .key_binding(KeyBinding::for_action_in(
1769 &RemoveSelected,
1770 &focus_handle,
1771 cx,
1772 ))
1773 .on_click(|_, window, cx| {
1774 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1775 })
1776 .into_any_element(),
1777 ),
1778 _ => None,
1779 };
1780
1781 Some(
1782 h_flex()
1783 .flex_1()
1784 .p_1p5()
1785 .gap_1()
1786 .justify_end()
1787 .border_t_1()
1788 .border_color(cx.theme().colors().border_variant)
1789 .when_some(secondary_footer_actions, |this, actions| {
1790 this.child(actions)
1791 })
1792 .map(|this| {
1793 if is_already_open_entry {
1794 this.when(show_move_to_new_window, |this| {
1795 this.child({
1796 let window_project_groups = self.window_project_groups.clone();
1797 let selected_index = self.selected_index;
1798 let filtered_entries = self.filtered_entries.clone();
1799 Button::new("move_to_new_window", "New Window")
1800 .key_binding(KeyBinding::for_action_in(
1801 &menu::SecondaryConfirm,
1802 &focus_handle,
1803 cx,
1804 ))
1805 .on_click(move |_, window, cx| {
1806 let key = match filtered_entries.get(selected_index) {
1807 Some(ProjectPickerEntry::ProjectGroup(hit)) => {
1808 window_project_groups.get(hit.candidate_id).cloned()
1809 }
1810 _ => None,
1811 };
1812 if let Some(key) = key {
1813 move_project_group_to_new_window(&key, window, cx);
1814 }
1815 })
1816 })
1817 })
1818 .child(
1819 Button::new("activate", "Activate")
1820 .key_binding(KeyBinding::for_action_in(
1821 &menu::Confirm,
1822 &focus_handle,
1823 cx,
1824 ))
1825 .on_click(|_, window, cx| {
1826 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1827 }),
1828 )
1829 } else if self.create_new_window {
1830 this.child(
1831 Button::new("open_here", "This Window")
1832 .key_binding(KeyBinding::for_action_in(
1833 &menu::SecondaryConfirm,
1834 &focus_handle,
1835 cx,
1836 ))
1837 .on_click(|_, window, cx| {
1838 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1839 }),
1840 )
1841 .child(
1842 Button::new("open_new_window", "Open")
1843 .key_binding(KeyBinding::for_action_in(
1844 &menu::Confirm,
1845 &focus_handle,
1846 cx,
1847 ))
1848 .on_click(|_, window, cx| {
1849 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1850 }),
1851 )
1852 } else {
1853 this.child(
1854 Button::new("open_new_window", "New Window")
1855 .key_binding(KeyBinding::for_action_in(
1856 &menu::SecondaryConfirm,
1857 &focus_handle,
1858 cx,
1859 ))
1860 .on_click(|_, window, cx| {
1861 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1862 }),
1863 )
1864 .child(
1865 Button::new("open_here", "Open")
1866 .key_binding(KeyBinding::for_action_in(
1867 &menu::Confirm,
1868 &focus_handle,
1869 cx,
1870 ))
1871 .on_click(|_, window, cx| {
1872 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1873 }),
1874 )
1875 }
1876 })
1877 .child(Divider::vertical())
1878 .child(
1879 PopoverMenu::new("actions-menu-popover")
1880 .with_handle(self.actions_menu_handle.clone())
1881 .anchor(gpui::Anchor::BottomRight)
1882 .offset(gpui::Point {
1883 x: px(0.0),
1884 y: px(-2.0),
1885 })
1886 .trigger(
1887 Button::new("actions-trigger", "Actions")
1888 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1889 .key_binding(KeyBinding::for_action_in(
1890 &ToggleActionsMenu,
1891 &focus_handle,
1892 cx,
1893 )),
1894 )
1895 .menu({
1896 let focus_handle = focus_handle.clone();
1897 let workspace_handle = self.workspace.clone();
1898 let create_new_window = self.create_new_window;
1899 let open_action = workspace::Open {
1900 create_new_window: Some(create_new_window),
1901 };
1902 let show_add_to_workspace = match selected_entry {
1903 Some(ProjectPickerEntry::RecentProject(hit)) => self
1904 .workspaces
1905 .get(hit.candidate_id)
1906 .map(|workspace| {
1907 matches!(
1908 workspace.location,
1909 SerializedWorkspaceLocation::Local
1910 )
1911 })
1912 .unwrap_or(false),
1913 _ => false,
1914 };
1915
1916 move |window, cx| {
1917 Some(ContextMenu::build(window, cx, {
1918 let focus_handle = focus_handle.clone();
1919 let workspace_handle = workspace_handle.clone();
1920 let open_action = open_action.clone();
1921 move |menu, _, _| {
1922 menu.context(focus_handle)
1923 .when(show_add_to_workspace, |menu| {
1924 menu.action(
1925 "Add Folder to this Project",
1926 AddToWorkspace.boxed_clone(),
1927 )
1928 .separator()
1929 })
1930 .entry(
1931 "Open Local Folders",
1932 Some(open_action.boxed_clone()),
1933 {
1934 let workspace_handle = workspace_handle.clone();
1935 move |window, cx| {
1936 open_local_project(
1937 workspace_handle.clone(),
1938 create_new_window,
1939 window,
1940 cx,
1941 );
1942 }
1943 },
1944 )
1945 .action(
1946 "Open Remote Folder",
1947 OpenRemote {
1948 from_existing_connection: false,
1949 create_new_window: Some(create_new_window),
1950 }
1951 .boxed_clone(),
1952 )
1953 }
1954 }))
1955 }
1956 }),
1957 )
1958 .into_any(),
1959 )
1960 }
1961}
1962
1963fn icon_for_project_group(key: &ProjectGroupKey) -> IconName {
1964 let host = key.host();
1965 icon_for_remote_connection(host.as_ref())
1966}
1967
1968pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1969 match options {
1970 None => IconName::Screen,
1971 Some(options) => match options {
1972 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1973 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1974 RemoteConnectionOptions::Docker(_) => IconName::Box,
1975 #[cfg(any(test, feature = "test-support"))]
1976 RemoteConnectionOptions::Mock(_) => IconName::Server,
1977 },
1978 }
1979}
1980
1981// Compute the highlighted text for the name and path
1982pub(crate) fn highlights_for_path(
1983 path: &Path,
1984 match_positions: &Vec<usize>,
1985 path_start_offset: usize,
1986) -> (Option<HighlightedMatch>, HighlightedMatch) {
1987 let path_string = path.to_string_lossy();
1988 let path_text = path_string.to_string();
1989 let path_byte_len = path_text.len();
1990 // Get the subset of match highlight positions that line up with the given path.
1991 // Also adjusts them to start at the path start
1992 let path_positions = match_positions
1993 .iter()
1994 .copied()
1995 .skip_while(|position| *position < path_start_offset)
1996 .take_while(|position| *position < path_start_offset + path_byte_len)
1997 .map(|position| position - path_start_offset)
1998 .collect::<Vec<_>>();
1999
2000 // Again subset the highlight positions to just those that line up with the file_name
2001 // again adjusted to the start of the file_name
2002 let file_name_text_and_positions = path.file_name().map(|file_name| {
2003 let file_name_text = file_name.to_string_lossy().into_owned();
2004 let file_name_start_byte = path_byte_len - file_name_text.len();
2005 let highlight_positions = path_positions
2006 .iter()
2007 .copied()
2008 .skip_while(|position| *position < file_name_start_byte)
2009 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
2010 .map(|position| position - file_name_start_byte)
2011 .collect::<Vec<_>>();
2012 HighlightedMatch {
2013 text: file_name_text,
2014 highlight_positions,
2015 color: Color::Default,
2016 }
2017 });
2018
2019 (
2020 file_name_text_and_positions,
2021 HighlightedMatch {
2022 text: path_text,
2023 highlight_positions: path_positions,
2024 color: Color::Default,
2025 },
2026 )
2027}
2028
2029fn move_project_group_to_new_window(key: &ProjectGroupKey, window: &mut Window, cx: &mut App) {
2030 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
2031 let key = key.clone();
2032 cx.defer(move |cx| {
2033 handle
2034 .update(cx, |multi_workspace, window, cx| {
2035 multi_workspace
2036 .open_project_group_in_new_window(&key, window, cx)
2037 .detach_and_log_err(cx);
2038 })
2039 .log_err();
2040 });
2041 }
2042}
2043
2044fn open_local_project(
2045 workspace: WeakEntity<Workspace>,
2046 create_new_window: bool,
2047 window: &mut Window,
2048 cx: &mut App,
2049) {
2050 use gpui::PathPromptOptions;
2051 use project::DirectoryLister;
2052
2053 let Some(workspace) = workspace.upgrade() else {
2054 return;
2055 };
2056
2057 let paths = workspace.update(cx, |workspace, cx| {
2058 workspace.prompt_for_open_path(
2059 PathPromptOptions {
2060 files: true,
2061 directories: true,
2062 multiple: true,
2063 prompt: None,
2064 },
2065 DirectoryLister::Local(
2066 workspace.project().clone(),
2067 workspace.app_state().fs.clone(),
2068 ),
2069 window,
2070 cx,
2071 )
2072 });
2073
2074 let multi_workspace_handle = window.window_handle().downcast::<MultiWorkspace>();
2075 window
2076 .spawn(cx, async move |cx| {
2077 let Some(paths) = paths.await.log_err().flatten() else {
2078 return;
2079 };
2080 if !create_new_window {
2081 if let Some(handle) = multi_workspace_handle {
2082 if let Some(task) = handle
2083 .update(cx, |multi_workspace, window, cx| {
2084 multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
2085 })
2086 .log_err()
2087 {
2088 task.await.log_err();
2089 }
2090 return;
2091 }
2092 }
2093 if let Some(task) = workspace
2094 .update_in(cx, |workspace, window, cx| {
2095 workspace.open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx)
2096 })
2097 .log_err()
2098 {
2099 task.await.log_err();
2100 }
2101 })
2102 .detach();
2103}
2104
2105impl RecentProjectsDelegate {
2106 fn open_recent_projects(
2107 &mut self,
2108 candidate_id: usize,
2109 secondary: bool,
2110 window: &mut Window,
2111 cx: &mut Context<Picker<Self>>,
2112 ) {
2113 let Some(workspace) = self.workspace.upgrade() else {
2114 return;
2115 };
2116 let Some(candidate_workspace) = self.workspaces.get(candidate_id) else {
2117 return;
2118 };
2119
2120 let replace_current_window = self.create_new_window == secondary;
2121 let candidate_workspace_id = candidate_workspace.workspace_id;
2122 let candidate_workspace_location = candidate_workspace.location.clone();
2123 let candidate_workspace_paths = candidate_workspace.paths.clone();
2124
2125 workspace.update(cx, |workspace, cx| {
2126 if workspace.database_id() == Some(candidate_workspace_id) {
2127 return;
2128 }
2129 match candidate_workspace_location {
2130 SerializedWorkspaceLocation::Local => {
2131 let paths = candidate_workspace_paths.paths().to_vec();
2132 if replace_current_window {
2133 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
2134 cx.defer(move |cx| {
2135 if let Some(task) = handle
2136 .update(cx, |multi_workspace, window, cx| {
2137 multi_workspace.open_project(
2138 paths,
2139 OpenMode::Activate,
2140 window,
2141 cx,
2142 )
2143 })
2144 .log_err()
2145 {
2146 task.detach_and_log_err(cx);
2147 }
2148 });
2149 }
2150 return;
2151 } else {
2152 workspace
2153 .open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx)
2154 .detach_and_prompt_err(
2155 "Failed to open project",
2156 window,
2157 cx,
2158 |_, _, _| None,
2159 );
2160 }
2161 }
2162 SerializedWorkspaceLocation::Remote(mut connection) => {
2163 let app_state = workspace.app_state().clone();
2164 let replace_window = if replace_current_window {
2165 window.window_handle().downcast::<MultiWorkspace>()
2166 } else {
2167 None
2168 };
2169 let open_options = OpenOptions {
2170 requesting_window: replace_window,
2171 ..Default::default()
2172 };
2173 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
2174 RemoteSettings::get_global(cx)
2175 .fill_connection_options_from_settings(connection);
2176 };
2177 let paths = candidate_workspace_paths.paths().to_vec();
2178 cx.spawn_in(window, async move |_, cx| {
2179 open_remote_project(connection.clone(), paths, app_state, open_options, cx)
2180 .await
2181 })
2182 .detach_and_prompt_err(
2183 "Failed to open project",
2184 window,
2185 cx,
2186 |_, _, _| None,
2187 );
2188 }
2189 }
2190 });
2191 cx.emit(DismissEvent);
2192 }
2193
2194 fn add_paths_to_project(
2195 &mut self,
2196 paths: Vec<PathBuf>,
2197 window: &mut Window,
2198 cx: &mut Context<Picker<Self>>,
2199 ) {
2200 let Some(workspace) = self.workspace.upgrade() else {
2201 return;
2202 };
2203 let open_paths_task = workspace.update(cx, |workspace, cx| {
2204 workspace.open_paths(
2205 paths,
2206 OpenOptions {
2207 visible: Some(OpenVisible::All),
2208 ..Default::default()
2209 },
2210 None,
2211 window,
2212 cx,
2213 )
2214 });
2215 cx.spawn_in(window, async move |picker, cx| {
2216 let _result = open_paths_task.await;
2217 picker
2218 .update_in(cx, |picker, window, cx| {
2219 let Some(workspace) = picker.delegate.workspace.upgrade() else {
2220 return;
2221 };
2222 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
2223 let query = picker.query(cx);
2224 picker.update_matches(query, window, cx);
2225 })
2226 .ok();
2227 })
2228 .detach();
2229 }
2230
2231 /// Returns the new selection index after the entry at `deleted_index`
2232 /// is removed.
2233 ///
2234 /// - Prefers the nearest entry matching `prefer_section` so the user
2235 /// stays in the same section they were navigating.
2236 /// - Falls back to any other selectable entry so the picker doesn't
2237 /// land on a header.
2238 fn replacement_index_after_deletion(
2239 &self,
2240 deleted_index: usize,
2241 prefer_previous: bool,
2242 prefer_section: fn(&ProjectPickerEntry) -> bool,
2243 ) -> Option<usize> {
2244 let replacement_index = |matches_entry: fn(&ProjectPickerEntry) -> bool| {
2245 let next_index = self
2246 .filtered_entries
2247 .iter()
2248 .enumerate()
2249 .skip(deleted_index)
2250 .find_map(|(index, entry)| matches_entry(entry).then_some(index));
2251 let previous_index = self
2252 .filtered_entries
2253 .iter()
2254 .enumerate()
2255 .take(deleted_index.min(self.filtered_entries.len()))
2256 .rev()
2257 .find_map(|(index, entry)| matches_entry(entry).then_some(index));
2258
2259 if prefer_previous {
2260 previous_index.or(next_index)
2261 } else {
2262 next_index.or(previous_index)
2263 }
2264 };
2265
2266 replacement_index(prefer_section).or_else(|| replacement_index(is_selectable_entry))
2267 }
2268
2269 fn update_picker_after_recent_project_deletion(
2270 picker: &mut Picker<Self>,
2271 deleted_index: usize,
2272 workspaces: Vec<RecentWorkspace>,
2273 window: &mut Window,
2274 cx: &mut Context<Picker<Self>>,
2275 ) {
2276 let prefer_previous = picker.is_scrolled_to_end() == Some(true);
2277 picker.delegate.set_workspaces(workspaces);
2278 picker.delegate.snap_selection_to_first_non_header_match = false;
2279 picker.update_matches_with_options(
2280 picker.query(cx),
2281 ScrollBehavior::PreserveOffset,
2282 window,
2283 cx,
2284 );
2285 if let Some(replacement_index) = picker.delegate.replacement_index_after_deletion(
2286 deleted_index,
2287 prefer_previous,
2288 |entry| matches!(entry, ProjectPickerEntry::RecentProject(_)),
2289 ) {
2290 picker.set_selected_index(replacement_index, None, false, window, cx);
2291 }
2292 }
2293
2294 fn delete_recent_project(
2295 &self,
2296 ix: usize,
2297 window: &mut Window,
2298 cx: &mut Context<Picker<Self>>,
2299 ) {
2300 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
2301 self.filtered_entries.get(ix)
2302 {
2303 let Some(recent_workspace) = self.workspaces.get(selected_match.candidate_id).cloned()
2304 else {
2305 return;
2306 };
2307 let fs = self
2308 .workspace
2309 .upgrade()
2310 .map(|ws| ws.read(cx).app_state().fs.clone());
2311 let db = WorkspaceDb::global(cx);
2312 cx.spawn_in(window, async move |this, cx| {
2313 let Some(fs) = fs else { return };
2314 let deleted_workspace_ids = db
2315 .delete_recent_workspace_group(&recent_workspace)
2316 .await
2317 .log_err()
2318 .unwrap_or_default();
2319 let workspaces = db
2320 .recent_project_workspaces(fs.as_ref())
2321 .await
2322 .unwrap_or_default();
2323 this.update_in(cx, move |picker, window, cx| {
2324 Self::update_picker_after_recent_project_deletion(
2325 picker, ix, workspaces, window, cx,
2326 );
2327 // After deleting a project, we want to update the history manager to reflect the change.
2328 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
2329 if let Some(history_manager) = HistoryManager::global(cx) {
2330 history_manager.update(cx, |this, cx| {
2331 for workspace_id in &deleted_workspace_ids {
2332 this.delete_history(*workspace_id, cx);
2333 }
2334 });
2335 }
2336 })
2337 .ok();
2338 })
2339 .detach();
2340 }
2341 }
2342
2343 fn remove_open_folder(
2344 picker: &mut Picker<Self>,
2345 worktree_id: WorktreeId,
2346 window: &mut Window,
2347 cx: &mut Context<Picker<Self>>,
2348 ) {
2349 let Some(workspace) = picker.delegate.workspace.upgrade() else {
2350 return;
2351 };
2352
2353 let old_key = workspace.read(cx).project_group_key(cx);
2354 workspace.update(cx, |workspace, cx| {
2355 let project = workspace.project().clone();
2356 project.update(cx, |project, cx| {
2357 project.remove_worktree(worktree_id, cx);
2358 });
2359 });
2360
2361 let new_key = workspace.read(cx).project_group_key(cx);
2362 if let Some(entry) = picker
2363 .delegate
2364 .window_project_groups
2365 .iter_mut()
2366 .find(|key| **key == old_key)
2367 {
2368 *entry = new_key;
2369 }
2370
2371 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
2372 let query = picker.query(cx);
2373 picker.update_matches(query, window, cx);
2374 }
2375
2376 fn remove_project_group(
2377 &mut self,
2378 key: ProjectGroupKey,
2379 window: &mut Window,
2380 cx: &mut Context<Picker<Self>>,
2381 ) {
2382 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
2383 let key_for_remove = key.clone();
2384 cx.defer(move |cx| {
2385 handle
2386 .update(cx, |multi_workspace, window, cx| {
2387 multi_workspace
2388 .remove_project_group(&key_for_remove, window, cx)
2389 .detach_and_log_err(cx);
2390 })
2391 .log_err();
2392 });
2393 }
2394
2395 self.window_project_groups.retain(|k| k != &key);
2396 }
2397
2398 fn is_current_workspace(
2399 &self,
2400 workspace_id: WorkspaceId,
2401 cx: &mut Context<Picker<Self>>,
2402 ) -> bool {
2403 if let Some(workspace) = self.workspace.upgrade() {
2404 let workspace = workspace.read(cx);
2405 if Some(workspace_id) == workspace.database_id() {
2406 return true;
2407 }
2408 }
2409
2410 false
2411 }
2412
2413 fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
2414 if let Some(workspace) = self.workspace.upgrade() {
2415 return workspace.read(cx).project_group_key(cx) == *key;
2416 }
2417 false
2418 }
2419
2420 fn is_in_current_window_groups(&self, workspace: &RecentWorkspace) -> bool {
2421 self.window_project_groups
2422 .iter()
2423 .any(|key| key.matches(&workspace.project_group_key()))
2424 }
2425
2426 fn is_open_folder(&self, workspace: &RecentWorkspace) -> bool {
2427 if self.open_folders.is_empty() {
2428 return false;
2429 }
2430
2431 let workspace_host = match &workspace.location {
2432 SerializedWorkspaceLocation::Local => None,
2433 SerializedWorkspaceLocation::Remote(options) => Some(options),
2434 };
2435
2436 for workspace_path in workspace.paths.paths() {
2437 for open_folder in &self.open_folders {
2438 if workspace_path == &open_folder.path
2439 && same_remote_connection_identity(
2440 workspace_host,
2441 open_folder.connection_options.as_ref(),
2442 )
2443 {
2444 return true;
2445 }
2446 }
2447 }
2448
2449 false
2450 }
2451
2452 fn is_valid_recent_candidate(
2453 &self,
2454 workspace: &RecentWorkspace,
2455 cx: &mut Context<Picker<Self>>,
2456 ) -> bool {
2457 !self.is_current_workspace(workspace.workspace_id, cx)
2458 && !self.is_in_current_window_groups(workspace)
2459 && !self.is_open_folder(workspace)
2460 }
2461}
2462
2463#[cfg(test)]
2464mod tests {
2465 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
2466
2467 use serde_json::json;
2468 use settings::SettingsStore;
2469 use util::path;
2470 use workspace::{AppState, PathList, open_paths};
2471
2472 use super::*;
2473
2474 // Test picker for the empty query:
2475 //
2476 // [0] Header("Current Folders")
2477 // [1] OpenFolder(0)
2478 // [2] OpenFolder(1)
2479 // [3] Header("This Window")
2480 // [4] ProjectGroup(0)
2481 // [5] ProjectGroup(1)
2482 // [6] Header("Recent Projects")
2483 // [7..=26] RecentProject(0..=19)
2484 //
2485 const RECENT_PROJECT_COUNT: usize = 20;
2486 const FIRST_RECENT_PROJECT: usize = 7;
2487 const LAST_RECENT_PROJECT: usize = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT - 1;
2488
2489 fn open_folder(index: usize) -> OpenFolderEntry {
2490 OpenFolderEntry {
2491 worktree_id: WorktreeId::from_usize(index),
2492 name: format!("project-folder-{index}").into(),
2493 path: PathBuf::from(format!("/current/project-folder-{index}")),
2494 branch: None,
2495 is_active: false,
2496 connection_options: None,
2497 }
2498 }
2499
2500 fn project_group(index: usize) -> ProjectGroupKey {
2501 ProjectGroupKey::new(
2502 None,
2503 PathList::new(&[PathBuf::from(format!("/this-window/project-{index}"))]),
2504 )
2505 }
2506
2507 fn remote_project_group(index: usize) -> ProjectGroupKey {
2508 ProjectGroupKey::new(
2509 Some(RemoteConnectionOptions::Mock(
2510 remote::MockConnectionOptions { id: index as u64 },
2511 )),
2512 PathList::new(&[PathBuf::from(format!(
2513 "/this-window/remote-project-{index}"
2514 ))]),
2515 )
2516 }
2517
2518 fn recent_workspace(index: usize) -> RecentWorkspace {
2519 let paths = PathList::new(&[PathBuf::from(format!("/recent/project-{index:02}"))]);
2520 RecentWorkspace {
2521 workspace_id: WorkspaceId::from_i64(index as i64),
2522 location: SerializedWorkspaceLocation::Local,
2523 paths: paths.clone(),
2524 identity_paths: paths,
2525 timestamp: Utc::now(),
2526 }
2527 }
2528
2529 fn recent_workspaces() -> Vec<RecentWorkspace> {
2530 (0..RECENT_PROJECT_COUNT).map(recent_workspace).collect()
2531 }
2532
2533 fn draw(cx: &mut VisualTestContext) {
2534 cx.update(|window, cx| window.draw(cx).clear(cx));
2535 }
2536
2537 fn build_picker(
2538 cx: &mut TestAppContext,
2539 ) -> (
2540 Entity<Picker<RecentProjectsDelegate>>,
2541 &mut VisualTestContext,
2542 ) {
2543 init_test(cx);
2544 let (picker, cx) = cx.add_window_view(|window, cx| {
2545 let mut delegate = RecentProjectsDelegate::new(
2546 WeakEntity::new_invalid(),
2547 false,
2548 cx.focus_handle(),
2549 vec![open_folder(0), open_folder(1)],
2550 vec![project_group(0), project_group(1)],
2551 ProjectPickerStyle::Modal,
2552 );
2553 delegate.set_workspaces(recent_workspaces());
2554 Picker::list(delegate, window, cx)
2555 .list_measure_all()
2556 .show_scrollbar(true)
2557 .max_height(Rems::from_pixels(px(240.0), window))
2558 });
2559 draw(cx);
2560 (picker, cx)
2561 }
2562
2563 fn scroll_to_and_select(
2564 picker: &Entity<Picker<RecentProjectsDelegate>>,
2565 cx: &mut VisualTestContext,
2566 index: usize,
2567 ) -> usize {
2568 picker.update_in(cx, |picker, window, cx| {
2569 picker.set_selected_index(index, None, true, window, cx);
2570 });
2571 draw(cx);
2572 picker.update(cx, |picker, _| picker.logical_scroll_top_index())
2573 }
2574
2575 fn delete_recent_project_in_picker(
2576 picker: &Entity<Picker<RecentProjectsDelegate>>,
2577 cx: &mut VisualTestContext,
2578 index: usize,
2579 ) {
2580 picker.update_in(cx, |picker, window, cx| {
2581 let Some(ProjectPickerEntry::RecentProject(hit)) =
2582 picker.delegate.filtered_entries.get(index)
2583 else {
2584 panic!("expected entry at {index} to be a recent project");
2585 };
2586 let mut workspaces = picker.delegate.workspaces.clone();
2587 workspaces.remove(hit.candidate_id);
2588 RecentProjectsDelegate::update_picker_after_recent_project_deletion(
2589 picker, index, workspaces, window, cx,
2590 );
2591 });
2592 }
2593
2594 #[track_caller]
2595 fn assert_scroll_top_is(
2596 picker: &Entity<Picker<RecentProjectsDelegate>>,
2597 cx: &mut VisualTestContext,
2598 expected: usize,
2599 phase: &str,
2600 ) {
2601 picker.update(cx, |picker, _| {
2602 assert_eq!(
2603 picker.logical_scroll_top_index(),
2604 expected,
2605 "scroll top should remain at {expected} ({phase})"
2606 );
2607 assert_selected_entry_is_recent_project(picker);
2608 });
2609 }
2610
2611 #[track_caller]
2612 fn assert_pinned_to_bottom(
2613 picker: &Entity<Picker<RecentProjectsDelegate>>,
2614 cx: &mut VisualTestContext,
2615 phase: &str,
2616 ) {
2617 picker.update(cx, |picker, _| {
2618 assert_eq!(
2619 picker.is_scrolled_to_end(),
2620 Some(true),
2621 "picker should remain pinned to the bottom ({phase})"
2622 );
2623 assert!(
2624 picker.logical_scroll_top_index() > 0,
2625 "picker should not jump to the top while pinned to the bottom ({phase})"
2626 );
2627 assert_selected_entry_is_recent_project(picker);
2628 });
2629 }
2630
2631 #[track_caller]
2632 fn assert_selected_entry_is_recent_project(picker: &Picker<RecentProjectsDelegate>) {
2633 assert!(matches!(
2634 picker
2635 .delegate
2636 .filtered_entries
2637 .get(picker.delegate.selected_index),
2638 Some(ProjectPickerEntry::RecentProject(_))
2639 ));
2640 }
2641
2642 #[gpui::test]
2643 fn this_window_project_icons_use_each_project_group_host(cx: &mut TestAppContext) {
2644 init_test(cx);
2645
2646 let mut delegate = RecentProjectsDelegate::new(
2647 WeakEntity::new_invalid(),
2648 false,
2649 cx.update(|cx| cx.focus_handle()),
2650 Vec::new(),
2651 vec![project_group(0), remote_project_group(1)],
2652 ProjectPickerStyle::Modal,
2653 );
2654 delegate.filtered_entries = vec![
2655 ProjectPickerEntry::ProjectGroup(StringMatch {
2656 candidate_id: 0,
2657 score: 0.0,
2658 positions: Vec::new(),
2659 string: Default::default(),
2660 }),
2661 ProjectPickerEntry::ProjectGroup(StringMatch {
2662 candidate_id: 1,
2663 score: 0.0,
2664 positions: Vec::new(),
2665 string: Default::default(),
2666 }),
2667 ];
2668
2669 assert!(!delegate.entry_is_remote_project(&delegate.filtered_entries[0]));
2670 assert!(delegate.entry_is_remote_project(&delegate.filtered_entries[1]));
2671 assert!(delegate.filtered_entries_include_remote_project());
2672 assert_eq!(
2673 icon_for_project_group(&delegate.window_project_groups[0]),
2674 IconName::Screen
2675 );
2676 assert_eq!(
2677 icon_for_project_group(&delegate.window_project_groups[1]),
2678 IconName::Server
2679 );
2680 }
2681
2682 #[gpui::test]
2683 fn is_open_folder_distinguishes_local_and_remote(cx: &mut TestAppContext) {
2684 init_test(cx);
2685
2686 let shared_path = PathBuf::from("/repo");
2687 let local_open_folder = OpenFolderEntry {
2688 worktree_id: WorktreeId::from_usize(0),
2689 name: "repo".into(),
2690 path: shared_path.clone(),
2691 branch: None,
2692 is_active: false,
2693 connection_options: None,
2694 };
2695
2696 let delegate = RecentProjectsDelegate::new(
2697 WeakEntity::new_invalid(),
2698 false,
2699 cx.update(|cx| cx.focus_handle()),
2700 vec![local_open_folder],
2701 Vec::new(),
2702 ProjectPickerStyle::Modal,
2703 );
2704
2705 let paths = PathList::new(&[shared_path]);
2706 let local_workspace = RecentWorkspace {
2707 workspace_id: WorkspaceId::from_i64(1),
2708 location: SerializedWorkspaceLocation::Local,
2709 paths: paths.clone(),
2710 identity_paths: paths.clone(),
2711 timestamp: Utc::now(),
2712 };
2713 let remote_workspace = RecentWorkspace {
2714 workspace_id: WorkspaceId::from_i64(2),
2715 location: SerializedWorkspaceLocation::Remote(RemoteConnectionOptions::Mock(
2716 remote::MockConnectionOptions { id: 0 },
2717 )),
2718 paths: paths.clone(),
2719 identity_paths: paths,
2720 timestamp: Utc::now(),
2721 };
2722
2723 // A local open folder should hide only the matching local recent
2724 // project, not a remote checkout that shares the same path.
2725 assert!(delegate.is_open_folder(&local_workspace));
2726 assert!(!delegate.is_open_folder(&remote_workspace));
2727 }
2728
2729 #[gpui::test]
2730 fn deleting_top_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
2731 let target = FIRST_RECENT_PROJECT;
2732 let (picker, cx) = build_picker(cx);
2733 let scroll_top = scroll_to_and_select(&picker, cx, target);
2734 assert!(
2735 scroll_top > 0,
2736 "test should start scrolled away from the top"
2737 );
2738
2739 delete_recent_project_in_picker(&picker, cx, target);
2740 assert_scroll_top_is(&picker, cx, scroll_top, "after delete");
2741
2742 // The picker re-runs layout on the next frame; the scroll position
2743 // must still be preserved after that redraw.
2744 draw(cx);
2745 assert_scroll_top_is(&picker, cx, scroll_top, "after redraw");
2746 }
2747
2748 #[gpui::test]
2749 fn deleting_middle_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
2750 let target = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT / 2;
2751 let (picker, cx) = build_picker(cx);
2752 let scroll_top = scroll_to_and_select(&picker, cx, target);
2753 assert!(
2754 scroll_top > 0,
2755 "test should start scrolled away from the top"
2756 );
2757
2758 delete_recent_project_in_picker(&picker, cx, target);
2759 assert_scroll_top_is(&picker, cx, scroll_top, "after delete");
2760
2761 draw(cx);
2762 assert_scroll_top_is(&picker, cx, scroll_top, "after redraw");
2763 }
2764
2765 #[gpui::test]
2766 fn deleting_last_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
2767 let target = LAST_RECENT_PROJECT;
2768 let (picker, cx) = build_picker(cx);
2769 scroll_to_and_select(&picker, cx, target);
2770
2771 picker.update(cx, |picker, _| {
2772 assert_eq!(
2773 picker.is_scrolled_to_end(),
2774 Some(true),
2775 "selecting the last entry should leave the picker pinned to the bottom"
2776 );
2777 });
2778
2779 delete_recent_project_in_picker(&picker, cx, target);
2780 assert_pinned_to_bottom(&picker, cx, "after delete");
2781
2782 draw(cx);
2783 assert_pinned_to_bottom(&picker, cx, "after redraw");
2784 }
2785
2786 #[gpui::test]
2787 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2788 let app_state = init_test(cx);
2789
2790 app_state
2791 .fs
2792 .as_fake()
2793 .insert_tree(
2794 path!("/project"),
2795 json!({
2796 ".devcontainer": {
2797 "devcontainer.json": "{}"
2798 },
2799 "src": {
2800 "main.rs": "fn main() {}"
2801 }
2802 }),
2803 )
2804 .await;
2805
2806 // Open a file path (not a directory) so that the worktree root is a
2807 // file. This means `active_project_directory` returns `None`, which
2808 // causes `DevContainerContext::from_workspace` to return `None`,
2809 // preventing `open_dev_container` from spawning real I/O (docker
2810 // commands, shell environment loading) that is incompatible with the
2811 // test scheduler. The modal is still created and the re-entrancy
2812 // guard that this test validates is still exercised.
2813 cx.update(|cx| {
2814 open_paths(
2815 &[PathBuf::from(path!("/project/src/main.rs"))],
2816 app_state,
2817 workspace::OpenOptions::default(),
2818 cx,
2819 )
2820 })
2821 .await
2822 .unwrap();
2823
2824 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2825 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2826
2827 cx.run_until_parked();
2828
2829 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2830 // -> Workspace::update -> toggle_modal -> new_dev_container.
2831 // Before the fix, this panicked with "cannot read workspace::Workspace while
2832 // it is already being updated" because new_dev_container and open_dev_container
2833 // tried to read the Workspace entity through a WeakEntity handle while it was
2834 // already leased by the outer update.
2835 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2836
2837 multi_workspace
2838 .update(cx, |multi_workspace, _, cx| {
2839 let modal = multi_workspace
2840 .workspace()
2841 .read(cx)
2842 .active_modal::<RemoteServerProjects>(cx);
2843 assert!(
2844 modal.is_some(),
2845 "Dev container modal should be open after dispatching OpenDevContainer"
2846 );
2847 })
2848 .unwrap();
2849 }
2850
2851 #[gpui::test]
2852 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2853 let app_state = init_test(cx);
2854
2855 app_state
2856 .fs
2857 .as_fake()
2858 .insert_tree(
2859 path!("/project"),
2860 json!({
2861 ".devcontainer": {
2862 "rust": {
2863 "devcontainer.json": "{}"
2864 },
2865 "python": {
2866 "devcontainer.json": "{}"
2867 }
2868 },
2869 "src": {
2870 "main.rs": "fn main() {}"
2871 }
2872 }),
2873 )
2874 .await;
2875
2876 cx.update(|cx| {
2877 open_paths(
2878 &[PathBuf::from(path!("/project"))],
2879 app_state,
2880 workspace::OpenOptions::default(),
2881 cx,
2882 )
2883 })
2884 .await
2885 .unwrap();
2886
2887 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2888 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2889
2890 cx.run_until_parked();
2891
2892 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2893
2894 multi_workspace
2895 .update(cx, |multi_workspace, _, cx| {
2896 let modal = multi_workspace
2897 .workspace()
2898 .read(cx)
2899 .active_modal::<RemoteServerProjects>(cx);
2900 assert!(
2901 modal.is_some(),
2902 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2903 );
2904 })
2905 .unwrap();
2906 }
2907
2908 #[gpui::test]
2909 async fn test_open_local_project_reuses_multi_workspace_window(cx: &mut TestAppContext) {
2910 let app_state = init_test(cx);
2911
2912 // Disable system path prompts so the injected mock is used.
2913 cx.update(|cx| {
2914 SettingsStore::update_global(cx, |store, cx| {
2915 store.update_user_settings(cx, |settings| {
2916 settings.workspace.use_system_path_prompts = Some(false);
2917 });
2918 });
2919 });
2920
2921 app_state
2922 .fs
2923 .as_fake()
2924 .insert_tree(
2925 path!("/initial-project"),
2926 json!({ "src": { "main.rs": "" } }),
2927 )
2928 .await;
2929 app_state
2930 .fs
2931 .as_fake()
2932 .insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
2933 .await;
2934
2935 cx.update(|cx| {
2936 open_paths(
2937 &[PathBuf::from(path!("/initial-project"))],
2938 app_state.clone(),
2939 workspace::OpenOptions::default(),
2940 cx,
2941 )
2942 })
2943 .await
2944 .unwrap();
2945
2946 let initial_window_count = cx.update(|cx| cx.windows().len());
2947 assert_eq!(initial_window_count, 1);
2948
2949 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2950 cx.run_until_parked();
2951
2952 let workspace = multi_workspace
2953 .read_with(cx, |mw, _| mw.workspace().clone())
2954 .unwrap();
2955
2956 // Set up the prompt mock to return the new project path.
2957 workspace.update(cx, |workspace, _cx| {
2958 workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
2959 let (tx, rx) = futures::channel::oneshot::channel();
2960 tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
2961 .ok();
2962 rx
2963 }));
2964 });
2965
2966 // Call open_local_project with create_new_window: false.
2967 let weak_workspace = workspace.downgrade();
2968 multi_workspace
2969 .update(cx, |_, window, cx| {
2970 open_local_project(weak_workspace, false, window, cx);
2971 })
2972 .unwrap();
2973
2974 cx.run_until_parked();
2975
2976 // Should NOT have opened a new window.
2977 let final_window_count = cx.update(|cx| cx.windows().len());
2978 assert_eq!(
2979 final_window_count, initial_window_count,
2980 "open_local_project with create_new_window=false should reuse the current multi-workspace window"
2981 );
2982 }
2983
2984 #[gpui::test]
2985 async fn test_open_local_project_new_window_creates_new_window(cx: &mut TestAppContext) {
2986 let app_state = init_test(cx);
2987
2988 // Disable system path prompts so the injected mock is used.
2989 cx.update(|cx| {
2990 SettingsStore::update_global(cx, |store, cx| {
2991 store.update_user_settings(cx, |settings| {
2992 settings.workspace.use_system_path_prompts = Some(false);
2993 });
2994 });
2995 });
2996
2997 app_state
2998 .fs
2999 .as_fake()
3000 .insert_tree(
3001 path!("/initial-project"),
3002 json!({ "src": { "main.rs": "" } }),
3003 )
3004 .await;
3005 app_state
3006 .fs
3007 .as_fake()
3008 .insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
3009 .await;
3010
3011 cx.update(|cx| {
3012 open_paths(
3013 &[PathBuf::from(path!("/initial-project"))],
3014 app_state.clone(),
3015 workspace::OpenOptions::default(),
3016 cx,
3017 )
3018 })
3019 .await
3020 .unwrap();
3021
3022 let initial_window_count = cx.update(|cx| cx.windows().len());
3023 assert_eq!(initial_window_count, 1);
3024
3025 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
3026 cx.run_until_parked();
3027
3028 let workspace = multi_workspace
3029 .read_with(cx, |mw, _| mw.workspace().clone())
3030 .unwrap();
3031
3032 // Set up the prompt mock to return the new project path.
3033 workspace.update(cx, |workspace, _cx| {
3034 workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
3035 let (tx, rx) = futures::channel::oneshot::channel();
3036 tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
3037 .ok();
3038 rx
3039 }));
3040 });
3041
3042 // Call open_local_project with create_new_window: true.
3043 let weak_workspace = workspace.downgrade();
3044 multi_workspace
3045 .update(cx, |_, window, cx| {
3046 open_local_project(weak_workspace, true, window, cx);
3047 })
3048 .unwrap();
3049
3050 cx.run_until_parked();
3051
3052 // Should have opened a new window.
3053 let final_window_count = cx.update(|cx| cx.windows().len());
3054 assert_eq!(
3055 final_window_count,
3056 initial_window_count + 1,
3057 "open_local_project with create_new_window=true should open a new window"
3058 );
3059 }
3060
3061 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
3062 cx.update(|cx| {
3063 let state = AppState::test(cx);
3064 crate::init(cx);
3065 editor::init(cx);
3066 state
3067 })
3068 }
3069
3070 #[gpui::test]
3071 async fn test_remote_project_group_confirm_does_not_create_local_workspace(
3072 cx: &mut TestAppContext,
3073 ) {
3074 // Regression test: confirming a ProjectGroup entry with a remote host
3075 // should call find_or_create_workspace with the host, not
3076 // find_or_create_local_workspace.
3077 let app_state = init_test(cx);
3078 app_state
3079 .fs
3080 .as_fake()
3081 .insert_tree("/local", json!({}))
3082 .await;
3083
3084 cx.update(|cx| {
3085 open_paths(
3086 &[PathBuf::from("/local")],
3087 app_state,
3088 workspace::OpenOptions::default(),
3089 cx,
3090 )
3091 })
3092 .await
3093 .unwrap();
3094
3095 cx.run_until_parked();
3096
3097 let mw = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
3098 let remote_key = remote_project_group(1);
3099
3100 // Get workspace info via WindowHandle::read_with (returns Result)
3101 let (workspace, groups, fh) = mw
3102 .read_with(cx, |mw, _cx| {
3103 let ws = mw.workspace().clone();
3104 (
3105 ws.clone(),
3106 mw.project_group_keys(),
3107 ws.read(_cx).focus_handle(_cx),
3108 )
3109 })
3110 .unwrap();
3111
3112 let mut augmented_groups = groups.clone();
3113 augmented_groups.push(remote_key.clone());
3114
3115 // Create the popover (same as the title bar does)
3116 let popover: Entity<RecentProjects> = cx.update(|cx| {
3117 let window = cx.windows()[0];
3118 window
3119 .update(cx, |_, window, cx| {
3120 RecentProjects::popover(
3121 workspace.downgrade(),
3122 augmented_groups,
3123 Some(false),
3124 fh,
3125 window,
3126 cx,
3127 )
3128 })
3129 .unwrap()
3130 });
3131
3132 cx.run_until_parked();
3133
3134 // Get the picker from the popover
3135 let picker: Entity<Picker<RecentProjectsDelegate>> = cx.update(|cx| {
3136 let window = cx.windows()[0];
3137 window
3138 .update(cx, |_, _window, cx| popover.read(cx).picker.clone())
3139 .unwrap()
3140 });
3141
3142 cx.run_until_parked();
3143
3144 // Find the remote project group entry index via Entity::read_with (no unwrap)
3145 let filtered = picker.read_with(cx, |p, _| p.delegate.filtered_entries.clone());
3146 let remote_idx = filtered
3147 .iter()
3148 .position(|entry| {
3149 matches!(entry, ProjectPickerEntry::ProjectGroup(m) if m.candidate_id == groups.len())
3150 })
3151 .expect("remote project group entry should exist");
3152
3153 // Select and confirm the remote entry via Entity::update
3154 let _ = cx.update(|cx| {
3155 let window = cx.windows()[0];
3156 window.update(cx, |_, window, cx| {
3157 picker.update(cx, |picker, cx| {
3158 picker.delegate.set_selected_index(remote_idx, window, cx);
3159 picker.delegate.confirm(false, window, cx);
3160 });
3161 })
3162 });
3163
3164 cx.run_until_parked();
3165
3166 // Verify no local workspace was created for the remote paths
3167 let has_local = mw
3168 .read_with(cx, |mw, cx| {
3169 mw.workspace_for_paths(remote_key.path_list(), None, cx)
3170 .is_some()
3171 })
3172 .unwrap();
3173 assert!(
3174 !has_local,
3175 "remote project group confirm should not create a local workspace"
3176 );
3177 }
3178
3179 #[gpui::test]
3180 async fn test_remove_open_folder_rekeys_this_window_group(cx: &mut TestAppContext) {
3181 // Regression test: removing a folder from the active project while the
3182 // picker is open must update the "This Window" group so it no longer
3183 // lists the removed folder.
3184 let app_state = init_test(cx);
3185 app_state
3186 .fs
3187 .as_fake()
3188 .insert_tree(path!("/a"), json!({ "1.txt": "" }))
3189 .await;
3190 app_state
3191 .fs
3192 .as_fake()
3193 .insert_tree(path!("/b"), json!({ "2.txt": "" }))
3194 .await;
3195
3196 cx.update(|cx| {
3197 open_paths(
3198 &[PathBuf::from(path!("/a")), PathBuf::from(path!("/b"))],
3199 app_state,
3200 workspace::OpenOptions::default(),
3201 cx,
3202 )
3203 })
3204 .await
3205 .unwrap();
3206 cx.run_until_parked();
3207
3208 let mw = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
3209 let (workspace, active_key, fh) = mw
3210 .read_with(cx, |mw, cx| {
3211 let ws = mw.workspace().clone();
3212 (
3213 ws.clone(),
3214 ws.read(cx).project_group_key(cx),
3215 ws.read(cx).focus_handle(cx),
3216 )
3217 })
3218 .unwrap();
3219
3220 assert_eq!(
3221 active_key.path_list().paths().len(),
3222 2,
3223 "group should span both folders before removal"
3224 );
3225 let groups = vec![active_key];
3226
3227 let popover: Entity<RecentProjects> = cx.update(|cx| {
3228 let window = cx.windows()[0];
3229 window
3230 .update(cx, |_, window, cx| {
3231 RecentProjects::popover(
3232 workspace.downgrade(),
3233 groups,
3234 Some(false),
3235 fh,
3236 window,
3237 cx,
3238 )
3239 })
3240 .unwrap()
3241 });
3242 cx.run_until_parked();
3243
3244 let picker: Entity<Picker<RecentProjectsDelegate>> = cx.update(|cx| {
3245 let window = cx.windows()[0];
3246 window
3247 .update(cx, |_, _window, cx| popover.read(cx).picker.clone())
3248 .unwrap()
3249 });
3250 cx.run_until_parked();
3251
3252 let a_worktree_id = workspace
3253 .read_with(cx, |workspace, cx| {
3254 workspace
3255 .project()
3256 .read(cx)
3257 .visible_worktrees(cx)
3258 .find(|wt| wt.read(cx).abs_path().ends_with("a"))
3259 .map(|wt| wt.read(cx).id())
3260 })
3261 .expect("a worktree should exist");
3262
3263 cx.update(|cx| {
3264 let window = cx.windows()[0];
3265 window
3266 .update(cx, |_, window, cx| {
3267 picker.update(cx, |picker, cx| {
3268 RecentProjectsDelegate::remove_open_folder(
3269 picker,
3270 a_worktree_id,
3271 window,
3272 cx,
3273 );
3274 });
3275 })
3276 .unwrap();
3277 });
3278 cx.run_until_parked();
3279
3280 let groups_after = picker.read_with(cx, |picker, _| {
3281 picker.delegate.window_project_groups.clone()
3282 });
3283 assert!(
3284 !groups_after.iter().any(|key| key
3285 .path_list()
3286 .paths()
3287 .iter()
3288 .any(|path| path.ends_with("a"))),
3289 "the removed folder should no longer appear in any This Window group, got {groups_after:?}"
3290 );
3291 }
3292}
3293