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