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