1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5pub mod sidebar_recent_projects;
6mod ssh_config;
7
8use std::{
9 path::{Path, PathBuf},
10 sync::Arc,
11};
12
13use chrono::{DateTime, Utc};
14
15use fs::Fs;
16
17#[cfg(target_os = "windows")]
18mod wsl_picker;
19
20use remote::RemoteConnectionOptions;
21pub use remote_connection::{RemoteConnectionModal, connect};
22pub use remote_connections::{navigate_to_positions, open_remote_project};
23
24use disconnected_overlay::DisconnectedOverlay;
25use fuzzy::{StringMatch, StringMatchCandidate};
26use gpui::{
27 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
28 Subscription, Task, WeakEntity, Window, actions, px,
29};
30
31use picker::{
32 Picker, PickerDelegate,
33 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
34};
35use project::{ProjectGroupKey, Worktree, git_store::Repository};
36pub use remote_connections::RemoteSettings;
37pub use remote_servers::RemoteServerProjects;
38use settings::{Settings, WorktreeId};
39use ui_input::ErasedEditor;
40
41use dev_container::{DevContainerContext, find_devcontainer_configs};
42use ui::{
43 ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
44 PopoverMenuHandle, TintColor, Tooltip, prelude::*,
45};
46use util::{ResultExt, paths::PathExt};
47use workspace::{
48 HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList,
49 SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
50 notifications::DetachAndPromptErr, with_active_or_new_workspace,
51};
52use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
53
54actions!(
55 recent_projects,
56 [ToggleActionsMenu, RemoveSelected, AddToWorkspace,]
57);
58
59#[derive(Clone, Debug)]
60pub struct RecentProjectEntry {
61 pub name: SharedString,
62 pub full_path: SharedString,
63 pub paths: Vec<PathBuf>,
64 pub workspace_id: WorkspaceId,
65 pub timestamp: DateTime<Utc>,
66}
67
68#[derive(Clone, Debug)]
69struct OpenFolderEntry {
70 worktree_id: WorktreeId,
71 name: SharedString,
72 path: PathBuf,
73 branch: Option<SharedString>,
74 is_active: bool,
75}
76
77#[derive(Clone, Debug)]
78enum ProjectPickerEntry {
79 Header(SharedString),
80 OpenFolder { index: usize, positions: Vec<usize> },
81 ProjectGroup(StringMatch),
82 RecentProject(StringMatch),
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum ProjectPickerStyle {
87 Modal,
88 Popover,
89}
90
91pub async fn get_recent_projects(
92 current_workspace_id: Option<WorkspaceId>,
93 limit: Option<usize>,
94 fs: Arc<dyn fs::Fs>,
95 db: &WorkspaceDb,
96) -> Vec<RecentProjectEntry> {
97 let workspaces = db
98 .recent_workspaces_on_disk(fs.as_ref())
99 .await
100 .unwrap_or_default();
101
102 let filtered: Vec<_> = workspaces
103 .into_iter()
104 .filter(|(id, _, _, _)| Some(*id) != current_workspace_id)
105 .filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local))
106 .collect();
107
108 let mut all_paths: Vec<PathBuf> = filtered
109 .iter()
110 .flat_map(|(_, _, path_list, _)| path_list.paths().iter().cloned())
111 .collect();
112 all_paths.sort();
113 all_paths.dedup();
114 let path_details =
115 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
116 project::path_suffix(path, detail)
117 });
118 let path_detail_map: std::collections::HashMap<PathBuf, usize> =
119 all_paths.into_iter().zip(path_details).collect();
120
121 let entries: Vec<RecentProjectEntry> = filtered
122 .into_iter()
123 .map(|(workspace_id, _, path_list, timestamp)| {
124 let paths: Vec<PathBuf> = path_list.paths().to_vec();
125 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
126
127 let name = ordered_paths
128 .iter()
129 .map(|p| {
130 let detail = path_detail_map.get(*p).copied().unwrap_or(0);
131 project::path_suffix(p, detail)
132 })
133 .filter(|s| !s.is_empty())
134 .collect::<Vec<_>>()
135 .join(", ");
136
137 let full_path = ordered_paths
138 .iter()
139 .map(|p| p.to_string_lossy().to_string())
140 .collect::<Vec<_>>()
141 .join("\n");
142
143 RecentProjectEntry {
144 name: SharedString::from(name),
145 full_path: SharedString::from(full_path),
146 paths,
147 workspace_id,
148 timestamp,
149 }
150 })
151 .collect();
152
153 match limit {
154 Some(n) => entries.into_iter().take(n).collect(),
155 None => entries,
156 }
157}
158
159pub async fn delete_recent_project(workspace_id: WorkspaceId, db: &WorkspaceDb) {
160 let _ = db.delete_workspace_by_id(workspace_id).await;
161}
162
163fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
164 let project = workspace.project().read(cx);
165 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
166
167 if visible_worktrees.len() <= 1 {
168 return Vec::new();
169 }
170
171 let active_worktree_id = workspace.active_worktree_override().or_else(|| {
172 if let Some(repo) = project.active_repository(cx) {
173 let repo = repo.read(cx);
174 let repo_path = &repo.work_directory_abs_path;
175 for worktree in project.visible_worktrees(cx) {
176 let worktree_path = worktree.read(cx).abs_path();
177 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
178 return Some(worktree.read(cx).id());
179 }
180 }
181 }
182 project
183 .visible_worktrees(cx)
184 .next()
185 .map(|wt| wt.read(cx).id())
186 });
187
188 let mut all_paths: Vec<PathBuf> = visible_worktrees
189 .iter()
190 .map(|wt| wt.read(cx).abs_path().to_path_buf())
191 .collect();
192 all_paths.sort();
193 all_paths.dedup();
194 let path_details =
195 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
196 project::path_suffix(path, detail)
197 });
198 let path_detail_map: std::collections::HashMap<PathBuf, usize> =
199 all_paths.into_iter().zip(path_details).collect();
200
201 let git_store = project.git_store().read(cx);
202 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
203
204 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
205 .into_iter()
206 .map(|worktree| {
207 let worktree_ref = worktree.read(cx);
208 let worktree_id = worktree_ref.id();
209 let path = worktree_ref.abs_path().to_path_buf();
210 let detail = path_detail_map.get(&path).copied().unwrap_or(0);
211 let name = SharedString::from(project::path_suffix(&path, detail));
212 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
213 let is_active = active_worktree_id == Some(worktree_id);
214 OpenFolderEntry {
215 worktree_id,
216 name,
217 path,
218 branch,
219 is_active,
220 }
221 })
222 .collect();
223
224 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
225 entries
226}
227
228fn get_branch_for_worktree(
229 worktree: &Worktree,
230 repositories: &[Entity<Repository>],
231 cx: &App,
232) -> Option<SharedString> {
233 let worktree_abs_path = worktree.abs_path();
234 repositories
235 .iter()
236 .filter(|repo| {
237 let repo_path = &repo.read(cx).work_directory_abs_path;
238 *repo_path == worktree_abs_path || worktree_abs_path.starts_with(repo_path.as_ref())
239 })
240 .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
241 .and_then(|repo| {
242 repo.read(cx)
243 .branch
244 .as_ref()
245 .map(|branch| SharedString::from(branch.name().to_string()))
246 })
247}
248
249pub fn init(cx: &mut App) {
250 #[cfg(target_os = "windows")]
251 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
252 let create_new_window = open_wsl.create_new_window;
253 with_active_or_new_workspace(cx, move |workspace, window, cx| {
254 use gpui::PathPromptOptions;
255 use project::DirectoryLister;
256
257 let paths = workspace.prompt_for_open_path(
258 PathPromptOptions {
259 files: true,
260 directories: true,
261 multiple: false,
262 prompt: None,
263 },
264 DirectoryLister::Local(
265 workspace.project().clone(),
266 workspace.app_state().fs.clone(),
267 ),
268 window,
269 cx,
270 );
271
272 let app_state = workspace.app_state().clone();
273 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
274
275 cx.spawn_in(window, async move |workspace, cx| {
276 use util::paths::SanitizedPath;
277
278 let Some(paths) = paths.await.log_err().flatten() else {
279 return;
280 };
281
282 let wsl_path = paths
283 .iter()
284 .find_map(util::paths::WslPath::from_path);
285
286 if let Some(util::paths::WslPath { distro, path }) = wsl_path {
287 use remote::WslConnectionOptions;
288
289 let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
290 distro_name: distro.to_string(),
291 user: None,
292 });
293
294 let requesting_window = match create_new_window {
295 false => window_handle,
296 true => None,
297 };
298
299 let open_options = workspace::OpenOptions {
300 requesting_window,
301 ..Default::default()
302 };
303
304 open_remote_project(connection_options, vec![path.into()], app_state, open_options, cx).await.log_err();
305 return;
306 }
307
308 let paths = paths
309 .into_iter()
310 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
311 .collect::<Vec<_>>();
312
313 if paths.is_empty() {
314 let message = indoc::indoc! { r#"
315 Invalid path specified when trying to open a folder inside WSL.
316
317 Please note that Zed currently does not support opening network share folders inside wsl.
318 "#};
319
320 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
321 return;
322 }
323
324 workspace.update_in(cx, |workspace, window, cx| {
325 workspace.toggle_modal(window, cx, |window, cx| {
326 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
327 });
328 }).log_err();
329 })
330 .detach();
331 });
332 });
333
334 #[cfg(target_os = "windows")]
335 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
336 let create_new_window = open_wsl.create_new_window;
337 with_active_or_new_workspace(cx, move |workspace, window, cx| {
338 let handle = cx.entity().downgrade();
339 let fs = workspace.project().read(cx).fs().clone();
340 workspace.toggle_modal(window, cx, |window, cx| {
341 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
342 });
343 });
344 });
345
346 #[cfg(target_os = "windows")]
347 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
348 let open_wsl = open_wsl.clone();
349 with_active_or_new_workspace(cx, move |workspace, window, cx| {
350 let fs = workspace.project().read(cx).fs().clone();
351 add_wsl_distro(fs, &open_wsl.distro, cx);
352 let open_options = OpenOptions {
353 requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
354 ..Default::default()
355 };
356
357 let app_state = workspace.app_state().clone();
358
359 cx.spawn_in(window, async move |_, cx| {
360 open_remote_project(
361 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
362 open_wsl.paths,
363 app_state,
364 open_options,
365 cx,
366 )
367 .await
368 })
369 .detach();
370 });
371 });
372
373 cx.on_action(|open_recent: &OpenRecent, cx| {
374 let create_new_window = open_recent.create_new_window;
375
376 match cx
377 .active_window()
378 .and_then(|w| w.downcast::<MultiWorkspace>())
379 {
380 Some(multi_workspace) => {
381 cx.defer(move |cx| {
382 multi_workspace
383 .update(cx, |multi_workspace, window, cx| {
384 let window_project_groups: Vec<ProjectGroupKey> =
385 multi_workspace.project_group_keys().cloned().collect();
386
387 let workspace = multi_workspace.workspace().clone();
388 workspace.update(cx, |workspace, cx| {
389 let Some(recent_projects) =
390 workspace.active_modal::<RecentProjects>(cx)
391 else {
392 let focus_handle = workspace.focus_handle(cx);
393 RecentProjects::open(
394 workspace,
395 create_new_window,
396 window_project_groups,
397 window,
398 focus_handle,
399 cx,
400 );
401 return;
402 };
403
404 recent_projects.update(cx, |recent_projects, cx| {
405 recent_projects
406 .picker
407 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
408 });
409 });
410 })
411 .log_err();
412 });
413 }
414 None => {
415 with_active_or_new_workspace(cx, move |workspace, window, cx| {
416 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
417 let focus_handle = workspace.focus_handle(cx);
418 RecentProjects::open(
419 workspace,
420 create_new_window,
421 Vec::new(),
422 window,
423 focus_handle,
424 cx,
425 );
426 return;
427 };
428
429 recent_projects.update(cx, |recent_projects, cx| {
430 recent_projects
431 .picker
432 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
433 });
434 });
435 }
436 }
437 });
438 cx.on_action(|open_remote: &OpenRemote, cx| {
439 let from_existing_connection = open_remote.from_existing_connection;
440 let create_new_window = open_remote.create_new_window;
441 with_active_or_new_workspace(cx, move |workspace, window, cx| {
442 if from_existing_connection {
443 cx.propagate();
444 return;
445 }
446 let handle = cx.entity().downgrade();
447 let fs = workspace.project().read(cx).fs().clone();
448 workspace.toggle_modal(window, cx, |window, cx| {
449 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
450 })
451 });
452 });
453
454 cx.observe_new(DisconnectedOverlay::register).detach();
455
456 cx.on_action(|_: &OpenDevContainer, cx| {
457 with_active_or_new_workspace(cx, move |workspace, window, cx| {
458 if !workspace.project().read(cx).is_local() {
459 cx.spawn_in(window, async move |_, cx| {
460 cx.prompt(
461 gpui::PromptLevel::Critical,
462 "Cannot open Dev Container from remote project",
463 None,
464 &["Ok"],
465 )
466 .await
467 .ok();
468 })
469 .detach();
470 return;
471 }
472
473 let fs = workspace.project().read(cx).fs().clone();
474 let configs = find_devcontainer_configs(workspace, cx);
475 let app_state = workspace.app_state().clone();
476 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
477 let handle = cx.entity().downgrade();
478 workspace.toggle_modal(window, cx, |window, cx| {
479 RemoteServerProjects::new_dev_container(
480 fs,
481 configs,
482 app_state,
483 dev_container_context,
484 window,
485 handle,
486 cx,
487 )
488 });
489 });
490 });
491
492 // Subscribe to worktree additions to suggest opening the project in a dev container
493 cx.observe_new(
494 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
495 let Some(window) = window else {
496 return;
497 };
498 cx.subscribe_in(
499 workspace.project(),
500 window,
501 move |workspace, project, event, window, cx| {
502 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
503 event
504 {
505 dev_container_suggest::suggest_on_worktree_updated(
506 workspace,
507 *worktree_id,
508 updated_entries,
509 project,
510 window,
511 cx,
512 );
513 }
514 },
515 )
516 .detach();
517 },
518 )
519 .detach();
520}
521
522#[cfg(target_os = "windows")]
523pub fn add_wsl_distro(
524 fs: Arc<dyn project::Fs>,
525 connection_options: &remote::WslConnectionOptions,
526 cx: &App,
527) {
528 use gpui::ReadGlobal;
529 use settings::SettingsStore;
530
531 let distro_name = connection_options.distro_name.clone();
532 let user = connection_options.user.clone();
533 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
534 let connections = setting
535 .remote
536 .wsl_connections
537 .get_or_insert(Default::default());
538
539 if !connections
540 .iter()
541 .any(|conn| conn.distro_name == distro_name && conn.user == user)
542 {
543 use std::collections::BTreeSet;
544
545 connections.push(settings::WslConnection {
546 distro_name,
547 user,
548 projects: BTreeSet::new(),
549 })
550 }
551 });
552}
553
554pub struct RecentProjects {
555 pub picker: Entity<Picker<RecentProjectsDelegate>>,
556 rem_width: f32,
557 _subscriptions: Vec<Subscription>,
558}
559
560impl ModalView for RecentProjects {
561 fn on_before_dismiss(
562 &mut self,
563 window: &mut Window,
564 cx: &mut Context<Self>,
565 ) -> workspace::DismissDecision {
566 let submenu_focused = self.picker.update(cx, |picker, cx| {
567 picker.delegate.actions_menu_handle.is_focused(window, cx)
568 });
569 workspace::DismissDecision::Dismiss(!submenu_focused)
570 }
571}
572
573impl RecentProjects {
574 fn new(
575 delegate: RecentProjectsDelegate,
576 fs: Option<Arc<dyn Fs>>,
577 rem_width: f32,
578 window: &mut Window,
579 cx: &mut Context<Self>,
580 ) -> Self {
581 let style = delegate.style;
582 let picker = cx.new(|cx| {
583 Picker::list(delegate, window, cx)
584 .list_measure_all()
585 .show_scrollbar(true)
586 });
587
588 let picker_focus_handle = picker.focus_handle(cx);
589 picker.update(cx, |picker, _| {
590 picker.delegate.focus_handle = picker_focus_handle;
591 });
592
593 let mut subscriptions = vec![cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent))];
594
595 if style == ProjectPickerStyle::Popover {
596 let picker_focus = picker.focus_handle(cx);
597 subscriptions.push(
598 cx.on_focus_out(&picker_focus, window, |this, _, window, cx| {
599 let submenu_focused = this.picker.update(cx, |picker, cx| {
600 picker.delegate.actions_menu_handle.is_focused(window, cx)
601 });
602 if !submenu_focused {
603 cx.emit(DismissEvent);
604 }
605 }),
606 );
607 }
608 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
609 // out workspace locations once the future runs to completion.
610 let db = WorkspaceDb::global(cx);
611 cx.spawn_in(window, async move |this, cx| {
612 let Some(fs) = fs else { return };
613 let workspaces = db
614 .recent_workspaces_on_disk(fs.as_ref())
615 .await
616 .log_err()
617 .unwrap_or_default();
618 let workspaces = workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
619 this.update_in(cx, move |this, window, cx| {
620 this.picker.update(cx, move |picker, cx| {
621 picker.delegate.set_workspaces(workspaces);
622 picker.update_matches(picker.query(cx), window, cx)
623 })
624 })
625 .ok();
626 })
627 .detach();
628 Self {
629 picker,
630 rem_width,
631 _subscriptions: subscriptions,
632 }
633 }
634
635 pub fn open(
636 workspace: &mut Workspace,
637 create_new_window: bool,
638 window_project_groups: Vec<ProjectGroupKey>,
639 window: &mut Window,
640 focus_handle: FocusHandle,
641 cx: &mut Context<Workspace>,
642 ) {
643 let weak = cx.entity().downgrade();
644 let open_folders = get_open_folders(workspace, cx);
645 let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
646 let fs = Some(workspace.app_state().fs.clone());
647
648 workspace.toggle_modal(window, cx, |window, cx| {
649 let delegate = RecentProjectsDelegate::new(
650 weak,
651 create_new_window,
652 focus_handle,
653 open_folders,
654 window_project_groups,
655 project_connection_options,
656 ProjectPickerStyle::Modal,
657 );
658
659 Self::new(delegate, fs, 34., window, cx)
660 })
661 }
662
663 pub fn popover(
664 workspace: WeakEntity<Workspace>,
665 window_project_groups: Vec<ProjectGroupKey>,
666 create_new_window: bool,
667 focus_handle: FocusHandle,
668 window: &mut Window,
669 cx: &mut App,
670 ) -> Entity<Self> {
671 let (open_folders, project_connection_options, fs) = workspace
672 .upgrade()
673 .map(|workspace| {
674 let workspace = workspace.read(cx);
675 (
676 get_open_folders(workspace, cx),
677 workspace.project().read(cx).remote_connection_options(cx),
678 Some(workspace.app_state().fs.clone()),
679 )
680 })
681 .unwrap_or_else(|| (Vec::new(), None, None));
682
683 cx.new(|cx| {
684 let delegate = RecentProjectsDelegate::new(
685 workspace,
686 create_new_window,
687 focus_handle,
688 open_folders,
689 window_project_groups,
690 project_connection_options,
691 ProjectPickerStyle::Popover,
692 );
693 let list = Self::new(delegate, fs, 20., window, cx);
694 list.picker.focus_handle(cx).focus(window, cx);
695 list
696 })
697 }
698
699 fn handle_toggle_open_menu(
700 &mut self,
701 _: &ToggleActionsMenu,
702 window: &mut Window,
703 cx: &mut Context<Self>,
704 ) {
705 self.picker.update(cx, |picker, cx| {
706 let menu_handle = &picker.delegate.actions_menu_handle;
707 if menu_handle.is_deployed() {
708 menu_handle.hide(cx);
709 } else {
710 menu_handle.show(window, cx);
711 }
712 });
713 }
714
715 fn handle_remove_selected(
716 &mut self,
717 _: &RemoveSelected,
718 window: &mut Window,
719 cx: &mut Context<Self>,
720 ) {
721 self.picker.update(cx, |picker, cx| {
722 let ix = picker.delegate.selected_index;
723
724 match picker.delegate.filtered_entries.get(ix) {
725 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
726 if let Some(folder) = picker.delegate.open_folders.get(*index) {
727 let worktree_id = folder.worktree_id;
728 let Some(workspace) = picker.delegate.workspace.upgrade() else {
729 return;
730 };
731 workspace.update(cx, |workspace, cx| {
732 let project = workspace.project().clone();
733 project.update(cx, |project, cx| {
734 project.remove_worktree(worktree_id, cx);
735 });
736 });
737 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
738 let query = picker.query(cx);
739 picker.update_matches(query, window, cx);
740 }
741 }
742 Some(ProjectPickerEntry::ProjectGroup(hit)) => {
743 if let Some(key) = picker
744 .delegate
745 .window_project_groups
746 .get(hit.candidate_id)
747 .cloned()
748 {
749 if picker.delegate.is_active_project_group(&key, cx) {
750 return;
751 }
752 picker.delegate.remove_project_group(key, window, cx);
753 let query = picker.query(cx);
754 picker.update_matches(query, window, cx);
755 }
756 }
757 Some(ProjectPickerEntry::RecentProject(_)) => {
758 picker.delegate.delete_recent_project(ix, window, cx);
759 }
760 _ => {}
761 }
762 });
763 }
764
765 fn handle_add_to_workspace(
766 &mut self,
767 _: &AddToWorkspace,
768 window: &mut Window,
769 cx: &mut Context<Self>,
770 ) {
771 self.picker.update(cx, |picker, cx| {
772 let ix = picker.delegate.selected_index;
773
774 if let Some(ProjectPickerEntry::RecentProject(hit)) =
775 picker.delegate.filtered_entries.get(ix)
776 {
777 if let Some((_, location, paths, _)) =
778 picker.delegate.workspaces.get(hit.candidate_id)
779 {
780 if matches!(location, SerializedWorkspaceLocation::Local) {
781 let paths_to_add = paths.paths().to_vec();
782 picker
783 .delegate
784 .add_project_to_workspace(paths_to_add, window, cx);
785 }
786 }
787 }
788 });
789 }
790}
791
792impl EventEmitter<DismissEvent> for RecentProjects {}
793
794impl Focusable for RecentProjects {
795 fn focus_handle(&self, cx: &App) -> FocusHandle {
796 self.picker.focus_handle(cx)
797 }
798}
799
800impl Render for RecentProjects {
801 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
802 v_flex()
803 .key_context("RecentProjects")
804 .on_action(cx.listener(Self::handle_toggle_open_menu))
805 .on_action(cx.listener(Self::handle_remove_selected))
806 .on_action(cx.listener(Self::handle_add_to_workspace))
807 .w(rems(self.rem_width))
808 .child(self.picker.clone())
809 }
810}
811
812pub struct RecentProjectsDelegate {
813 workspace: WeakEntity<Workspace>,
814 open_folders: Vec<OpenFolderEntry>,
815 window_project_groups: Vec<ProjectGroupKey>,
816 workspaces: Vec<(
817 WorkspaceId,
818 SerializedWorkspaceLocation,
819 PathList,
820 DateTime<Utc>,
821 )>,
822 filtered_entries: Vec<ProjectPickerEntry>,
823 selected_index: usize,
824 render_paths: bool,
825 create_new_window: bool,
826 // Flag to reset index when there is a new query vs not reset index when user delete an item
827 reset_selected_match_index: bool,
828 has_any_non_local_projects: bool,
829 project_connection_options: Option<RemoteConnectionOptions>,
830 focus_handle: FocusHandle,
831 style: ProjectPickerStyle,
832 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
833}
834
835impl RecentProjectsDelegate {
836 fn new(
837 workspace: WeakEntity<Workspace>,
838 create_new_window: bool,
839 focus_handle: FocusHandle,
840 open_folders: Vec<OpenFolderEntry>,
841 window_project_groups: Vec<ProjectGroupKey>,
842 project_connection_options: Option<RemoteConnectionOptions>,
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 reset_selected_match_index: true,
856 has_any_non_local_projects: project_connection_options.is_some(),
857 project_connection_options,
858 focus_handle,
859 style,
860 actions_menu_handle: PopoverMenuHandle::default(),
861 }
862 }
863
864 pub fn set_workspaces(
865 &mut self,
866 workspaces: Vec<(
867 WorkspaceId,
868 SerializedWorkspaceLocation,
869 PathList,
870 DateTime<Utc>,
871 )>,
872 ) {
873 self.workspaces = workspaces;
874 let has_non_local_recent = !self
875 .workspaces
876 .iter()
877 .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
878 self.has_any_non_local_projects =
879 self.project_connection_options.is_some() || has_non_local_recent;
880 }
881}
882impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
883impl PickerDelegate for RecentProjectsDelegate {
884 type ListItem = AnyElement;
885
886 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
887 "Search projects…".into()
888 }
889
890 fn render_editor(
891 &self,
892 editor: &Arc<dyn ErasedEditor>,
893 window: &mut Window,
894 cx: &mut Context<Picker<Self>>,
895 ) -> Div {
896 h_flex()
897 .flex_none()
898 .h_9()
899 .px_2p5()
900 .justify_between()
901 .border_b_1()
902 .border_color(cx.theme().colors().border_variant)
903 .child(editor.render(window, cx))
904 }
905
906 fn match_count(&self) -> usize {
907 self.filtered_entries.len()
908 }
909
910 fn selected_index(&self) -> usize {
911 self.selected_index
912 }
913
914 fn set_selected_index(
915 &mut self,
916 ix: usize,
917 _window: &mut Window,
918 _cx: &mut Context<Picker<Self>>,
919 ) {
920 self.selected_index = ix;
921 }
922
923 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
924 matches!(
925 self.filtered_entries.get(ix),
926 Some(
927 ProjectPickerEntry::OpenFolder { .. }
928 | ProjectPickerEntry::ProjectGroup(_)
929 | ProjectPickerEntry::RecentProject(_)
930 )
931 )
932 }
933
934 fn update_matches(
935 &mut self,
936 query: String,
937 _: &mut Window,
938 cx: &mut Context<Picker<Self>>,
939 ) -> gpui::Task<()> {
940 let query = query.trim_start();
941 let smart_case = query.chars().any(|c| c.is_uppercase());
942 let is_empty_query = query.is_empty();
943
944 let folder_matches = if self.open_folders.is_empty() {
945 Vec::new()
946 } else {
947 let candidates: Vec<_> = self
948 .open_folders
949 .iter()
950 .enumerate()
951 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
952 .collect();
953
954 smol::block_on(fuzzy::match_strings(
955 &candidates,
956 query,
957 smart_case,
958 true,
959 100,
960 &Default::default(),
961 cx.background_executor().clone(),
962 ))
963 };
964
965 let project_group_candidates: Vec<_> = self
966 .window_project_groups
967 .iter()
968 .enumerate()
969 .map(|(id, key)| {
970 let combined_string = key
971 .path_list()
972 .ordered_paths()
973 .map(|path| path.compact().to_string_lossy().into_owned())
974 .collect::<Vec<_>>()
975 .join("");
976 StringMatchCandidate::new(id, &combined_string)
977 })
978 .collect();
979
980 let mut project_group_matches = smol::block_on(fuzzy::match_strings(
981 &project_group_candidates,
982 query,
983 smart_case,
984 true,
985 100,
986 &Default::default(),
987 cx.background_executor().clone(),
988 ));
989 project_group_matches.sort_unstable_by(|a, b| {
990 b.score
991 .partial_cmp(&a.score)
992 .unwrap_or(std::cmp::Ordering::Equal)
993 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
994 });
995
996 // Build candidates for recent projects (not current, not sibling, not open folder)
997 let recent_candidates: Vec<_> = self
998 .workspaces
999 .iter()
1000 .enumerate()
1001 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
1002 .map(|(id, (_, _, paths, _))| {
1003 let combined_string = paths
1004 .ordered_paths()
1005 .map(|path| path.compact().to_string_lossy().into_owned())
1006 .collect::<Vec<_>>()
1007 .join("");
1008 StringMatchCandidate::new(id, &combined_string)
1009 })
1010 .collect();
1011
1012 let mut recent_matches = smol::block_on(fuzzy::match_strings(
1013 &recent_candidates,
1014 query,
1015 smart_case,
1016 true,
1017 100,
1018 &Default::default(),
1019 cx.background_executor().clone(),
1020 ));
1021 recent_matches.sort_unstable_by(|a, b| {
1022 b.score
1023 .partial_cmp(&a.score)
1024 .unwrap_or(std::cmp::Ordering::Equal)
1025 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1026 });
1027
1028 let mut entries = Vec::new();
1029
1030 if !self.open_folders.is_empty() {
1031 let matched_folders: Vec<_> = if is_empty_query {
1032 (0..self.open_folders.len())
1033 .map(|i| (i, Vec::new()))
1034 .collect()
1035 } else {
1036 folder_matches
1037 .iter()
1038 .map(|m| (m.candidate_id, m.positions.clone()))
1039 .collect()
1040 };
1041
1042 for (index, positions) in matched_folders {
1043 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1044 }
1045 }
1046
1047 let has_projects_to_show = if is_empty_query {
1048 !project_group_candidates.is_empty()
1049 } else {
1050 !project_group_matches.is_empty()
1051 };
1052
1053 if has_projects_to_show {
1054 entries.push(ProjectPickerEntry::Header("This Window".into()));
1055
1056 if is_empty_query {
1057 for id in 0..self.window_project_groups.len() {
1058 entries.push(ProjectPickerEntry::ProjectGroup(StringMatch {
1059 candidate_id: id,
1060 score: 0.0,
1061 positions: Vec::new(),
1062 string: String::new(),
1063 }));
1064 }
1065 } else {
1066 for m in project_group_matches {
1067 entries.push(ProjectPickerEntry::ProjectGroup(m));
1068 }
1069 }
1070 }
1071
1072 let has_recent_to_show = if is_empty_query {
1073 !recent_candidates.is_empty()
1074 } else {
1075 !recent_matches.is_empty()
1076 };
1077
1078 if has_recent_to_show {
1079 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1080
1081 if is_empty_query {
1082 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1083 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1084 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1085 candidate_id: id,
1086 score: 0.0,
1087 positions: Vec::new(),
1088 string: String::new(),
1089 }));
1090 }
1091 }
1092 } else {
1093 for m in recent_matches {
1094 entries.push(ProjectPickerEntry::RecentProject(m));
1095 }
1096 }
1097 }
1098
1099 self.filtered_entries = entries;
1100
1101 if self.reset_selected_match_index {
1102 self.selected_index = self
1103 .filtered_entries
1104 .iter()
1105 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1106 .unwrap_or(0);
1107 }
1108 self.reset_selected_match_index = true;
1109 Task::ready(())
1110 }
1111
1112 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1113 match self.filtered_entries.get(self.selected_index) {
1114 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1115 let Some(folder) = self.open_folders.get(*index) else {
1116 return;
1117 };
1118 let worktree_id = folder.worktree_id;
1119 if let Some(workspace) = self.workspace.upgrade() {
1120 workspace.update(cx, |workspace, cx| {
1121 workspace.set_active_worktree_override(Some(worktree_id), cx);
1122 });
1123 }
1124 cx.emit(DismissEvent);
1125 }
1126 Some(ProjectPickerEntry::ProjectGroup(selected_match)) => {
1127 let Some(key) = self.window_project_groups.get(selected_match.candidate_id) else {
1128 return;
1129 };
1130
1131 let path_list = key.path_list().clone();
1132 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1133 cx.defer(move |cx| {
1134 if let Some(task) = handle
1135 .update(cx, |multi_workspace, window, cx| {
1136 multi_workspace.find_or_create_local_workspace(
1137 path_list,
1138 &[],
1139 window,
1140 cx,
1141 )
1142 })
1143 .log_err()
1144 {
1145 task.detach_and_log_err(cx);
1146 }
1147 });
1148 }
1149 cx.emit(DismissEvent);
1150 }
1151 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1152 let Some(workspace) = self.workspace.upgrade() else {
1153 return;
1154 };
1155 let Some((
1156 candidate_workspace_id,
1157 candidate_workspace_location,
1158 candidate_workspace_paths,
1159 _,
1160 )) = self.workspaces.get(selected_match.candidate_id)
1161 else {
1162 return;
1163 };
1164
1165 let replace_current_window = self.create_new_window == secondary;
1166 let candidate_workspace_id = *candidate_workspace_id;
1167 let candidate_workspace_location = candidate_workspace_location.clone();
1168 let candidate_workspace_paths = candidate_workspace_paths.clone();
1169
1170 workspace.update(cx, |workspace, cx| {
1171 if workspace.database_id() == Some(candidate_workspace_id) {
1172 return;
1173 }
1174 match candidate_workspace_location {
1175 SerializedWorkspaceLocation::Local => {
1176 let paths = candidate_workspace_paths.paths().to_vec();
1177 if replace_current_window {
1178 if let Some(handle) =
1179 window.window_handle().downcast::<MultiWorkspace>()
1180 {
1181 cx.defer(move |cx| {
1182 if let Some(task) = handle
1183 .update(cx, |multi_workspace, window, cx| {
1184 multi_workspace.open_project(
1185 paths,
1186 OpenMode::Activate,
1187 window,
1188 cx,
1189 )
1190 })
1191 .log_err()
1192 {
1193 task.detach_and_log_err(cx);
1194 }
1195 });
1196 }
1197 return;
1198 } else {
1199 workspace
1200 .open_workspace_for_paths(
1201 OpenMode::NewWindow,
1202 paths,
1203 window,
1204 cx,
1205 )
1206 .detach_and_prompt_err(
1207 "Failed to open project",
1208 window,
1209 cx,
1210 |_, _, _| None,
1211 );
1212 }
1213 }
1214 SerializedWorkspaceLocation::Remote(mut connection) => {
1215 let app_state = workspace.app_state().clone();
1216 let replace_window = if replace_current_window {
1217 window.window_handle().downcast::<MultiWorkspace>()
1218 } else {
1219 None
1220 };
1221 let open_options = OpenOptions {
1222 requesting_window: replace_window,
1223 ..Default::default()
1224 };
1225 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1226 RemoteSettings::get_global(cx)
1227 .fill_connection_options_from_settings(connection);
1228 };
1229 let paths = candidate_workspace_paths.paths().to_vec();
1230 cx.spawn_in(window, async move |_, cx| {
1231 open_remote_project(
1232 connection.clone(),
1233 paths,
1234 app_state,
1235 open_options,
1236 cx,
1237 )
1238 .await
1239 })
1240 .detach_and_prompt_err(
1241 "Failed to open project",
1242 window,
1243 cx,
1244 |_, _, _| None,
1245 );
1246 }
1247 }
1248 });
1249 cx.emit(DismissEvent);
1250 }
1251 _ => {}
1252 }
1253 }
1254
1255 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1256
1257 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1258 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1259 "Recently opened projects will show up here".into()
1260 } else {
1261 "No matches".into()
1262 };
1263 Some(text)
1264 }
1265
1266 fn render_match(
1267 &self,
1268 ix: usize,
1269 selected: bool,
1270 window: &mut Window,
1271 cx: &mut Context<Picker<Self>>,
1272 ) -> Option<Self::ListItem> {
1273 match self.filtered_entries.get(ix)? {
1274 ProjectPickerEntry::Header(title) => Some(
1275 v_flex()
1276 .w_full()
1277 .gap_1()
1278 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1279 .child(ListSubHeader::new(title.clone()).inset(true))
1280 .into_any_element(),
1281 ),
1282 ProjectPickerEntry::OpenFolder { index, positions } => {
1283 let folder = self.open_folders.get(*index)?;
1284 let name = folder.name.clone();
1285 let path = folder.path.compact();
1286 let branch = folder.branch.clone();
1287 let is_active = folder.is_active;
1288 let worktree_id = folder.worktree_id;
1289 let positions = positions.clone();
1290 let show_path = self.style == ProjectPickerStyle::Modal;
1291
1292 let secondary_actions = h_flex()
1293 .gap_1()
1294 .child(
1295 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1296 .icon_size(IconSize::Small)
1297 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1298 .on_click(cx.listener(move |picker, _, window, cx| {
1299 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1300 return;
1301 };
1302 workspace.update(cx, |workspace, cx| {
1303 let project = workspace.project().clone();
1304 project.update(cx, |project, cx| {
1305 project.remove_worktree(worktree_id, cx);
1306 });
1307 });
1308 picker.delegate.open_folders =
1309 get_open_folders(workspace.read(cx), cx);
1310 let query = picker.query(cx);
1311 picker.update_matches(query, window, cx);
1312 })),
1313 )
1314 .into_any_element();
1315
1316 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1317
1318 Some(
1319 ListItem::new(ix)
1320 .toggle_state(selected)
1321 .inset(true)
1322 .spacing(ListItemSpacing::Sparse)
1323 .child(
1324 h_flex()
1325 .id("open_folder_item")
1326 .gap_3()
1327 .flex_grow()
1328 .when(self.has_any_non_local_projects, |this| {
1329 this.child(Icon::new(icon).color(Color::Muted))
1330 })
1331 .child(
1332 v_flex()
1333 .child(
1334 h_flex()
1335 .gap_1()
1336 .child({
1337 let highlighted = HighlightedMatch {
1338 text: name.to_string(),
1339 highlight_positions: positions,
1340 color: Color::Default,
1341 };
1342 highlighted.render(window, cx)
1343 })
1344 .when_some(branch, |this, branch| {
1345 this.child(
1346 Label::new(branch).color(Color::Muted),
1347 )
1348 })
1349 .when(is_active, |this| {
1350 this.child(
1351 Icon::new(IconName::Check)
1352 .size(IconSize::Small)
1353 .color(Color::Accent),
1354 )
1355 }),
1356 )
1357 .when(show_path, |this| {
1358 this.child(
1359 Label::new(path.to_string_lossy().to_string())
1360 .size(LabelSize::Small)
1361 .color(Color::Muted),
1362 )
1363 }),
1364 )
1365 .when(!show_path, |this| {
1366 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1367 }),
1368 )
1369 .end_slot(secondary_actions)
1370 .show_end_slot_on_hover()
1371 .into_any_element(),
1372 )
1373 }
1374 ProjectPickerEntry::ProjectGroup(hit) => {
1375 let key = self.window_project_groups.get(hit.candidate_id)?;
1376 let is_active = self.is_active_project_group(key, cx);
1377 let paths = key.path_list();
1378 let ordered_paths: Vec<_> = paths
1379 .ordered_paths()
1380 .map(|p| p.compact().to_string_lossy().to_string())
1381 .collect();
1382 let tooltip_path: SharedString = ordered_paths.join("\n").into();
1383
1384 let mut path_start_offset = 0;
1385 let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
1386 .ordered_paths()
1387 .map(|p| p.compact())
1388 .map(|path| {
1389 let highlighted_text =
1390 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1391 path_start_offset += highlighted_text.1.text.len();
1392 highlighted_text
1393 })
1394 .unzip();
1395
1396 let highlighted_match = HighlightedMatchWithPaths {
1397 prefix: None,
1398 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1399 paths: path_highlights,
1400 active: is_active,
1401 };
1402
1403 let project_group_key = key.clone();
1404 let secondary_actions = h_flex()
1405 .gap_1()
1406 .when(!is_active, |this| {
1407 this.child(
1408 IconButton::new("remove_open_project", IconName::Close)
1409 .icon_size(IconSize::Small)
1410 .tooltip(Tooltip::text("Remove Project from Window"))
1411 .on_click({
1412 let project_group_key = project_group_key.clone();
1413 cx.listener(move |picker, _, window, cx| {
1414 cx.stop_propagation();
1415 window.prevent_default();
1416 picker.delegate.remove_project_group(
1417 project_group_key.clone(),
1418 window,
1419 cx,
1420 );
1421 let query = picker.query(cx);
1422 picker.update_matches(query, window, cx);
1423 })
1424 }),
1425 )
1426 })
1427 .into_any_element();
1428
1429 Some(
1430 ListItem::new(ix)
1431 .toggle_state(selected)
1432 .inset(true)
1433 .spacing(ListItemSpacing::Sparse)
1434 .child(
1435 h_flex()
1436 .id("open_project_info_container")
1437 .gap_3()
1438 .child({
1439 let mut highlighted = highlighted_match;
1440 if !self.render_paths {
1441 highlighted.paths.clear();
1442 }
1443 highlighted.render(window, cx)
1444 })
1445 .tooltip(Tooltip::text(tooltip_path)),
1446 )
1447 .end_slot(secondary_actions)
1448 .show_end_slot_on_hover()
1449 .into_any_element(),
1450 )
1451 }
1452 ProjectPickerEntry::RecentProject(hit) => {
1453 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1454 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1455 let paths_to_add = paths.paths().to_vec();
1456 let ordered_paths: Vec<_> = paths
1457 .ordered_paths()
1458 .map(|p| p.compact().to_string_lossy().to_string())
1459 .collect();
1460 let tooltip_path: SharedString = match &location {
1461 SerializedWorkspaceLocation::Remote(options) => {
1462 let host = options.display_name();
1463 if ordered_paths.len() == 1 {
1464 format!("{} ({})", ordered_paths[0], host).into()
1465 } else {
1466 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1467 }
1468 }
1469 _ => ordered_paths.join("\n").into(),
1470 };
1471
1472 let mut path_start_offset = 0;
1473 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1474 .ordered_paths()
1475 .map(|p| p.compact())
1476 .map(|path| {
1477 let highlighted_text =
1478 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1479 path_start_offset += highlighted_text.1.text.len();
1480 highlighted_text
1481 })
1482 .unzip();
1483
1484 let prefix = match &location {
1485 SerializedWorkspaceLocation::Remote(options) => {
1486 Some(SharedString::from(options.display_name()))
1487 }
1488 _ => None,
1489 };
1490
1491 let highlighted_match = HighlightedMatchWithPaths {
1492 prefix,
1493 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1494 paths,
1495 active: false,
1496 };
1497
1498 let focus_handle = self.focus_handle.clone();
1499
1500 let secondary_actions = h_flex()
1501 .gap_px()
1502 .when(is_local, |this| {
1503 this.child(
1504 IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1505 .icon_size(IconSize::Small)
1506 .tooltip(move |_, cx| {
1507 Tooltip::with_meta(
1508 "Add Project to this Workspace",
1509 None,
1510 "As a multi-root folder project",
1511 cx,
1512 )
1513 })
1514 .on_click({
1515 let paths_to_add = paths_to_add.clone();
1516 cx.listener(move |picker, _event, window, cx| {
1517 cx.stop_propagation();
1518 window.prevent_default();
1519 picker.delegate.add_project_to_workspace(
1520 paths_to_add.clone(),
1521 window,
1522 cx,
1523 );
1524 })
1525 }),
1526 )
1527 })
1528 .child(
1529 IconButton::new("open_new_window", IconName::OpenNewWindow)
1530 .icon_size(IconSize::Small)
1531 .tooltip({
1532 move |_, cx| {
1533 Tooltip::for_action_in(
1534 "Open Project in New Window",
1535 &menu::SecondaryConfirm,
1536 &focus_handle,
1537 cx,
1538 )
1539 }
1540 })
1541 .on_click(cx.listener(move |this, _event, window, cx| {
1542 cx.stop_propagation();
1543 window.prevent_default();
1544 this.delegate.set_selected_index(ix, window, cx);
1545 this.delegate.confirm(true, window, cx);
1546 })),
1547 )
1548 .child(
1549 IconButton::new("delete", IconName::Close)
1550 .icon_size(IconSize::Small)
1551 .tooltip(Tooltip::text("Delete from Recent Projects"))
1552 .on_click(cx.listener(move |this, _event, window, cx| {
1553 cx.stop_propagation();
1554 window.prevent_default();
1555 this.delegate.delete_recent_project(ix, window, cx)
1556 })),
1557 )
1558 .into_any_element();
1559
1560 let icon = icon_for_remote_connection(match location {
1561 SerializedWorkspaceLocation::Local => None,
1562 SerializedWorkspaceLocation::Remote(options) => Some(options),
1563 });
1564
1565 Some(
1566 ListItem::new(ix)
1567 .toggle_state(selected)
1568 .inset(true)
1569 .spacing(ListItemSpacing::Sparse)
1570 .child(
1571 h_flex()
1572 .id("project_info_container")
1573 .gap_3()
1574 .flex_grow()
1575 .when(self.has_any_non_local_projects, |this| {
1576 this.child(Icon::new(icon).color(Color::Muted))
1577 })
1578 .child({
1579 let mut highlighted = highlighted_match;
1580 if !self.render_paths {
1581 highlighted.paths.clear();
1582 }
1583 highlighted.render(window, cx)
1584 })
1585 .tooltip(move |_, cx| {
1586 Tooltip::with_meta(
1587 "Open Project in This Window",
1588 None,
1589 tooltip_path.clone(),
1590 cx,
1591 )
1592 }),
1593 )
1594 .end_slot(secondary_actions)
1595 .show_end_slot_on_hover()
1596 .into_any_element(),
1597 )
1598 }
1599 }
1600 }
1601
1602 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1603 let focus_handle = self.focus_handle.clone();
1604 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1605 let is_already_open_entry = matches!(
1606 self.filtered_entries.get(self.selected_index),
1607 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
1608 );
1609
1610 if popover_style {
1611 return Some(
1612 v_flex()
1613 .flex_1()
1614 .p_1p5()
1615 .gap_1()
1616 .border_t_1()
1617 .border_color(cx.theme().colors().border_variant)
1618 .child({
1619 let open_action = workspace::Open::default();
1620 Button::new("open_local_folder", "Open Local Project")
1621 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1622 .on_click(move |_, window, cx| {
1623 window.dispatch_action(open_action.boxed_clone(), cx)
1624 })
1625 })
1626 .child(
1627 Button::new("open_remote_folder", "Open Remote Project")
1628 .key_binding(KeyBinding::for_action(
1629 &OpenRemote {
1630 from_existing_connection: false,
1631 create_new_window: false,
1632 },
1633 cx,
1634 ))
1635 .on_click(|_, window, cx| {
1636 window.dispatch_action(
1637 OpenRemote {
1638 from_existing_connection: false,
1639 create_new_window: false,
1640 }
1641 .boxed_clone(),
1642 cx,
1643 )
1644 }),
1645 )
1646 .into_any(),
1647 );
1648 }
1649
1650 let selected_entry = self.filtered_entries.get(self.selected_index);
1651
1652 let is_current_workspace_entry =
1653 if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
1654 self.window_project_groups
1655 .get(hit.candidate_id)
1656 .is_some_and(|key| self.is_active_project_group(key, cx))
1657 } else {
1658 false
1659 };
1660
1661 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1662 Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1663 Button::new("remove_selected", "Remove Folder")
1664 .key_binding(KeyBinding::for_action_in(
1665 &RemoveSelected,
1666 &focus_handle,
1667 cx,
1668 ))
1669 .on_click(|_, window, cx| {
1670 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1671 })
1672 .into_any_element(),
1673 ),
1674 Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
1675 Button::new("remove_selected", "Remove from Window")
1676 .key_binding(KeyBinding::for_action_in(
1677 &RemoveSelected,
1678 &focus_handle,
1679 cx,
1680 ))
1681 .on_click(|_, window, cx| {
1682 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1683 })
1684 .into_any_element(),
1685 ),
1686 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1687 Button::new("delete_recent", "Delete")
1688 .key_binding(KeyBinding::for_action_in(
1689 &RemoveSelected,
1690 &focus_handle,
1691 cx,
1692 ))
1693 .on_click(|_, window, cx| {
1694 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1695 })
1696 .into_any_element(),
1697 ),
1698 _ => None,
1699 };
1700
1701 Some(
1702 h_flex()
1703 .flex_1()
1704 .p_1p5()
1705 .gap_1()
1706 .justify_end()
1707 .border_t_1()
1708 .border_color(cx.theme().colors().border_variant)
1709 .when_some(secondary_footer_actions, |this, actions| {
1710 this.child(actions)
1711 })
1712 .map(|this| {
1713 if is_already_open_entry {
1714 this.child(
1715 Button::new("activate", "Activate")
1716 .key_binding(KeyBinding::for_action_in(
1717 &menu::Confirm,
1718 &focus_handle,
1719 cx,
1720 ))
1721 .on_click(|_, window, cx| {
1722 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1723 }),
1724 )
1725 } else {
1726 this.child(
1727 Button::new("open_new_window", "New Window")
1728 .key_binding(KeyBinding::for_action_in(
1729 &menu::SecondaryConfirm,
1730 &focus_handle,
1731 cx,
1732 ))
1733 .on_click(|_, window, cx| {
1734 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1735 }),
1736 )
1737 .child(
1738 Button::new("open_here", "Open")
1739 .key_binding(KeyBinding::for_action_in(
1740 &menu::Confirm,
1741 &focus_handle,
1742 cx,
1743 ))
1744 .on_click(|_, window, cx| {
1745 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1746 }),
1747 )
1748 }
1749 })
1750 .child(Divider::vertical())
1751 .child(
1752 PopoverMenu::new("actions-menu-popover")
1753 .with_handle(self.actions_menu_handle.clone())
1754 .anchor(gpui::Corner::BottomRight)
1755 .offset(gpui::Point {
1756 x: px(0.0),
1757 y: px(-2.0),
1758 })
1759 .trigger(
1760 Button::new("actions-trigger", "Actions")
1761 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1762 .key_binding(KeyBinding::for_action_in(
1763 &ToggleActionsMenu,
1764 &focus_handle,
1765 cx,
1766 )),
1767 )
1768 .menu({
1769 let focus_handle = focus_handle.clone();
1770 let show_add_to_workspace = match selected_entry {
1771 Some(ProjectPickerEntry::RecentProject(hit)) => self
1772 .workspaces
1773 .get(hit.candidate_id)
1774 .map(|(_, loc, ..)| {
1775 matches!(loc, SerializedWorkspaceLocation::Local)
1776 })
1777 .unwrap_or(false),
1778 _ => false,
1779 };
1780
1781 move |window, cx| {
1782 Some(ContextMenu::build(window, cx, {
1783 let focus_handle = focus_handle.clone();
1784 move |menu, _, _| {
1785 menu.context(focus_handle)
1786 .when(show_add_to_workspace, |menu| {
1787 menu.action(
1788 "Add to this Workspace",
1789 AddToWorkspace.boxed_clone(),
1790 )
1791 .separator()
1792 })
1793 .action(
1794 "Open Local Project",
1795 workspace::Open::default().boxed_clone(),
1796 )
1797 .action(
1798 "Open Remote Project",
1799 OpenRemote {
1800 from_existing_connection: false,
1801 create_new_window: false,
1802 }
1803 .boxed_clone(),
1804 )
1805 }
1806 }))
1807 }
1808 }),
1809 )
1810 .into_any(),
1811 )
1812 }
1813}
1814
1815pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1816 match options {
1817 None => IconName::Screen,
1818 Some(options) => match options {
1819 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1820 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1821 RemoteConnectionOptions::Docker(_) => IconName::Box,
1822 #[cfg(any(test, feature = "test-support"))]
1823 RemoteConnectionOptions::Mock(_) => IconName::Server,
1824 },
1825 }
1826}
1827
1828// Compute the highlighted text for the name and path
1829pub(crate) fn highlights_for_path(
1830 path: &Path,
1831 match_positions: &Vec<usize>,
1832 path_start_offset: usize,
1833) -> (Option<HighlightedMatch>, HighlightedMatch) {
1834 let path_string = path.to_string_lossy();
1835 let path_text = path_string.to_string();
1836 let path_byte_len = path_text.len();
1837 // Get the subset of match highlight positions that line up with the given path.
1838 // Also adjusts them to start at the path start
1839 let path_positions = match_positions
1840 .iter()
1841 .copied()
1842 .skip_while(|position| *position < path_start_offset)
1843 .take_while(|position| *position < path_start_offset + path_byte_len)
1844 .map(|position| position - path_start_offset)
1845 .collect::<Vec<_>>();
1846
1847 // Again subset the highlight positions to just those that line up with the file_name
1848 // again adjusted to the start of the file_name
1849 let file_name_text_and_positions = path.file_name().map(|file_name| {
1850 let file_name_text = file_name.to_string_lossy().into_owned();
1851 let file_name_start_byte = path_byte_len - file_name_text.len();
1852 let highlight_positions = path_positions
1853 .iter()
1854 .copied()
1855 .skip_while(|position| *position < file_name_start_byte)
1856 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1857 .map(|position| position - file_name_start_byte)
1858 .collect::<Vec<_>>();
1859 HighlightedMatch {
1860 text: file_name_text,
1861 highlight_positions,
1862 color: Color::Default,
1863 }
1864 });
1865
1866 (
1867 file_name_text_and_positions,
1868 HighlightedMatch {
1869 text: path_text,
1870 highlight_positions: path_positions,
1871 color: Color::Default,
1872 },
1873 )
1874}
1875impl RecentProjectsDelegate {
1876 fn add_project_to_workspace(
1877 &mut self,
1878 paths: Vec<PathBuf>,
1879 window: &mut Window,
1880 cx: &mut Context<Picker<Self>>,
1881 ) {
1882 let Some(workspace) = self.workspace.upgrade() else {
1883 return;
1884 };
1885 let open_paths_task = workspace.update(cx, |workspace, cx| {
1886 workspace.open_paths(
1887 paths,
1888 OpenOptions {
1889 visible: Some(OpenVisible::All),
1890 ..Default::default()
1891 },
1892 None,
1893 window,
1894 cx,
1895 )
1896 });
1897 cx.spawn_in(window, async move |picker, cx| {
1898 let _result = open_paths_task.await;
1899 picker
1900 .update_in(cx, |picker, window, cx| {
1901 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1902 return;
1903 };
1904 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1905 let query = picker.query(cx);
1906 picker.update_matches(query, window, cx);
1907 })
1908 .ok();
1909 })
1910 .detach();
1911 }
1912
1913 fn delete_recent_project(
1914 &self,
1915 ix: usize,
1916 window: &mut Window,
1917 cx: &mut Context<Picker<Self>>,
1918 ) {
1919 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1920 self.filtered_entries.get(ix)
1921 {
1922 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1923 let workspace_id = *workspace_id;
1924 let fs = self
1925 .workspace
1926 .upgrade()
1927 .map(|ws| ws.read(cx).app_state().fs.clone());
1928 let db = WorkspaceDb::global(cx);
1929 cx.spawn_in(window, async move |this, cx| {
1930 db.delete_workspace_by_id(workspace_id).await.log_err();
1931 let Some(fs) = fs else { return };
1932 let workspaces = db
1933 .recent_workspaces_on_disk(fs.as_ref())
1934 .await
1935 .unwrap_or_default();
1936 let workspaces =
1937 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1938 this.update_in(cx, move |picker, window, cx| {
1939 picker.delegate.set_workspaces(workspaces);
1940 picker
1941 .delegate
1942 .set_selected_index(ix.saturating_sub(1), window, cx);
1943 picker.delegate.reset_selected_match_index = false;
1944 picker.update_matches(picker.query(cx), window, cx);
1945 // After deleting a project, we want to update the history manager to reflect the change.
1946 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1947 if let Some(history_manager) = HistoryManager::global(cx) {
1948 history_manager
1949 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1950 }
1951 })
1952 .ok();
1953 })
1954 .detach();
1955 }
1956 }
1957
1958 fn remove_project_group(
1959 &mut self,
1960 key: ProjectGroupKey,
1961 window: &mut Window,
1962 cx: &mut Context<Picker<Self>>,
1963 ) {
1964 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1965 let key_for_remove = key.clone();
1966 cx.defer(move |cx| {
1967 handle
1968 .update(cx, |multi_workspace, window, cx| {
1969 multi_workspace
1970 .remove_project_group(&key_for_remove, window, cx)
1971 .detach_and_log_err(cx);
1972 })
1973 .log_err();
1974 });
1975 }
1976
1977 self.window_project_groups.retain(|k| k != &key);
1978 }
1979
1980 fn is_current_workspace(
1981 &self,
1982 workspace_id: WorkspaceId,
1983 cx: &mut Context<Picker<Self>>,
1984 ) -> bool {
1985 if let Some(workspace) = self.workspace.upgrade() {
1986 let workspace = workspace.read(cx);
1987 if Some(workspace_id) == workspace.database_id() {
1988 return true;
1989 }
1990 }
1991
1992 false
1993 }
1994
1995 fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
1996 if let Some(workspace) = self.workspace.upgrade() {
1997 return workspace.read(cx).project_group_key(cx) == *key;
1998 }
1999 false
2000 }
2001
2002 fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
2003 self.window_project_groups
2004 .iter()
2005 .any(|key| key.path_list() == paths)
2006 }
2007
2008 fn is_open_folder(&self, paths: &PathList) -> bool {
2009 if self.open_folders.is_empty() {
2010 return false;
2011 }
2012
2013 for workspace_path in paths.paths() {
2014 for open_folder in &self.open_folders {
2015 if workspace_path == &open_folder.path {
2016 return true;
2017 }
2018 }
2019 }
2020
2021 false
2022 }
2023
2024 fn is_valid_recent_candidate(
2025 &self,
2026 workspace_id: WorkspaceId,
2027 paths: &PathList,
2028 cx: &mut Context<Picker<Self>>,
2029 ) -> bool {
2030 !self.is_current_workspace(workspace_id, cx)
2031 && !self.is_in_current_window_groups(paths)
2032 && !self.is_open_folder(paths)
2033 }
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038 use gpui::{TestAppContext, VisualTestContext};
2039
2040 use serde_json::json;
2041 use util::path;
2042 use workspace::{AppState, open_paths};
2043
2044 use super::*;
2045
2046 #[gpui::test]
2047 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2048 let app_state = init_test(cx);
2049
2050 app_state
2051 .fs
2052 .as_fake()
2053 .insert_tree(
2054 path!("/project"),
2055 json!({
2056 ".devcontainer": {
2057 "devcontainer.json": "{}"
2058 },
2059 "src": {
2060 "main.rs": "fn main() {}"
2061 }
2062 }),
2063 )
2064 .await;
2065
2066 // Open a file path (not a directory) so that the worktree root is a
2067 // file. This means `active_project_directory` returns `None`, which
2068 // causes `DevContainerContext::from_workspace` to return `None`,
2069 // preventing `open_dev_container` from spawning real I/O (docker
2070 // commands, shell environment loading) that is incompatible with the
2071 // test scheduler. The modal is still created and the re-entrancy
2072 // guard that this test validates is still exercised.
2073 cx.update(|cx| {
2074 open_paths(
2075 &[PathBuf::from(path!("/project/src/main.rs"))],
2076 app_state,
2077 workspace::OpenOptions::default(),
2078 cx,
2079 )
2080 })
2081 .await
2082 .unwrap();
2083
2084 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2085 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2086
2087 cx.run_until_parked();
2088
2089 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2090 // -> Workspace::update -> toggle_modal -> new_dev_container.
2091 // Before the fix, this panicked with "cannot read workspace::Workspace while
2092 // it is already being updated" because new_dev_container and open_dev_container
2093 // tried to read the Workspace entity through a WeakEntity handle while it was
2094 // already leased by the outer update.
2095 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2096
2097 multi_workspace
2098 .update(cx, |multi_workspace, _, cx| {
2099 let modal = multi_workspace
2100 .workspace()
2101 .read(cx)
2102 .active_modal::<RemoteServerProjects>(cx);
2103 assert!(
2104 modal.is_some(),
2105 "Dev container modal should be open after dispatching OpenDevContainer"
2106 );
2107 })
2108 .unwrap();
2109 }
2110
2111 #[gpui::test]
2112 async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2113 let app_state = init_test(cx);
2114
2115 app_state
2116 .fs
2117 .as_fake()
2118 .insert_tree(
2119 path!("/project"),
2120 json!({
2121 ".devcontainer": {
2122 "devcontainer.json": "{}"
2123 },
2124 "src": {
2125 "main.rs": "fn main() {}"
2126 }
2127 }),
2128 )
2129 .await;
2130
2131 cx.update(|cx| {
2132 open_paths(
2133 &[PathBuf::from(path!("/project"))],
2134 app_state,
2135 workspace::OpenOptions::default(),
2136 cx,
2137 )
2138 })
2139 .await
2140 .unwrap();
2141
2142 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2143 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2144
2145 cx.run_until_parked();
2146
2147 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2148
2149 multi_workspace
2150 .update(cx, |multi_workspace, _, cx| {
2151 assert!(
2152 multi_workspace
2153 .active_modal::<RemoteServerProjects>(cx)
2154 .is_some(),
2155 "Dev container modal should be open"
2156 );
2157 })
2158 .unwrap();
2159
2160 // Click outside the modal (on the backdrop) to try to dismiss it
2161 let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2162 vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2163
2164 multi_workspace
2165 .update(cx, |multi_workspace, _, cx| {
2166 assert!(
2167 multi_workspace
2168 .active_modal::<RemoteServerProjects>(cx)
2169 .is_some(),
2170 "Dev container modal should remain open during creation"
2171 );
2172 })
2173 .unwrap();
2174 }
2175
2176 #[gpui::test]
2177 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2178 let app_state = init_test(cx);
2179
2180 app_state
2181 .fs
2182 .as_fake()
2183 .insert_tree(
2184 path!("/project"),
2185 json!({
2186 ".devcontainer": {
2187 "rust": {
2188 "devcontainer.json": "{}"
2189 },
2190 "python": {
2191 "devcontainer.json": "{}"
2192 }
2193 },
2194 "src": {
2195 "main.rs": "fn main() {}"
2196 }
2197 }),
2198 )
2199 .await;
2200
2201 cx.update(|cx| {
2202 open_paths(
2203 &[PathBuf::from(path!("/project"))],
2204 app_state,
2205 workspace::OpenOptions::default(),
2206 cx,
2207 )
2208 })
2209 .await
2210 .unwrap();
2211
2212 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2213 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2214
2215 cx.run_until_parked();
2216
2217 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2218
2219 multi_workspace
2220 .update(cx, |multi_workspace, _, cx| {
2221 let modal = multi_workspace
2222 .workspace()
2223 .read(cx)
2224 .active_modal::<RemoteServerProjects>(cx);
2225 assert!(
2226 modal.is_some(),
2227 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2228 );
2229 })
2230 .unwrap();
2231 }
2232
2233 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2234 cx.update(|cx| {
2235 let state = AppState::test(cx);
2236 crate::init(cx);
2237 editor::init(cx);
2238 state
2239 })
2240 }
2241}