Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:31:56.822Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

recent_projects.rs

3293 lines · 127.6 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.