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, 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 replace_window = match create_new_window {
266 false => window_handle,
267 true => None,
268 };
269
270 let open_options = workspace::OpenOptions {
271 replace_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 replace_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 .iter()
358 .filter_map(|ws| ws.read(cx).database_id())
359 .collect();
360
361 let workspace = multi_workspace.workspace().clone();
362 workspace.update(cx, |workspace, cx| {
363 let Some(recent_projects) =
364 workspace.active_modal::<RecentProjects>(cx)
365 else {
366 let focus_handle = workspace.focus_handle(cx);
367 RecentProjects::open(
368 workspace,
369 create_new_window,
370 sibling_workspace_ids,
371 window,
372 focus_handle,
373 cx,
374 );
375 return;
376 };
377
378 recent_projects.update(cx, |recent_projects, cx| {
379 recent_projects
380 .picker
381 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
382 });
383 });
384 })
385 .log_err();
386 });
387 }
388 None => {
389 with_active_or_new_workspace(cx, move |workspace, window, cx| {
390 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
391 let focus_handle = workspace.focus_handle(cx);
392 RecentProjects::open(
393 workspace,
394 create_new_window,
395 HashSet::new(),
396 window,
397 focus_handle,
398 cx,
399 );
400 return;
401 };
402
403 recent_projects.update(cx, |recent_projects, cx| {
404 recent_projects
405 .picker
406 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
407 });
408 });
409 }
410 }
411 });
412 cx.on_action(|open_remote: &OpenRemote, cx| {
413 let from_existing_connection = open_remote.from_existing_connection;
414 let create_new_window = open_remote.create_new_window;
415 with_active_or_new_workspace(cx, move |workspace, window, cx| {
416 if from_existing_connection {
417 cx.propagate();
418 return;
419 }
420 let handle = cx.entity().downgrade();
421 let fs = workspace.project().read(cx).fs().clone();
422 workspace.toggle_modal(window, cx, |window, cx| {
423 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
424 })
425 });
426 });
427
428 cx.observe_new(DisconnectedOverlay::register).detach();
429
430 cx.on_action(|_: &OpenDevContainer, cx| {
431 with_active_or_new_workspace(cx, move |workspace, window, cx| {
432 if !workspace.project().read(cx).is_local() {
433 cx.spawn_in(window, async move |_, cx| {
434 cx.prompt(
435 gpui::PromptLevel::Critical,
436 "Cannot open Dev Container from remote project",
437 None,
438 &["Ok"],
439 )
440 .await
441 .ok();
442 })
443 .detach();
444 return;
445 }
446
447 let fs = workspace.project().read(cx).fs().clone();
448 let configs = find_devcontainer_configs(workspace, cx);
449 let app_state = workspace.app_state().clone();
450 let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
451 let handle = cx.entity().downgrade();
452 workspace.toggle_modal(window, cx, |window, cx| {
453 RemoteServerProjects::new_dev_container(
454 fs,
455 configs,
456 app_state,
457 dev_container_context,
458 window,
459 handle,
460 cx,
461 )
462 });
463 });
464 });
465
466 // Subscribe to worktree additions to suggest opening the project in a dev container
467 cx.observe_new(
468 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
469 let Some(window) = window else {
470 return;
471 };
472 cx.subscribe_in(
473 workspace.project(),
474 window,
475 move |_, project, event, window, cx| {
476 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
477 event
478 {
479 dev_container_suggest::suggest_on_worktree_updated(
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 sibling_workspace_ids: HashSet<WorkspaceId>,
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 sibling_workspace_ids,
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 sibling_workspace_ids: HashSet<WorkspaceId>,
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 sibling_workspace_ids,
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
689impl EventEmitter<DismissEvent> for RecentProjects {}
690
691impl Focusable for RecentProjects {
692 fn focus_handle(&self, cx: &App) -> FocusHandle {
693 self.picker.focus_handle(cx)
694 }
695}
696
697impl Render for RecentProjects {
698 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
699 v_flex()
700 .key_context("RecentProjects")
701 .on_action(cx.listener(Self::handle_toggle_open_menu))
702 .w(rems(self.rem_width))
703 .child(self.picker.clone())
704 }
705}
706
707pub struct RecentProjectsDelegate {
708 workspace: WeakEntity<Workspace>,
709 open_folders: Vec<OpenFolderEntry>,
710 sibling_workspace_ids: HashSet<WorkspaceId>,
711 workspaces: Vec<(
712 WorkspaceId,
713 SerializedWorkspaceLocation,
714 PathList,
715 DateTime<Utc>,
716 )>,
717 filtered_entries: Vec<ProjectPickerEntry>,
718 selected_index: usize,
719 render_paths: bool,
720 create_new_window: bool,
721 // Flag to reset index when there is a new query vs not reset index when user delete an item
722 reset_selected_match_index: bool,
723 has_any_non_local_projects: bool,
724 project_connection_options: Option<RemoteConnectionOptions>,
725 focus_handle: FocusHandle,
726 style: ProjectPickerStyle,
727 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
728}
729
730impl RecentProjectsDelegate {
731 fn new(
732 workspace: WeakEntity<Workspace>,
733 create_new_window: bool,
734 focus_handle: FocusHandle,
735 open_folders: Vec<OpenFolderEntry>,
736 sibling_workspace_ids: HashSet<WorkspaceId>,
737 project_connection_options: Option<RemoteConnectionOptions>,
738 style: ProjectPickerStyle,
739 ) -> Self {
740 let render_paths = style == ProjectPickerStyle::Modal;
741 Self {
742 workspace,
743 open_folders,
744 sibling_workspace_ids,
745 workspaces: Vec::new(),
746 filtered_entries: Vec::new(),
747 selected_index: 0,
748 create_new_window,
749 render_paths,
750 reset_selected_match_index: true,
751 has_any_non_local_projects: project_connection_options.is_some(),
752 project_connection_options,
753 focus_handle,
754 style,
755 actions_menu_handle: PopoverMenuHandle::default(),
756 }
757 }
758
759 pub fn set_workspaces(
760 &mut self,
761 workspaces: Vec<(
762 WorkspaceId,
763 SerializedWorkspaceLocation,
764 PathList,
765 DateTime<Utc>,
766 )>,
767 ) {
768 self.workspaces = workspaces;
769 let has_non_local_recent = !self
770 .workspaces
771 .iter()
772 .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
773 self.has_any_non_local_projects =
774 self.project_connection_options.is_some() || has_non_local_recent;
775 }
776}
777impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
778impl PickerDelegate for RecentProjectsDelegate {
779 type ListItem = AnyElement;
780
781 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
782 "Search projects…".into()
783 }
784
785 fn render_editor(
786 &self,
787 editor: &Arc<dyn ErasedEditor>,
788 window: &mut Window,
789 cx: &mut Context<Picker<Self>>,
790 ) -> Div {
791 h_flex()
792 .flex_none()
793 .h_9()
794 .px_2p5()
795 .justify_between()
796 .border_b_1()
797 .border_color(cx.theme().colors().border_variant)
798 .child(editor.render(window, cx))
799 }
800
801 fn match_count(&self) -> usize {
802 self.filtered_entries.len()
803 }
804
805 fn selected_index(&self) -> usize {
806 self.selected_index
807 }
808
809 fn set_selected_index(
810 &mut self,
811 ix: usize,
812 _window: &mut Window,
813 _cx: &mut Context<Picker<Self>>,
814 ) {
815 self.selected_index = ix;
816 }
817
818 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
819 matches!(
820 self.filtered_entries.get(ix),
821 Some(
822 ProjectPickerEntry::OpenFolder { .. }
823 | ProjectPickerEntry::OpenProject(_)
824 | ProjectPickerEntry::RecentProject(_)
825 )
826 )
827 }
828
829 fn update_matches(
830 &mut self,
831 query: String,
832 _: &mut Window,
833 cx: &mut Context<Picker<Self>>,
834 ) -> gpui::Task<()> {
835 let query = query.trim_start();
836 let smart_case = query.chars().any(|c| c.is_uppercase());
837 let is_empty_query = query.is_empty();
838
839 let folder_matches = if self.open_folders.is_empty() {
840 Vec::new()
841 } else {
842 let candidates: Vec<_> = self
843 .open_folders
844 .iter()
845 .enumerate()
846 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
847 .collect();
848
849 smol::block_on(fuzzy::match_strings(
850 &candidates,
851 query,
852 smart_case,
853 true,
854 100,
855 &Default::default(),
856 cx.background_executor().clone(),
857 ))
858 };
859
860 let sibling_candidates: Vec<_> = self
861 .workspaces
862 .iter()
863 .enumerate()
864 .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id, cx))
865 .map(|(id, (_, _, paths, _))| {
866 let combined_string = paths
867 .ordered_paths()
868 .map(|path| path.compact().to_string_lossy().into_owned())
869 .collect::<Vec<_>>()
870 .join("");
871 StringMatchCandidate::new(id, &combined_string)
872 })
873 .collect();
874
875 let mut sibling_matches = smol::block_on(fuzzy::match_strings(
876 &sibling_candidates,
877 query,
878 smart_case,
879 true,
880 100,
881 &Default::default(),
882 cx.background_executor().clone(),
883 ));
884 sibling_matches.sort_unstable_by(|a, b| {
885 b.score
886 .partial_cmp(&a.score)
887 .unwrap_or(std::cmp::Ordering::Equal)
888 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
889 });
890
891 // Build candidates for recent projects (not current, not sibling, not open folder)
892 let recent_candidates: Vec<_> = self
893 .workspaces
894 .iter()
895 .enumerate()
896 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
897 .map(|(id, (_, _, paths, _))| {
898 let combined_string = paths
899 .ordered_paths()
900 .map(|path| path.compact().to_string_lossy().into_owned())
901 .collect::<Vec<_>>()
902 .join("");
903 StringMatchCandidate::new(id, &combined_string)
904 })
905 .collect();
906
907 let mut recent_matches = smol::block_on(fuzzy::match_strings(
908 &recent_candidates,
909 query,
910 smart_case,
911 true,
912 100,
913 &Default::default(),
914 cx.background_executor().clone(),
915 ));
916 recent_matches.sort_unstable_by(|a, b| {
917 b.score
918 .partial_cmp(&a.score)
919 .unwrap_or(std::cmp::Ordering::Equal)
920 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
921 });
922
923 let mut entries = Vec::new();
924
925 if !self.open_folders.is_empty() {
926 let matched_folders: Vec<_> = if is_empty_query {
927 (0..self.open_folders.len())
928 .map(|i| (i, Vec::new()))
929 .collect()
930 } else {
931 folder_matches
932 .iter()
933 .map(|m| (m.candidate_id, m.positions.clone()))
934 .collect()
935 };
936
937 for (index, positions) in matched_folders {
938 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
939 }
940 }
941
942 let has_siblings_to_show = if is_empty_query {
943 !sibling_candidates.is_empty()
944 } else {
945 !sibling_matches.is_empty()
946 };
947
948 if has_siblings_to_show {
949 entries.push(ProjectPickerEntry::Header("This Window".into()));
950
951 if is_empty_query {
952 for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
953 if self.is_sibling_workspace(*workspace_id, cx) {
954 entries.push(ProjectPickerEntry::OpenProject(StringMatch {
955 candidate_id: id,
956 score: 0.0,
957 positions: Vec::new(),
958 string: String::new(),
959 }));
960 }
961 }
962 } else {
963 for m in sibling_matches {
964 entries.push(ProjectPickerEntry::OpenProject(m));
965 }
966 }
967 }
968
969 let has_recent_to_show = if is_empty_query {
970 !recent_candidates.is_empty()
971 } else {
972 !recent_matches.is_empty()
973 };
974
975 if has_recent_to_show {
976 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
977
978 if is_empty_query {
979 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
980 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
981 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
982 candidate_id: id,
983 score: 0.0,
984 positions: Vec::new(),
985 string: String::new(),
986 }));
987 }
988 }
989 } else {
990 for m in recent_matches {
991 entries.push(ProjectPickerEntry::RecentProject(m));
992 }
993 }
994 }
995
996 self.filtered_entries = entries;
997
998 if self.reset_selected_match_index {
999 self.selected_index = self
1000 .filtered_entries
1001 .iter()
1002 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1003 .unwrap_or(0);
1004 }
1005 self.reset_selected_match_index = true;
1006 Task::ready(())
1007 }
1008
1009 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1010 match self.filtered_entries.get(self.selected_index) {
1011 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1012 let Some(folder) = self.open_folders.get(*index) else {
1013 return;
1014 };
1015 let worktree_id = folder.worktree_id;
1016 if let Some(workspace) = self.workspace.upgrade() {
1017 workspace.update(cx, |workspace, cx| {
1018 workspace.set_active_worktree_override(Some(worktree_id), cx);
1019 });
1020 }
1021 cx.emit(DismissEvent);
1022 }
1023 Some(ProjectPickerEntry::OpenProject(selected_match)) => {
1024 let Some((workspace_id, _, _, _)) =
1025 self.workspaces.get(selected_match.candidate_id)
1026 else {
1027 return;
1028 };
1029 let workspace_id = *workspace_id;
1030
1031 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1032 cx.defer(move |cx| {
1033 handle
1034 .update(cx, |multi_workspace, _window, cx| {
1035 let workspace = multi_workspace
1036 .workspaces()
1037 .iter()
1038 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1039 .cloned();
1040 if let Some(workspace) = workspace {
1041 multi_workspace.activate(workspace, cx);
1042 }
1043 })
1044 .log_err();
1045 });
1046 }
1047 cx.emit(DismissEvent);
1048 }
1049 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1050 let Some(workspace) = self.workspace.upgrade() else {
1051 return;
1052 };
1053 let Some((
1054 candidate_workspace_id,
1055 candidate_workspace_location,
1056 candidate_workspace_paths,
1057 _,
1058 )) = self.workspaces.get(selected_match.candidate_id)
1059 else {
1060 return;
1061 };
1062
1063 let replace_current_window = self.create_new_window == secondary;
1064 let candidate_workspace_id = *candidate_workspace_id;
1065 let candidate_workspace_location = candidate_workspace_location.clone();
1066 let candidate_workspace_paths = candidate_workspace_paths.clone();
1067
1068 workspace.update(cx, |workspace, cx| {
1069 if workspace.database_id() == Some(candidate_workspace_id) {
1070 return;
1071 }
1072 match candidate_workspace_location {
1073 SerializedWorkspaceLocation::Local => {
1074 let paths = candidate_workspace_paths.paths().to_vec();
1075 if replace_current_window {
1076 if let Some(handle) =
1077 window.window_handle().downcast::<MultiWorkspace>()
1078 {
1079 cx.defer(move |cx| {
1080 if let Some(task) = handle
1081 .update(cx, |multi_workspace, window, cx| {
1082 multi_workspace.open_project(paths, window, cx)
1083 })
1084 .log_err()
1085 {
1086 task.detach_and_log_err(cx);
1087 }
1088 });
1089 }
1090 return;
1091 } else {
1092 workspace
1093 .open_workspace_for_paths(false, paths, window, cx)
1094 .detach_and_prompt_err(
1095 "Failed to open project",
1096 window,
1097 cx,
1098 |_, _, _| None,
1099 );
1100 }
1101 }
1102 SerializedWorkspaceLocation::Remote(mut connection) => {
1103 let app_state = workspace.app_state().clone();
1104 let replace_window = if replace_current_window {
1105 window.window_handle().downcast::<MultiWorkspace>()
1106 } else {
1107 None
1108 };
1109 let open_options = OpenOptions {
1110 replace_window,
1111 ..Default::default()
1112 };
1113 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1114 RemoteSettings::get_global(cx)
1115 .fill_connection_options_from_settings(connection);
1116 };
1117 let paths = candidate_workspace_paths.paths().to_vec();
1118 cx.spawn_in(window, async move |_, cx| {
1119 open_remote_project(
1120 connection.clone(),
1121 paths,
1122 app_state,
1123 open_options,
1124 cx,
1125 )
1126 .await
1127 })
1128 .detach_and_prompt_err(
1129 "Failed to open project",
1130 window,
1131 cx,
1132 |_, _, _| None,
1133 );
1134 }
1135 }
1136 });
1137 cx.emit(DismissEvent);
1138 }
1139 _ => {}
1140 }
1141 }
1142
1143 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1144
1145 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1146 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1147 "Recently opened projects will show up here".into()
1148 } else {
1149 "No matches".into()
1150 };
1151 Some(text)
1152 }
1153
1154 fn render_match(
1155 &self,
1156 ix: usize,
1157 selected: bool,
1158 window: &mut Window,
1159 cx: &mut Context<Picker<Self>>,
1160 ) -> Option<Self::ListItem> {
1161 match self.filtered_entries.get(ix)? {
1162 ProjectPickerEntry::Header(title) => Some(
1163 v_flex()
1164 .w_full()
1165 .gap_1()
1166 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1167 .child(ListSubHeader::new(title.clone()).inset(true))
1168 .into_any_element(),
1169 ),
1170 ProjectPickerEntry::OpenFolder { index, positions } => {
1171 let folder = self.open_folders.get(*index)?;
1172 let name = folder.name.clone();
1173 let path = folder.path.compact();
1174 let branch = folder.branch.clone();
1175 let is_active = folder.is_active;
1176 let worktree_id = folder.worktree_id;
1177 let positions = positions.clone();
1178 let show_path = self.style == ProjectPickerStyle::Modal;
1179
1180 let secondary_actions = h_flex()
1181 .gap_1()
1182 .child(
1183 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1184 .icon_size(IconSize::Small)
1185 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1186 .on_click(cx.listener(move |picker, _, window, cx| {
1187 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1188 return;
1189 };
1190 workspace.update(cx, |workspace, cx| {
1191 let project = workspace.project().clone();
1192 project.update(cx, |project, cx| {
1193 project.remove_worktree(worktree_id, cx);
1194 });
1195 });
1196 picker.delegate.open_folders =
1197 get_open_folders(workspace.read(cx), cx);
1198 let query = picker.query(cx);
1199 picker.update_matches(query, window, cx);
1200 })),
1201 )
1202 .into_any_element();
1203
1204 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1205
1206 Some(
1207 ListItem::new(ix)
1208 .toggle_state(selected)
1209 .inset(true)
1210 .spacing(ListItemSpacing::Sparse)
1211 .child(
1212 h_flex()
1213 .id("open_folder_item")
1214 .gap_3()
1215 .flex_grow()
1216 .when(self.has_any_non_local_projects, |this| {
1217 this.child(Icon::new(icon).color(Color::Muted))
1218 })
1219 .child(
1220 v_flex()
1221 .child(
1222 h_flex()
1223 .gap_1()
1224 .child({
1225 let highlighted = HighlightedMatch {
1226 text: name.to_string(),
1227 highlight_positions: positions,
1228 color: Color::Default,
1229 };
1230 highlighted.render(window, cx)
1231 })
1232 .when_some(branch, |this, branch| {
1233 this.child(
1234 Label::new(branch).color(Color::Muted),
1235 )
1236 })
1237 .when(is_active, |this| {
1238 this.child(
1239 Icon::new(IconName::Check)
1240 .size(IconSize::Small)
1241 .color(Color::Accent),
1242 )
1243 }),
1244 )
1245 .when(show_path, |this| {
1246 this.child(
1247 Label::new(path.to_string_lossy().to_string())
1248 .size(LabelSize::Small)
1249 .color(Color::Muted),
1250 )
1251 }),
1252 )
1253 .when(!show_path, |this| {
1254 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1255 }),
1256 )
1257 .map(|el| {
1258 if self.selected_index == ix {
1259 el.end_slot(secondary_actions)
1260 } else {
1261 el.end_hover_slot(secondary_actions)
1262 }
1263 })
1264 .into_any_element(),
1265 )
1266 }
1267 ProjectPickerEntry::OpenProject(hit) => {
1268 let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1269 let workspace_id = *workspace_id;
1270 let ordered_paths: Vec<_> = paths
1271 .ordered_paths()
1272 .map(|p| p.compact().to_string_lossy().to_string())
1273 .collect();
1274 let tooltip_path: SharedString = match &location {
1275 SerializedWorkspaceLocation::Remote(options) => {
1276 let host = options.display_name();
1277 if ordered_paths.len() == 1 {
1278 format!("{} ({})", ordered_paths[0], host).into()
1279 } else {
1280 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1281 }
1282 }
1283 _ => ordered_paths.join("\n").into(),
1284 };
1285
1286 let mut path_start_offset = 0;
1287 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1288 .ordered_paths()
1289 .map(|p| p.compact())
1290 .map(|path| {
1291 let highlighted_text =
1292 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1293 path_start_offset += highlighted_text.1.text.len();
1294 highlighted_text
1295 })
1296 .unzip();
1297
1298 let prefix = match &location {
1299 SerializedWorkspaceLocation::Remote(options) => {
1300 Some(SharedString::from(options.display_name()))
1301 }
1302 _ => None,
1303 };
1304
1305 let highlighted_match = HighlightedMatchWithPaths {
1306 prefix,
1307 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1308 paths,
1309 };
1310
1311 let icon = icon_for_remote_connection(match location {
1312 SerializedWorkspaceLocation::Local => None,
1313 SerializedWorkspaceLocation::Remote(options) => Some(options),
1314 });
1315
1316 let secondary_actions = h_flex()
1317 .gap_1()
1318 .child(
1319 IconButton::new("remove_open_project", IconName::Close)
1320 .icon_size(IconSize::Small)
1321 .tooltip(Tooltip::text("Remove Project from Window"))
1322 .on_click(cx.listener(move |picker, _, window, cx| {
1323 cx.stop_propagation();
1324 window.prevent_default();
1325 picker
1326 .delegate
1327 .remove_sibling_workspace(workspace_id, window, cx);
1328 let query = picker.query(cx);
1329 picker.update_matches(query, window, cx);
1330 })),
1331 )
1332 .into_any_element();
1333
1334 Some(
1335 ListItem::new(ix)
1336 .toggle_state(selected)
1337 .inset(true)
1338 .spacing(ListItemSpacing::Sparse)
1339 .child(
1340 h_flex()
1341 .id("open_project_info_container")
1342 .gap_3()
1343 .flex_grow()
1344 .when(self.has_any_non_local_projects, |this| {
1345 this.child(Icon::new(icon).color(Color::Muted))
1346 })
1347 .child({
1348 let mut highlighted = highlighted_match;
1349 if !self.render_paths {
1350 highlighted.paths.clear();
1351 }
1352 highlighted.render(window, cx)
1353 })
1354 .tooltip(Tooltip::text(tooltip_path)),
1355 )
1356 .map(|el| {
1357 if self.selected_index == ix {
1358 el.end_slot(secondary_actions)
1359 } else {
1360 el.end_hover_slot(secondary_actions)
1361 }
1362 })
1363 .into_any_element(),
1364 )
1365 }
1366 ProjectPickerEntry::RecentProject(hit) => {
1367 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1368 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1369 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1370 let paths_to_add = paths.paths().to_vec();
1371 let ordered_paths: Vec<_> = paths
1372 .ordered_paths()
1373 .map(|p| p.compact().to_string_lossy().to_string())
1374 .collect();
1375 let tooltip_path: SharedString = match &location {
1376 SerializedWorkspaceLocation::Remote(options) => {
1377 let host = options.display_name();
1378 if ordered_paths.len() == 1 {
1379 format!("{} ({})", ordered_paths[0], host).into()
1380 } else {
1381 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1382 }
1383 }
1384 _ => ordered_paths.join("\n").into(),
1385 };
1386
1387 let mut path_start_offset = 0;
1388 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1389 .ordered_paths()
1390 .map(|p| p.compact())
1391 .map(|path| {
1392 let highlighted_text =
1393 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1394 path_start_offset += highlighted_text.1.text.len();
1395 highlighted_text
1396 })
1397 .unzip();
1398
1399 let prefix = match &location {
1400 SerializedWorkspaceLocation::Remote(options) => {
1401 Some(SharedString::from(options.display_name()))
1402 }
1403 _ => None,
1404 };
1405
1406 let highlighted_match = HighlightedMatchWithPaths {
1407 prefix,
1408 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1409 paths,
1410 };
1411
1412 let focus_handle = self.focus_handle.clone();
1413
1414 let secondary_actions = h_flex()
1415 .gap_px()
1416 .when(is_local, |this| {
1417 this.child(
1418 IconButton::new("add_to_workspace", IconName::FolderPlus)
1419 .icon_size(IconSize::Small)
1420 .tooltip(Tooltip::text("Add Project to this Workspace"))
1421 .on_click({
1422 let paths_to_add = paths_to_add.clone();
1423 cx.listener(move |picker, _event, window, cx| {
1424 cx.stop_propagation();
1425 window.prevent_default();
1426 picker.delegate.add_project_to_workspace(
1427 paths_to_add.clone(),
1428 window,
1429 cx,
1430 );
1431 })
1432 }),
1433 )
1434 })
1435 .when(popover_style, |this| {
1436 this.child(
1437 IconButton::new("open_new_window", IconName::ArrowUpRight)
1438 .icon_size(IconSize::XSmall)
1439 .tooltip({
1440 move |_, cx| {
1441 Tooltip::for_action_in(
1442 "Open Project in New Window",
1443 &menu::SecondaryConfirm,
1444 &focus_handle,
1445 cx,
1446 )
1447 }
1448 })
1449 .on_click(cx.listener(move |this, _event, window, cx| {
1450 cx.stop_propagation();
1451 window.prevent_default();
1452 this.delegate.set_selected_index(ix, window, cx);
1453 this.delegate.confirm(true, window, cx);
1454 })),
1455 )
1456 })
1457 .child(
1458 IconButton::new("delete", IconName::Close)
1459 .icon_size(IconSize::Small)
1460 .tooltip(Tooltip::text("Delete from Recent Projects"))
1461 .on_click(cx.listener(move |this, _event, window, cx| {
1462 cx.stop_propagation();
1463 window.prevent_default();
1464 this.delegate.delete_recent_project(ix, window, cx)
1465 })),
1466 )
1467 .into_any_element();
1468
1469 let icon = icon_for_remote_connection(match location {
1470 SerializedWorkspaceLocation::Local => None,
1471 SerializedWorkspaceLocation::Remote(options) => Some(options),
1472 });
1473
1474 Some(
1475 ListItem::new(ix)
1476 .toggle_state(selected)
1477 .inset(true)
1478 .spacing(ListItemSpacing::Sparse)
1479 .child(
1480 h_flex()
1481 .id("project_info_container")
1482 .gap_3()
1483 .flex_grow()
1484 .when(self.has_any_non_local_projects, |this| {
1485 this.child(Icon::new(icon).color(Color::Muted))
1486 })
1487 .child({
1488 let mut highlighted = highlighted_match;
1489 if !self.render_paths {
1490 highlighted.paths.clear();
1491 }
1492 highlighted.render(window, cx)
1493 })
1494 .tooltip(Tooltip::text(tooltip_path)),
1495 )
1496 .map(|el| {
1497 if self.selected_index == ix {
1498 el.end_slot(secondary_actions)
1499 } else {
1500 el.end_hover_slot(secondary_actions)
1501 }
1502 })
1503 .into_any_element(),
1504 )
1505 }
1506 }
1507 }
1508
1509 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1510 let focus_handle = self.focus_handle.clone();
1511 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1512 let is_already_open_entry = matches!(
1513 self.filtered_entries.get(self.selected_index),
1514 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
1515 );
1516
1517 if popover_style {
1518 return Some(
1519 v_flex()
1520 .flex_1()
1521 .p_1p5()
1522 .gap_1()
1523 .border_t_1()
1524 .border_color(cx.theme().colors().border_variant)
1525 .child({
1526 let open_action = workspace::Open {
1527 create_new_window: self.create_new_window,
1528 };
1529 Button::new("open_local_folder", "Open Local Project")
1530 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1531 .on_click(move |_, window, cx| {
1532 window.dispatch_action(open_action.boxed_clone(), cx)
1533 })
1534 })
1535 .child(
1536 Button::new("open_remote_folder", "Open Remote Project")
1537 .key_binding(KeyBinding::for_action(
1538 &OpenRemote {
1539 from_existing_connection: false,
1540 create_new_window: false,
1541 },
1542 cx,
1543 ))
1544 .on_click(|_, window, cx| {
1545 window.dispatch_action(
1546 OpenRemote {
1547 from_existing_connection: false,
1548 create_new_window: false,
1549 }
1550 .boxed_clone(),
1551 cx,
1552 )
1553 }),
1554 )
1555 .into_any(),
1556 );
1557 }
1558
1559 Some(
1560 h_flex()
1561 .flex_1()
1562 .p_1p5()
1563 .gap_1()
1564 .justify_end()
1565 .border_t_1()
1566 .border_color(cx.theme().colors().border_variant)
1567 .map(|this| {
1568 if is_already_open_entry {
1569 this.child(
1570 Button::new("activate", "Activate")
1571 .key_binding(KeyBinding::for_action_in(
1572 &menu::Confirm,
1573 &focus_handle,
1574 cx,
1575 ))
1576 .on_click(|_, window, cx| {
1577 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1578 }),
1579 )
1580 } else {
1581 this.child(
1582 Button::new("open_new_window", "New Window")
1583 .key_binding(KeyBinding::for_action_in(
1584 &menu::SecondaryConfirm,
1585 &focus_handle,
1586 cx,
1587 ))
1588 .on_click(|_, window, cx| {
1589 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1590 }),
1591 )
1592 .child(
1593 Button::new("open_here", "Open")
1594 .key_binding(KeyBinding::for_action_in(
1595 &menu::Confirm,
1596 &focus_handle,
1597 cx,
1598 ))
1599 .on_click(|_, window, cx| {
1600 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1601 }),
1602 )
1603 }
1604 })
1605 .child(Divider::vertical())
1606 .child(
1607 PopoverMenu::new("actions-menu-popover")
1608 .with_handle(self.actions_menu_handle.clone())
1609 .anchor(gpui::Corner::BottomRight)
1610 .offset(gpui::Point {
1611 x: px(0.0),
1612 y: px(-2.0),
1613 })
1614 .trigger(
1615 Button::new("actions-trigger", "Actions…")
1616 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1617 .key_binding(KeyBinding::for_action_in(
1618 &ToggleActionsMenu,
1619 &focus_handle,
1620 cx,
1621 )),
1622 )
1623 .menu({
1624 let focus_handle = focus_handle.clone();
1625 let create_new_window = self.create_new_window;
1626
1627 move |window, cx| {
1628 Some(ContextMenu::build(window, cx, {
1629 let focus_handle = focus_handle.clone();
1630 move |menu, _, _| {
1631 menu.context(focus_handle)
1632 .action(
1633 "Open Local Project",
1634 workspace::Open { create_new_window }.boxed_clone(),
1635 )
1636 .action(
1637 "Open Remote Project",
1638 OpenRemote {
1639 from_existing_connection: false,
1640 create_new_window: false,
1641 }
1642 .boxed_clone(),
1643 )
1644 }
1645 }))
1646 }
1647 }),
1648 )
1649 .into_any(),
1650 )
1651 }
1652}
1653
1654pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1655 match options {
1656 None => IconName::Screen,
1657 Some(options) => match options {
1658 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1659 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1660 RemoteConnectionOptions::Docker(_) => IconName::Box,
1661 #[cfg(any(test, feature = "test-support"))]
1662 RemoteConnectionOptions::Mock(_) => IconName::Server,
1663 },
1664 }
1665}
1666
1667// Compute the highlighted text for the name and path
1668pub(crate) fn highlights_for_path(
1669 path: &Path,
1670 match_positions: &Vec<usize>,
1671 path_start_offset: usize,
1672) -> (Option<HighlightedMatch>, HighlightedMatch) {
1673 let path_string = path.to_string_lossy();
1674 let path_text = path_string.to_string();
1675 let path_byte_len = path_text.len();
1676 // Get the subset of match highlight positions that line up with the given path.
1677 // Also adjusts them to start at the path start
1678 let path_positions = match_positions
1679 .iter()
1680 .copied()
1681 .skip_while(|position| *position < path_start_offset)
1682 .take_while(|position| *position < path_start_offset + path_byte_len)
1683 .map(|position| position - path_start_offset)
1684 .collect::<Vec<_>>();
1685
1686 // Again subset the highlight positions to just those that line up with the file_name
1687 // again adjusted to the start of the file_name
1688 let file_name_text_and_positions = path.file_name().map(|file_name| {
1689 let file_name_text = file_name.to_string_lossy().into_owned();
1690 let file_name_start_byte = path_byte_len - file_name_text.len();
1691 let highlight_positions = path_positions
1692 .iter()
1693 .copied()
1694 .skip_while(|position| *position < file_name_start_byte)
1695 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1696 .map(|position| position - file_name_start_byte)
1697 .collect::<Vec<_>>();
1698 HighlightedMatch {
1699 text: file_name_text,
1700 highlight_positions,
1701 color: Color::Default,
1702 }
1703 });
1704
1705 (
1706 file_name_text_and_positions,
1707 HighlightedMatch {
1708 text: path_text,
1709 highlight_positions: path_positions,
1710 color: Color::Default,
1711 },
1712 )
1713}
1714impl RecentProjectsDelegate {
1715 fn add_project_to_workspace(
1716 &mut self,
1717 paths: Vec<PathBuf>,
1718 window: &mut Window,
1719 cx: &mut Context<Picker<Self>>,
1720 ) {
1721 let Some(workspace) = self.workspace.upgrade() else {
1722 return;
1723 };
1724 let open_paths_task = workspace.update(cx, |workspace, cx| {
1725 workspace.open_paths(
1726 paths,
1727 OpenOptions {
1728 visible: Some(OpenVisible::All),
1729 ..Default::default()
1730 },
1731 None,
1732 window,
1733 cx,
1734 )
1735 });
1736 cx.spawn_in(window, async move |picker, cx| {
1737 let _result = open_paths_task.await;
1738 picker
1739 .update_in(cx, |picker, window, cx| {
1740 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1741 return;
1742 };
1743 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1744 let query = picker.query(cx);
1745 picker.update_matches(query, window, cx);
1746 })
1747 .ok();
1748 })
1749 .detach();
1750 }
1751
1752 fn delete_recent_project(
1753 &self,
1754 ix: usize,
1755 window: &mut Window,
1756 cx: &mut Context<Picker<Self>>,
1757 ) {
1758 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1759 self.filtered_entries.get(ix)
1760 {
1761 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1762 let workspace_id = *workspace_id;
1763 let fs = self
1764 .workspace
1765 .upgrade()
1766 .map(|ws| ws.read(cx).app_state().fs.clone());
1767 let db = WorkspaceDb::global(cx);
1768 cx.spawn_in(window, async move |this, cx| {
1769 db.delete_workspace_by_id(workspace_id).await.log_err();
1770 let Some(fs) = fs else { return };
1771 let workspaces = db
1772 .recent_workspaces_on_disk(fs.as_ref())
1773 .await
1774 .unwrap_or_default();
1775 let workspaces =
1776 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1777 this.update_in(cx, move |picker, window, cx| {
1778 picker.delegate.set_workspaces(workspaces);
1779 picker
1780 .delegate
1781 .set_selected_index(ix.saturating_sub(1), window, cx);
1782 picker.delegate.reset_selected_match_index = false;
1783 picker.update_matches(picker.query(cx), window, cx);
1784 // After deleting a project, we want to update the history manager to reflect the change.
1785 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1786 if let Some(history_manager) = HistoryManager::global(cx) {
1787 history_manager
1788 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1789 }
1790 })
1791 .ok();
1792 })
1793 .detach();
1794 }
1795 }
1796
1797 fn remove_sibling_workspace(
1798 &mut self,
1799 workspace_id: WorkspaceId,
1800 window: &mut Window,
1801 cx: &mut Context<Picker<Self>>,
1802 ) {
1803 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1804 cx.defer(move |cx| {
1805 handle
1806 .update(cx, |multi_workspace, window, cx| {
1807 let index = multi_workspace
1808 .workspaces()
1809 .iter()
1810 .position(|ws| ws.read(cx).database_id() == Some(workspace_id));
1811 if let Some(index) = index {
1812 multi_workspace.remove_workspace(index, window, cx);
1813 }
1814 })
1815 .log_err();
1816 });
1817 }
1818
1819 self.sibling_workspace_ids.remove(&workspace_id);
1820 }
1821
1822 fn is_current_workspace(
1823 &self,
1824 workspace_id: WorkspaceId,
1825 cx: &mut Context<Picker<Self>>,
1826 ) -> bool {
1827 if let Some(workspace) = self.workspace.upgrade() {
1828 let workspace = workspace.read(cx);
1829 if Some(workspace_id) == workspace.database_id() {
1830 return true;
1831 }
1832 }
1833
1834 false
1835 }
1836
1837 fn is_sibling_workspace(
1838 &self,
1839 workspace_id: WorkspaceId,
1840 cx: &mut Context<Picker<Self>>,
1841 ) -> bool {
1842 self.sibling_workspace_ids.contains(&workspace_id)
1843 && !self.is_current_workspace(workspace_id, cx)
1844 }
1845
1846 fn is_open_folder(&self, paths: &PathList) -> bool {
1847 if self.open_folders.is_empty() {
1848 return false;
1849 }
1850
1851 for workspace_path in paths.paths() {
1852 for open_folder in &self.open_folders {
1853 if workspace_path == &open_folder.path {
1854 return true;
1855 }
1856 }
1857 }
1858
1859 false
1860 }
1861
1862 fn is_valid_recent_candidate(
1863 &self,
1864 workspace_id: WorkspaceId,
1865 paths: &PathList,
1866 cx: &mut Context<Picker<Self>>,
1867 ) -> bool {
1868 !self.is_current_workspace(workspace_id, cx)
1869 && !self.is_sibling_workspace(workspace_id, cx)
1870 && !self.is_open_folder(paths)
1871 }
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876 use std::path::PathBuf;
1877
1878 use editor::Editor;
1879 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1880
1881 use serde_json::json;
1882 use settings::SettingsStore;
1883 use util::path;
1884 use workspace::{AppState, open_paths};
1885
1886 use super::*;
1887
1888 #[gpui::test]
1889 async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1890 let app_state = init_test(cx);
1891
1892 cx.update(|cx| {
1893 SettingsStore::update_global(cx, |store, cx| {
1894 store.update_user_settings(cx, |settings| {
1895 settings
1896 .session
1897 .get_or_insert_default()
1898 .restore_unsaved_buffers = Some(false)
1899 });
1900 });
1901 });
1902
1903 app_state
1904 .fs
1905 .as_fake()
1906 .insert_tree(
1907 path!("/dir"),
1908 json!({
1909 "main.ts": "a"
1910 }),
1911 )
1912 .await;
1913 app_state
1914 .fs
1915 .as_fake()
1916 .insert_tree(path!("/test/path"), json!({}))
1917 .await;
1918 cx.update(|cx| {
1919 open_paths(
1920 &[PathBuf::from(path!("/dir/main.ts"))],
1921 app_state,
1922 workspace::OpenOptions::default(),
1923 cx,
1924 )
1925 })
1926 .await
1927 .unwrap();
1928 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1929
1930 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1931 multi_workspace
1932 .update(cx, |multi_workspace, _, cx| {
1933 assert!(!multi_workspace.workspace().read(cx).is_edited())
1934 })
1935 .unwrap();
1936
1937 let editor = multi_workspace
1938 .read_with(cx, |multi_workspace, cx| {
1939 multi_workspace
1940 .workspace()
1941 .read(cx)
1942 .active_item(cx)
1943 .unwrap()
1944 .downcast::<Editor>()
1945 .unwrap()
1946 })
1947 .unwrap();
1948 multi_workspace
1949 .update(cx, |_, window, cx| {
1950 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1951 })
1952 .unwrap();
1953 multi_workspace
1954 .update(cx, |multi_workspace, _, cx| {
1955 assert!(
1956 multi_workspace.workspace().read(cx).is_edited(),
1957 "After inserting more text into the editor without saving, we should have a dirty project"
1958 )
1959 })
1960 .unwrap();
1961
1962 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1963 multi_workspace
1964 .update(cx, |_, _, cx| {
1965 recent_projects_picker.update(cx, |picker, cx| {
1966 assert_eq!(picker.query(cx), "");
1967 let delegate = &mut picker.delegate;
1968 delegate.set_workspaces(vec![(
1969 WorkspaceId::default(),
1970 SerializedWorkspaceLocation::Local,
1971 PathList::new(&[path!("/test/path")]),
1972 Utc::now(),
1973 )]);
1974 delegate.filtered_entries =
1975 vec![ProjectPickerEntry::RecentProject(StringMatch {
1976 candidate_id: 0,
1977 score: 1.0,
1978 positions: Vec::new(),
1979 string: "fake candidate".to_string(),
1980 })];
1981 });
1982 })
1983 .unwrap();
1984
1985 assert!(
1986 !cx.has_pending_prompt(),
1987 "Should have no pending prompt on dirty project before opening the new recent project"
1988 );
1989 let dirty_workspace = multi_workspace
1990 .read_with(cx, |multi_workspace, _cx| {
1991 multi_workspace.workspace().clone()
1992 })
1993 .unwrap();
1994
1995 cx.dispatch_action(*multi_workspace, menu::Confirm);
1996 cx.run_until_parked();
1997
1998 multi_workspace
1999 .update(cx, |multi_workspace, _, cx| {
2000 assert!(
2001 multi_workspace
2002 .workspace()
2003 .read(cx)
2004 .active_modal::<RecentProjects>(cx)
2005 .is_none(),
2006 "Should remove the modal after selecting new recent project"
2007 );
2008
2009 assert!(
2010 multi_workspace.workspaces().len() >= 2,
2011 "Should have at least 2 workspaces: the dirty one and the newly opened one"
2012 );
2013
2014 assert!(
2015 multi_workspace.workspaces().contains(&dirty_workspace),
2016 "The original dirty workspace should still be present"
2017 );
2018
2019 assert!(
2020 dirty_workspace.read(cx).is_edited(),
2021 "The original workspace should still be dirty"
2022 );
2023 })
2024 .unwrap();
2025
2026 assert!(
2027 !cx.has_pending_prompt(),
2028 "No save prompt in multi-workspace mode — dirty workspace survives in background"
2029 );
2030 }
2031
2032 fn open_recent_projects(
2033 multi_workspace: &WindowHandle<MultiWorkspace>,
2034 cx: &mut TestAppContext,
2035 ) -> Entity<Picker<RecentProjectsDelegate>> {
2036 cx.dispatch_action(
2037 (*multi_workspace).into(),
2038 OpenRecent {
2039 create_new_window: false,
2040 },
2041 );
2042 multi_workspace
2043 .update(cx, |multi_workspace, _, cx| {
2044 multi_workspace
2045 .workspace()
2046 .read(cx)
2047 .active_modal::<RecentProjects>(cx)
2048 .unwrap()
2049 .read(cx)
2050 .picker
2051 .clone()
2052 })
2053 .unwrap()
2054 }
2055
2056 #[gpui::test]
2057 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2058 let app_state = init_test(cx);
2059
2060 app_state
2061 .fs
2062 .as_fake()
2063 .insert_tree(
2064 path!("/project"),
2065 json!({
2066 ".devcontainer": {
2067 "devcontainer.json": "{}"
2068 },
2069 "src": {
2070 "main.rs": "fn main() {}"
2071 }
2072 }),
2073 )
2074 .await;
2075
2076 cx.update(|cx| {
2077 open_paths(
2078 &[PathBuf::from(path!("/project"))],
2079 app_state,
2080 workspace::OpenOptions::default(),
2081 cx,
2082 )
2083 })
2084 .await
2085 .unwrap();
2086
2087 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2088 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2089
2090 cx.run_until_parked();
2091
2092 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2093 // -> Workspace::update -> toggle_modal -> new_dev_container.
2094 // Before the fix, this panicked with "cannot read workspace::Workspace while
2095 // it is already being updated" because new_dev_container and open_dev_container
2096 // tried to read the Workspace entity through a WeakEntity handle while it was
2097 // already leased by the outer update.
2098 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2099
2100 multi_workspace
2101 .update(cx, |multi_workspace, _, cx| {
2102 let modal = multi_workspace
2103 .workspace()
2104 .read(cx)
2105 .active_modal::<RemoteServerProjects>(cx);
2106 assert!(
2107 modal.is_some(),
2108 "Dev container modal should be open after dispatching OpenDevContainer"
2109 );
2110 })
2111 .unwrap();
2112 }
2113
2114 #[gpui::test]
2115 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2116 let app_state = init_test(cx);
2117
2118 app_state
2119 .fs
2120 .as_fake()
2121 .insert_tree(
2122 path!("/project"),
2123 json!({
2124 ".devcontainer": {
2125 "rust": {
2126 "devcontainer.json": "{}"
2127 },
2128 "python": {
2129 "devcontainer.json": "{}"
2130 }
2131 },
2132 "src": {
2133 "main.rs": "fn main() {}"
2134 }
2135 }),
2136 )
2137 .await;
2138
2139 cx.update(|cx| {
2140 open_paths(
2141 &[PathBuf::from(path!("/project"))],
2142 app_state,
2143 workspace::OpenOptions::default(),
2144 cx,
2145 )
2146 })
2147 .await
2148 .unwrap();
2149
2150 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2151 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2152
2153 cx.run_until_parked();
2154
2155 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2156
2157 multi_workspace
2158 .update(cx, |multi_workspace, _, cx| {
2159 let modal = multi_workspace
2160 .workspace()
2161 .read(cx)
2162 .active_modal::<RemoteServerProjects>(cx);
2163 assert!(
2164 modal.is_some(),
2165 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2166 );
2167 })
2168 .unwrap();
2169 }
2170
2171 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2172 cx.update(|cx| {
2173 let state = AppState::test(cx);
2174 crate::init(cx);
2175 editor::init(cx);
2176 state
2177 })
2178 }
2179}