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