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
1137 .find_or_create_local_workspace(path_list, window, cx)
1138 })
1139 .log_err()
1140 {
1141 task.detach_and_log_err(cx);
1142 }
1143 });
1144 }
1145 cx.emit(DismissEvent);
1146 }
1147 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1148 let Some(workspace) = self.workspace.upgrade() else {
1149 return;
1150 };
1151 let Some((
1152 candidate_workspace_id,
1153 candidate_workspace_location,
1154 candidate_workspace_paths,
1155 _,
1156 )) = self.workspaces.get(selected_match.candidate_id)
1157 else {
1158 return;
1159 };
1160
1161 let replace_current_window = self.create_new_window == secondary;
1162 let candidate_workspace_id = *candidate_workspace_id;
1163 let candidate_workspace_location = candidate_workspace_location.clone();
1164 let candidate_workspace_paths = candidate_workspace_paths.clone();
1165
1166 workspace.update(cx, |workspace, cx| {
1167 if workspace.database_id() == Some(candidate_workspace_id) {
1168 return;
1169 }
1170 match candidate_workspace_location {
1171 SerializedWorkspaceLocation::Local => {
1172 let paths = candidate_workspace_paths.paths().to_vec();
1173 if replace_current_window {
1174 if let Some(handle) =
1175 window.window_handle().downcast::<MultiWorkspace>()
1176 {
1177 cx.defer(move |cx| {
1178 if let Some(task) = handle
1179 .update(cx, |multi_workspace, window, cx| {
1180 multi_workspace.open_project(
1181 paths,
1182 OpenMode::Activate,
1183 window,
1184 cx,
1185 )
1186 })
1187 .log_err()
1188 {
1189 task.detach_and_log_err(cx);
1190 }
1191 });
1192 }
1193 return;
1194 } else {
1195 workspace
1196 .open_workspace_for_paths(
1197 OpenMode::NewWindow,
1198 paths,
1199 window,
1200 cx,
1201 )
1202 .detach_and_prompt_err(
1203 "Failed to open project",
1204 window,
1205 cx,
1206 |_, _, _| None,
1207 );
1208 }
1209 }
1210 SerializedWorkspaceLocation::Remote(mut connection) => {
1211 let app_state = workspace.app_state().clone();
1212 let replace_window = if replace_current_window {
1213 window.window_handle().downcast::<MultiWorkspace>()
1214 } else {
1215 None
1216 };
1217 let open_options = OpenOptions {
1218 requesting_window: replace_window,
1219 ..Default::default()
1220 };
1221 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1222 RemoteSettings::get_global(cx)
1223 .fill_connection_options_from_settings(connection);
1224 };
1225 let paths = candidate_workspace_paths.paths().to_vec();
1226 cx.spawn_in(window, async move |_, cx| {
1227 open_remote_project(
1228 connection.clone(),
1229 paths,
1230 app_state,
1231 open_options,
1232 cx,
1233 )
1234 .await
1235 })
1236 .detach_and_prompt_err(
1237 "Failed to open project",
1238 window,
1239 cx,
1240 |_, _, _| None,
1241 );
1242 }
1243 }
1244 });
1245 cx.emit(DismissEvent);
1246 }
1247 _ => {}
1248 }
1249 }
1250
1251 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1252
1253 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1254 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1255 "Recently opened projects will show up here".into()
1256 } else {
1257 "No matches".into()
1258 };
1259 Some(text)
1260 }
1261
1262 fn render_match(
1263 &self,
1264 ix: usize,
1265 selected: bool,
1266 window: &mut Window,
1267 cx: &mut Context<Picker<Self>>,
1268 ) -> Option<Self::ListItem> {
1269 match self.filtered_entries.get(ix)? {
1270 ProjectPickerEntry::Header(title) => Some(
1271 v_flex()
1272 .w_full()
1273 .gap_1()
1274 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1275 .child(ListSubHeader::new(title.clone()).inset(true))
1276 .into_any_element(),
1277 ),
1278 ProjectPickerEntry::OpenFolder { index, positions } => {
1279 let folder = self.open_folders.get(*index)?;
1280 let name = folder.name.clone();
1281 let path = folder.path.compact();
1282 let branch = folder.branch.clone();
1283 let is_active = folder.is_active;
1284 let worktree_id = folder.worktree_id;
1285 let positions = positions.clone();
1286 let show_path = self.style == ProjectPickerStyle::Modal;
1287
1288 let secondary_actions = h_flex()
1289 .gap_1()
1290 .child(
1291 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1292 .icon_size(IconSize::Small)
1293 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1294 .on_click(cx.listener(move |picker, _, window, cx| {
1295 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1296 return;
1297 };
1298 workspace.update(cx, |workspace, cx| {
1299 let project = workspace.project().clone();
1300 project.update(cx, |project, cx| {
1301 project.remove_worktree(worktree_id, cx);
1302 });
1303 });
1304 picker.delegate.open_folders =
1305 get_open_folders(workspace.read(cx), cx);
1306 let query = picker.query(cx);
1307 picker.update_matches(query, window, cx);
1308 })),
1309 )
1310 .into_any_element();
1311
1312 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1313
1314 Some(
1315 ListItem::new(ix)
1316 .toggle_state(selected)
1317 .inset(true)
1318 .spacing(ListItemSpacing::Sparse)
1319 .child(
1320 h_flex()
1321 .id("open_folder_item")
1322 .gap_3()
1323 .flex_grow()
1324 .when(self.has_any_non_local_projects, |this| {
1325 this.child(Icon::new(icon).color(Color::Muted))
1326 })
1327 .child(
1328 v_flex()
1329 .child(
1330 h_flex()
1331 .gap_1()
1332 .child({
1333 let highlighted = HighlightedMatch {
1334 text: name.to_string(),
1335 highlight_positions: positions,
1336 color: Color::Default,
1337 };
1338 highlighted.render(window, cx)
1339 })
1340 .when_some(branch, |this, branch| {
1341 this.child(
1342 Label::new(branch).color(Color::Muted),
1343 )
1344 })
1345 .when(is_active, |this| {
1346 this.child(
1347 Icon::new(IconName::Check)
1348 .size(IconSize::Small)
1349 .color(Color::Accent),
1350 )
1351 }),
1352 )
1353 .when(show_path, |this| {
1354 this.child(
1355 Label::new(path.to_string_lossy().to_string())
1356 .size(LabelSize::Small)
1357 .color(Color::Muted),
1358 )
1359 }),
1360 )
1361 .when(!show_path, |this| {
1362 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1363 }),
1364 )
1365 .end_slot(secondary_actions)
1366 .show_end_slot_on_hover()
1367 .into_any_element(),
1368 )
1369 }
1370 ProjectPickerEntry::ProjectGroup(hit) => {
1371 let key = self.window_project_groups.get(hit.candidate_id)?;
1372 let is_active = self.is_active_project_group(key, cx);
1373 let paths = key.path_list();
1374 let ordered_paths: Vec<_> = paths
1375 .ordered_paths()
1376 .map(|p| p.compact().to_string_lossy().to_string())
1377 .collect();
1378 let tooltip_path: SharedString = ordered_paths.join("\n").into();
1379
1380 let mut path_start_offset = 0;
1381 let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
1382 .ordered_paths()
1383 .map(|p| p.compact())
1384 .map(|path| {
1385 let highlighted_text =
1386 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1387 path_start_offset += highlighted_text.1.text.len();
1388 highlighted_text
1389 })
1390 .unzip();
1391
1392 let highlighted_match = HighlightedMatchWithPaths {
1393 prefix: None,
1394 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1395 paths: path_highlights,
1396 active: is_active,
1397 };
1398
1399 let project_group_key = key.clone();
1400 let secondary_actions = h_flex()
1401 .gap_1()
1402 .when(!is_active, |this| {
1403 this.child(
1404 IconButton::new("remove_open_project", IconName::Close)
1405 .icon_size(IconSize::Small)
1406 .tooltip(Tooltip::text("Remove Project from Window"))
1407 .on_click({
1408 let project_group_key = project_group_key.clone();
1409 cx.listener(move |picker, _, window, cx| {
1410 cx.stop_propagation();
1411 window.prevent_default();
1412 picker.delegate.remove_project_group(
1413 project_group_key.clone(),
1414 window,
1415 cx,
1416 );
1417 let query = picker.query(cx);
1418 picker.update_matches(query, window, cx);
1419 })
1420 }),
1421 )
1422 })
1423 .into_any_element();
1424
1425 Some(
1426 ListItem::new(ix)
1427 .toggle_state(selected)
1428 .inset(true)
1429 .spacing(ListItemSpacing::Sparse)
1430 .child(
1431 h_flex()
1432 .id("open_project_info_container")
1433 .gap_3()
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 (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1450 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1451 let paths_to_add = paths.paths().to_vec();
1452 let ordered_paths: Vec<_> = paths
1453 .ordered_paths()
1454 .map(|p| p.compact().to_string_lossy().to_string())
1455 .collect();
1456 let tooltip_path: SharedString = match &location {
1457 SerializedWorkspaceLocation::Remote(options) => {
1458 let host = options.display_name();
1459 if ordered_paths.len() == 1 {
1460 format!("{} ({})", ordered_paths[0], host).into()
1461 } else {
1462 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1463 }
1464 }
1465 _ => ordered_paths.join("\n").into(),
1466 };
1467
1468 let mut path_start_offset = 0;
1469 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1470 .ordered_paths()
1471 .map(|p| p.compact())
1472 .map(|path| {
1473 let highlighted_text =
1474 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1475 path_start_offset += highlighted_text.1.text.len();
1476 highlighted_text
1477 })
1478 .unzip();
1479
1480 let prefix = match &location {
1481 SerializedWorkspaceLocation::Remote(options) => {
1482 Some(SharedString::from(options.display_name()))
1483 }
1484 _ => None,
1485 };
1486
1487 let highlighted_match = HighlightedMatchWithPaths {
1488 prefix,
1489 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1490 paths,
1491 active: false,
1492 };
1493
1494 let focus_handle = self.focus_handle.clone();
1495
1496 let secondary_actions = h_flex()
1497 .gap_px()
1498 .when(is_local, |this| {
1499 this.child(
1500 IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1501 .icon_size(IconSize::Small)
1502 .tooltip(move |_, cx| {
1503 Tooltip::with_meta(
1504 "Add Project to this Workspace",
1505 None,
1506 "As a multi-root folder project",
1507 cx,
1508 )
1509 })
1510 .on_click({
1511 let paths_to_add = paths_to_add.clone();
1512 cx.listener(move |picker, _event, window, cx| {
1513 cx.stop_propagation();
1514 window.prevent_default();
1515 picker.delegate.add_project_to_workspace(
1516 paths_to_add.clone(),
1517 window,
1518 cx,
1519 );
1520 })
1521 }),
1522 )
1523 })
1524 .child(
1525 IconButton::new("open_new_window", IconName::OpenNewWindow)
1526 .icon_size(IconSize::Small)
1527 .tooltip({
1528 move |_, cx| {
1529 Tooltip::for_action_in(
1530 "Open Project in New Window",
1531 &menu::SecondaryConfirm,
1532 &focus_handle,
1533 cx,
1534 )
1535 }
1536 })
1537 .on_click(cx.listener(move |this, _event, window, cx| {
1538 cx.stop_propagation();
1539 window.prevent_default();
1540 this.delegate.set_selected_index(ix, window, cx);
1541 this.delegate.confirm(true, window, cx);
1542 })),
1543 )
1544 .child(
1545 IconButton::new("delete", IconName::Close)
1546 .icon_size(IconSize::Small)
1547 .tooltip(Tooltip::text("Delete from Recent Projects"))
1548 .on_click(cx.listener(move |this, _event, window, cx| {
1549 cx.stop_propagation();
1550 window.prevent_default();
1551 this.delegate.delete_recent_project(ix, window, cx)
1552 })),
1553 )
1554 .into_any_element();
1555
1556 let icon = icon_for_remote_connection(match location {
1557 SerializedWorkspaceLocation::Local => None,
1558 SerializedWorkspaceLocation::Remote(options) => Some(options),
1559 });
1560
1561 Some(
1562 ListItem::new(ix)
1563 .toggle_state(selected)
1564 .inset(true)
1565 .spacing(ListItemSpacing::Sparse)
1566 .child(
1567 h_flex()
1568 .id("project_info_container")
1569 .gap_3()
1570 .flex_grow()
1571 .when(self.has_any_non_local_projects, |this| {
1572 this.child(Icon::new(icon).color(Color::Muted))
1573 })
1574 .child({
1575 let mut highlighted = highlighted_match;
1576 if !self.render_paths {
1577 highlighted.paths.clear();
1578 }
1579 highlighted.render(window, cx)
1580 })
1581 .tooltip(move |_, cx| {
1582 Tooltip::with_meta(
1583 "Open Project in This Window",
1584 None,
1585 tooltip_path.clone(),
1586 cx,
1587 )
1588 }),
1589 )
1590 .end_slot(secondary_actions)
1591 .show_end_slot_on_hover()
1592 .into_any_element(),
1593 )
1594 }
1595 }
1596 }
1597
1598 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1599 let focus_handle = self.focus_handle.clone();
1600 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1601 let is_already_open_entry = matches!(
1602 self.filtered_entries.get(self.selected_index),
1603 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
1604 );
1605
1606 if popover_style {
1607 return Some(
1608 v_flex()
1609 .flex_1()
1610 .p_1p5()
1611 .gap_1()
1612 .border_t_1()
1613 .border_color(cx.theme().colors().border_variant)
1614 .child({
1615 let open_action = workspace::Open::default();
1616 Button::new("open_local_folder", "Open Local Project")
1617 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1618 .on_click(move |_, window, cx| {
1619 window.dispatch_action(open_action.boxed_clone(), cx)
1620 })
1621 })
1622 .child(
1623 Button::new("open_remote_folder", "Open Remote Project")
1624 .key_binding(KeyBinding::for_action(
1625 &OpenRemote {
1626 from_existing_connection: false,
1627 create_new_window: false,
1628 },
1629 cx,
1630 ))
1631 .on_click(|_, window, cx| {
1632 window.dispatch_action(
1633 OpenRemote {
1634 from_existing_connection: false,
1635 create_new_window: false,
1636 }
1637 .boxed_clone(),
1638 cx,
1639 )
1640 }),
1641 )
1642 .into_any(),
1643 );
1644 }
1645
1646 let selected_entry = self.filtered_entries.get(self.selected_index);
1647
1648 let is_current_workspace_entry =
1649 if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
1650 self.window_project_groups
1651 .get(hit.candidate_id)
1652 .is_some_and(|key| self.is_active_project_group(key, cx))
1653 } else {
1654 false
1655 };
1656
1657 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1658 Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1659 Button::new("remove_selected", "Remove Folder")
1660 .key_binding(KeyBinding::for_action_in(
1661 &RemoveSelected,
1662 &focus_handle,
1663 cx,
1664 ))
1665 .on_click(|_, window, cx| {
1666 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1667 })
1668 .into_any_element(),
1669 ),
1670 Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
1671 Button::new("remove_selected", "Remove from Window")
1672 .key_binding(KeyBinding::for_action_in(
1673 &RemoveSelected,
1674 &focus_handle,
1675 cx,
1676 ))
1677 .on_click(|_, window, cx| {
1678 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1679 })
1680 .into_any_element(),
1681 ),
1682 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1683 Button::new("delete_recent", "Delete")
1684 .key_binding(KeyBinding::for_action_in(
1685 &RemoveSelected,
1686 &focus_handle,
1687 cx,
1688 ))
1689 .on_click(|_, window, cx| {
1690 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1691 })
1692 .into_any_element(),
1693 ),
1694 _ => None,
1695 };
1696
1697 Some(
1698 h_flex()
1699 .flex_1()
1700 .p_1p5()
1701 .gap_1()
1702 .justify_end()
1703 .border_t_1()
1704 .border_color(cx.theme().colors().border_variant)
1705 .when_some(secondary_footer_actions, |this, actions| {
1706 this.child(actions)
1707 })
1708 .map(|this| {
1709 if is_already_open_entry {
1710 this.child(
1711 Button::new("activate", "Activate")
1712 .key_binding(KeyBinding::for_action_in(
1713 &menu::Confirm,
1714 &focus_handle,
1715 cx,
1716 ))
1717 .on_click(|_, window, cx| {
1718 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1719 }),
1720 )
1721 } else {
1722 this.child(
1723 Button::new("open_new_window", "New Window")
1724 .key_binding(KeyBinding::for_action_in(
1725 &menu::SecondaryConfirm,
1726 &focus_handle,
1727 cx,
1728 ))
1729 .on_click(|_, window, cx| {
1730 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1731 }),
1732 )
1733 .child(
1734 Button::new("open_here", "Open")
1735 .key_binding(KeyBinding::for_action_in(
1736 &menu::Confirm,
1737 &focus_handle,
1738 cx,
1739 ))
1740 .on_click(|_, window, cx| {
1741 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1742 }),
1743 )
1744 }
1745 })
1746 .child(Divider::vertical())
1747 .child(
1748 PopoverMenu::new("actions-menu-popover")
1749 .with_handle(self.actions_menu_handle.clone())
1750 .anchor(gpui::Corner::BottomRight)
1751 .offset(gpui::Point {
1752 x: px(0.0),
1753 y: px(-2.0),
1754 })
1755 .trigger(
1756 Button::new("actions-trigger", "Actions")
1757 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1758 .key_binding(KeyBinding::for_action_in(
1759 &ToggleActionsMenu,
1760 &focus_handle,
1761 cx,
1762 )),
1763 )
1764 .menu({
1765 let focus_handle = focus_handle.clone();
1766 let show_add_to_workspace = match selected_entry {
1767 Some(ProjectPickerEntry::RecentProject(hit)) => self
1768 .workspaces
1769 .get(hit.candidate_id)
1770 .map(|(_, loc, ..)| {
1771 matches!(loc, SerializedWorkspaceLocation::Local)
1772 })
1773 .unwrap_or(false),
1774 _ => false,
1775 };
1776
1777 move |window, cx| {
1778 Some(ContextMenu::build(window, cx, {
1779 let focus_handle = focus_handle.clone();
1780 move |menu, _, _| {
1781 menu.context(focus_handle)
1782 .when(show_add_to_workspace, |menu| {
1783 menu.action(
1784 "Add to this Workspace",
1785 AddToWorkspace.boxed_clone(),
1786 )
1787 .separator()
1788 })
1789 .action(
1790 "Open Local Project",
1791 workspace::Open::default().boxed_clone(),
1792 )
1793 .action(
1794 "Open Remote Project",
1795 OpenRemote {
1796 from_existing_connection: false,
1797 create_new_window: false,
1798 }
1799 .boxed_clone(),
1800 )
1801 }
1802 }))
1803 }
1804 }),
1805 )
1806 .into_any(),
1807 )
1808 }
1809}
1810
1811pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1812 match options {
1813 None => IconName::Screen,
1814 Some(options) => match options {
1815 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1816 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1817 RemoteConnectionOptions::Docker(_) => IconName::Box,
1818 #[cfg(any(test, feature = "test-support"))]
1819 RemoteConnectionOptions::Mock(_) => IconName::Server,
1820 },
1821 }
1822}
1823
1824// Compute the highlighted text for the name and path
1825pub(crate) fn highlights_for_path(
1826 path: &Path,
1827 match_positions: &Vec<usize>,
1828 path_start_offset: usize,
1829) -> (Option<HighlightedMatch>, HighlightedMatch) {
1830 let path_string = path.to_string_lossy();
1831 let path_text = path_string.to_string();
1832 let path_byte_len = path_text.len();
1833 // Get the subset of match highlight positions that line up with the given path.
1834 // Also adjusts them to start at the path start
1835 let path_positions = match_positions
1836 .iter()
1837 .copied()
1838 .skip_while(|position| *position < path_start_offset)
1839 .take_while(|position| *position < path_start_offset + path_byte_len)
1840 .map(|position| position - path_start_offset)
1841 .collect::<Vec<_>>();
1842
1843 // Again subset the highlight positions to just those that line up with the file_name
1844 // again adjusted to the start of the file_name
1845 let file_name_text_and_positions = path.file_name().map(|file_name| {
1846 let file_name_text = file_name.to_string_lossy().into_owned();
1847 let file_name_start_byte = path_byte_len - file_name_text.len();
1848 let highlight_positions = path_positions
1849 .iter()
1850 .copied()
1851 .skip_while(|position| *position < file_name_start_byte)
1852 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1853 .map(|position| position - file_name_start_byte)
1854 .collect::<Vec<_>>();
1855 HighlightedMatch {
1856 text: file_name_text,
1857 highlight_positions,
1858 color: Color::Default,
1859 }
1860 });
1861
1862 (
1863 file_name_text_and_positions,
1864 HighlightedMatch {
1865 text: path_text,
1866 highlight_positions: path_positions,
1867 color: Color::Default,
1868 },
1869 )
1870}
1871impl RecentProjectsDelegate {
1872 fn add_project_to_workspace(
1873 &mut self,
1874 paths: Vec<PathBuf>,
1875 window: &mut Window,
1876 cx: &mut Context<Picker<Self>>,
1877 ) {
1878 let Some(workspace) = self.workspace.upgrade() else {
1879 return;
1880 };
1881 let open_paths_task = workspace.update(cx, |workspace, cx| {
1882 workspace.open_paths(
1883 paths,
1884 OpenOptions {
1885 visible: Some(OpenVisible::All),
1886 ..Default::default()
1887 },
1888 None,
1889 window,
1890 cx,
1891 )
1892 });
1893 cx.spawn_in(window, async move |picker, cx| {
1894 let _result = open_paths_task.await;
1895 picker
1896 .update_in(cx, |picker, window, cx| {
1897 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1898 return;
1899 };
1900 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1901 let query = picker.query(cx);
1902 picker.update_matches(query, window, cx);
1903 })
1904 .ok();
1905 })
1906 .detach();
1907 }
1908
1909 fn delete_recent_project(
1910 &self,
1911 ix: usize,
1912 window: &mut Window,
1913 cx: &mut Context<Picker<Self>>,
1914 ) {
1915 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1916 self.filtered_entries.get(ix)
1917 {
1918 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1919 let workspace_id = *workspace_id;
1920 let fs = self
1921 .workspace
1922 .upgrade()
1923 .map(|ws| ws.read(cx).app_state().fs.clone());
1924 let db = WorkspaceDb::global(cx);
1925 cx.spawn_in(window, async move |this, cx| {
1926 db.delete_workspace_by_id(workspace_id).await.log_err();
1927 let Some(fs) = fs else { return };
1928 let workspaces = db
1929 .recent_workspaces_on_disk(fs.as_ref())
1930 .await
1931 .unwrap_or_default();
1932 let workspaces =
1933 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1934 this.update_in(cx, move |picker, window, cx| {
1935 picker.delegate.set_workspaces(workspaces);
1936 picker
1937 .delegate
1938 .set_selected_index(ix.saturating_sub(1), window, cx);
1939 picker.delegate.reset_selected_match_index = false;
1940 picker.update_matches(picker.query(cx), window, cx);
1941 // After deleting a project, we want to update the history manager to reflect the change.
1942 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1943 if let Some(history_manager) = HistoryManager::global(cx) {
1944 history_manager
1945 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1946 }
1947 })
1948 .ok();
1949 })
1950 .detach();
1951 }
1952 }
1953
1954 fn remove_project_group(
1955 &mut self,
1956 key: ProjectGroupKey,
1957 window: &mut Window,
1958 cx: &mut Context<Picker<Self>>,
1959 ) {
1960 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1961 let key_for_remove = key.clone();
1962 cx.defer(move |cx| {
1963 handle
1964 .update(cx, |multi_workspace, window, cx| {
1965 multi_workspace
1966 .remove_project_group(&key_for_remove, window, cx)
1967 .detach_and_log_err(cx);
1968 })
1969 .log_err();
1970 });
1971 }
1972
1973 self.window_project_groups.retain(|k| k != &key);
1974 }
1975
1976 fn is_current_workspace(
1977 &self,
1978 workspace_id: WorkspaceId,
1979 cx: &mut Context<Picker<Self>>,
1980 ) -> bool {
1981 if let Some(workspace) = self.workspace.upgrade() {
1982 let workspace = workspace.read(cx);
1983 if Some(workspace_id) == workspace.database_id() {
1984 return true;
1985 }
1986 }
1987
1988 false
1989 }
1990
1991 fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
1992 if let Some(workspace) = self.workspace.upgrade() {
1993 return workspace.read(cx).project_group_key(cx) == *key;
1994 }
1995 false
1996 }
1997
1998 fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
1999 self.window_project_groups
2000 .iter()
2001 .any(|key| key.path_list() == paths)
2002 }
2003
2004 fn is_open_folder(&self, paths: &PathList) -> bool {
2005 if self.open_folders.is_empty() {
2006 return false;
2007 }
2008
2009 for workspace_path in paths.paths() {
2010 for open_folder in &self.open_folders {
2011 if workspace_path == &open_folder.path {
2012 return true;
2013 }
2014 }
2015 }
2016
2017 false
2018 }
2019
2020 fn is_valid_recent_candidate(
2021 &self,
2022 workspace_id: WorkspaceId,
2023 paths: &PathList,
2024 cx: &mut Context<Picker<Self>>,
2025 ) -> bool {
2026 !self.is_current_workspace(workspace_id, cx)
2027 && !self.is_in_current_window_groups(paths)
2028 && !self.is_open_folder(paths)
2029 }
2030}
2031
2032#[cfg(test)]
2033mod tests {
2034 use gpui::{TestAppContext, VisualTestContext};
2035
2036 use serde_json::json;
2037 use util::path;
2038 use workspace::{AppState, open_paths};
2039
2040 use super::*;
2041
2042 #[gpui::test]
2043 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2044 let app_state = init_test(cx);
2045
2046 app_state
2047 .fs
2048 .as_fake()
2049 .insert_tree(
2050 path!("/project"),
2051 json!({
2052 ".devcontainer": {
2053 "devcontainer.json": "{}"
2054 },
2055 "src": {
2056 "main.rs": "fn main() {}"
2057 }
2058 }),
2059 )
2060 .await;
2061
2062 // Open a file path (not a directory) so that the worktree root is a
2063 // file. This means `active_project_directory` returns `None`, which
2064 // causes `DevContainerContext::from_workspace` to return `None`,
2065 // preventing `open_dev_container` from spawning real I/O (docker
2066 // commands, shell environment loading) that is incompatible with the
2067 // test scheduler. The modal is still created and the re-entrancy
2068 // guard that this test validates is still exercised.
2069 cx.update(|cx| {
2070 open_paths(
2071 &[PathBuf::from(path!("/project/src/main.rs"))],
2072 app_state,
2073 workspace::OpenOptions::default(),
2074 cx,
2075 )
2076 })
2077 .await
2078 .unwrap();
2079
2080 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2081 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2082
2083 cx.run_until_parked();
2084
2085 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2086 // -> Workspace::update -> toggle_modal -> new_dev_container.
2087 // Before the fix, this panicked with "cannot read workspace::Workspace while
2088 // it is already being updated" because new_dev_container and open_dev_container
2089 // tried to read the Workspace entity through a WeakEntity handle while it was
2090 // already leased by the outer update.
2091 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2092
2093 multi_workspace
2094 .update(cx, |multi_workspace, _, cx| {
2095 let modal = multi_workspace
2096 .workspace()
2097 .read(cx)
2098 .active_modal::<RemoteServerProjects>(cx);
2099 assert!(
2100 modal.is_some(),
2101 "Dev container modal should be open after dispatching OpenDevContainer"
2102 );
2103 })
2104 .unwrap();
2105 }
2106
2107 #[gpui::test]
2108 async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2109 let app_state = init_test(cx);
2110
2111 app_state
2112 .fs
2113 .as_fake()
2114 .insert_tree(
2115 path!("/project"),
2116 json!({
2117 ".devcontainer": {
2118 "devcontainer.json": "{}"
2119 },
2120 "src": {
2121 "main.rs": "fn main() {}"
2122 }
2123 }),
2124 )
2125 .await;
2126
2127 cx.update(|cx| {
2128 open_paths(
2129 &[PathBuf::from(path!("/project"))],
2130 app_state,
2131 workspace::OpenOptions::default(),
2132 cx,
2133 )
2134 })
2135 .await
2136 .unwrap();
2137
2138 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2139 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2140
2141 cx.run_until_parked();
2142
2143 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2144
2145 multi_workspace
2146 .update(cx, |multi_workspace, _, cx| {
2147 assert!(
2148 multi_workspace
2149 .active_modal::<RemoteServerProjects>(cx)
2150 .is_some(),
2151 "Dev container modal should be open"
2152 );
2153 })
2154 .unwrap();
2155
2156 // Click outside the modal (on the backdrop) to try to dismiss it
2157 let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2158 vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2159
2160 multi_workspace
2161 .update(cx, |multi_workspace, _, cx| {
2162 assert!(
2163 multi_workspace
2164 .active_modal::<RemoteServerProjects>(cx)
2165 .is_some(),
2166 "Dev container modal should remain open during creation"
2167 );
2168 })
2169 .unwrap();
2170 }
2171
2172 #[gpui::test]
2173 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2174 let app_state = init_test(cx);
2175
2176 app_state
2177 .fs
2178 .as_fake()
2179 .insert_tree(
2180 path!("/project"),
2181 json!({
2182 ".devcontainer": {
2183 "rust": {
2184 "devcontainer.json": "{}"
2185 },
2186 "python": {
2187 "devcontainer.json": "{}"
2188 }
2189 },
2190 "src": {
2191 "main.rs": "fn main() {}"
2192 }
2193 }),
2194 )
2195 .await;
2196
2197 cx.update(|cx| {
2198 open_paths(
2199 &[PathBuf::from(path!("/project"))],
2200 app_state,
2201 workspace::OpenOptions::default(),
2202 cx,
2203 )
2204 })
2205 .await
2206 .unwrap();
2207
2208 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2209 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2210
2211 cx.run_until_parked();
2212
2213 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2214
2215 multi_workspace
2216 .update(cx, |multi_workspace, _, cx| {
2217 let modal = multi_workspace
2218 .workspace()
2219 .read(cx)
2220 .active_modal::<RemoteServerProjects>(cx);
2221 assert!(
2222 modal.is_some(),
2223 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2224 );
2225 })
2226 .unwrap();
2227 }
2228
2229 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2230 cx.update(|cx| {
2231 let state = AppState::test(cx);
2232 crate::init(cx);
2233 editor::init(cx);
2234 state
2235 })
2236 }
2237}