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 .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, window, 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(
1083 paths,
1084 OpenMode::Replace,
1085 window,
1086 cx,
1087 )
1088 })
1089 .log_err()
1090 {
1091 task.detach_and_log_err(cx);
1092 }
1093 });
1094 }
1095 return;
1096 } else {
1097 workspace
1098 .open_workspace_for_paths(
1099 OpenMode::NewWindow,
1100 paths,
1101 window,
1102 cx,
1103 )
1104 .detach_and_prompt_err(
1105 "Failed to open project",
1106 window,
1107 cx,
1108 |_, _, _| None,
1109 );
1110 }
1111 }
1112 SerializedWorkspaceLocation::Remote(mut connection) => {
1113 let app_state = workspace.app_state().clone();
1114 let replace_window = if replace_current_window {
1115 window.window_handle().downcast::<MultiWorkspace>()
1116 } else {
1117 None
1118 };
1119 let open_options = OpenOptions {
1120 requesting_window: replace_window,
1121 ..Default::default()
1122 };
1123 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1124 RemoteSettings::get_global(cx)
1125 .fill_connection_options_from_settings(connection);
1126 };
1127 let paths = candidate_workspace_paths.paths().to_vec();
1128 cx.spawn_in(window, async move |_, cx| {
1129 open_remote_project(
1130 connection.clone(),
1131 paths,
1132 app_state,
1133 open_options,
1134 cx,
1135 )
1136 .await
1137 })
1138 .detach_and_prompt_err(
1139 "Failed to open project",
1140 window,
1141 cx,
1142 |_, _, _| None,
1143 );
1144 }
1145 }
1146 });
1147 cx.emit(DismissEvent);
1148 }
1149 _ => {}
1150 }
1151 }
1152
1153 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1154
1155 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1156 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1157 "Recently opened projects will show up here".into()
1158 } else {
1159 "No matches".into()
1160 };
1161 Some(text)
1162 }
1163
1164 fn render_match(
1165 &self,
1166 ix: usize,
1167 selected: bool,
1168 window: &mut Window,
1169 cx: &mut Context<Picker<Self>>,
1170 ) -> Option<Self::ListItem> {
1171 match self.filtered_entries.get(ix)? {
1172 ProjectPickerEntry::Header(title) => Some(
1173 v_flex()
1174 .w_full()
1175 .gap_1()
1176 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1177 .child(ListSubHeader::new(title.clone()).inset(true))
1178 .into_any_element(),
1179 ),
1180 ProjectPickerEntry::OpenFolder { index, positions } => {
1181 let folder = self.open_folders.get(*index)?;
1182 let name = folder.name.clone();
1183 let path = folder.path.compact();
1184 let branch = folder.branch.clone();
1185 let is_active = folder.is_active;
1186 let worktree_id = folder.worktree_id;
1187 let positions = positions.clone();
1188 let show_path = self.style == ProjectPickerStyle::Modal;
1189
1190 let secondary_actions = h_flex()
1191 .gap_1()
1192 .child(
1193 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1194 .icon_size(IconSize::Small)
1195 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1196 .on_click(cx.listener(move |picker, _, window, cx| {
1197 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1198 return;
1199 };
1200 workspace.update(cx, |workspace, cx| {
1201 let project = workspace.project().clone();
1202 project.update(cx, |project, cx| {
1203 project.remove_worktree(worktree_id, cx);
1204 });
1205 });
1206 picker.delegate.open_folders =
1207 get_open_folders(workspace.read(cx), cx);
1208 let query = picker.query(cx);
1209 picker.update_matches(query, window, cx);
1210 })),
1211 )
1212 .into_any_element();
1213
1214 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1215
1216 Some(
1217 ListItem::new(ix)
1218 .toggle_state(selected)
1219 .inset(true)
1220 .spacing(ListItemSpacing::Sparse)
1221 .child(
1222 h_flex()
1223 .id("open_folder_item")
1224 .gap_3()
1225 .flex_grow()
1226 .when(self.has_any_non_local_projects, |this| {
1227 this.child(Icon::new(icon).color(Color::Muted))
1228 })
1229 .child(
1230 v_flex()
1231 .child(
1232 h_flex()
1233 .gap_1()
1234 .child({
1235 let highlighted = HighlightedMatch {
1236 text: name.to_string(),
1237 highlight_positions: positions,
1238 color: Color::Default,
1239 };
1240 highlighted.render(window, cx)
1241 })
1242 .when_some(branch, |this, branch| {
1243 this.child(
1244 Label::new(branch).color(Color::Muted),
1245 )
1246 })
1247 .when(is_active, |this| {
1248 this.child(
1249 Icon::new(IconName::Check)
1250 .size(IconSize::Small)
1251 .color(Color::Accent),
1252 )
1253 }),
1254 )
1255 .when(show_path, |this| {
1256 this.child(
1257 Label::new(path.to_string_lossy().to_string())
1258 .size(LabelSize::Small)
1259 .color(Color::Muted),
1260 )
1261 }),
1262 )
1263 .when(!show_path, |this| {
1264 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1265 }),
1266 )
1267 .end_slot(secondary_actions)
1268 .show_end_slot_on_hover()
1269 .into_any_element(),
1270 )
1271 }
1272 ProjectPickerEntry::OpenProject(hit) => {
1273 let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1274 let workspace_id = *workspace_id;
1275 let ordered_paths: Vec<_> = paths
1276 .ordered_paths()
1277 .map(|p| p.compact().to_string_lossy().to_string())
1278 .collect();
1279 let tooltip_path: SharedString = match &location {
1280 SerializedWorkspaceLocation::Remote(options) => {
1281 let host = options.display_name();
1282 if ordered_paths.len() == 1 {
1283 format!("{} ({})", ordered_paths[0], host).into()
1284 } else {
1285 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1286 }
1287 }
1288 _ => ordered_paths.join("\n").into(),
1289 };
1290
1291 let mut path_start_offset = 0;
1292 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1293 .ordered_paths()
1294 .map(|p| p.compact())
1295 .map(|path| {
1296 let highlighted_text =
1297 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1298 path_start_offset += highlighted_text.1.text.len();
1299 highlighted_text
1300 })
1301 .unzip();
1302
1303 let prefix = match &location {
1304 SerializedWorkspaceLocation::Remote(options) => {
1305 Some(SharedString::from(options.display_name()))
1306 }
1307 _ => None,
1308 };
1309
1310 let highlighted_match = HighlightedMatchWithPaths {
1311 prefix,
1312 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1313 paths,
1314 };
1315
1316 let icon = icon_for_remote_connection(match location {
1317 SerializedWorkspaceLocation::Local => None,
1318 SerializedWorkspaceLocation::Remote(options) => Some(options),
1319 });
1320
1321 let secondary_actions = h_flex()
1322 .gap_1()
1323 .child(
1324 IconButton::new("remove_open_project", IconName::Close)
1325 .icon_size(IconSize::Small)
1326 .tooltip(Tooltip::text("Remove Project from Window"))
1327 .on_click(cx.listener(move |picker, _, window, cx| {
1328 cx.stop_propagation();
1329 window.prevent_default();
1330 picker
1331 .delegate
1332 .remove_sibling_workspace(workspace_id, window, cx);
1333 let query = picker.query(cx);
1334 picker.update_matches(query, window, cx);
1335 })),
1336 )
1337 .into_any_element();
1338
1339 Some(
1340 ListItem::new(ix)
1341 .toggle_state(selected)
1342 .inset(true)
1343 .spacing(ListItemSpacing::Sparse)
1344 .child(
1345 h_flex()
1346 .id("open_project_info_container")
1347 .gap_3()
1348 .flex_grow()
1349 .when(self.has_any_non_local_projects, |this| {
1350 this.child(Icon::new(icon).color(Color::Muted))
1351 })
1352 .child({
1353 let mut highlighted = highlighted_match;
1354 if !self.render_paths {
1355 highlighted.paths.clear();
1356 }
1357 highlighted.render(window, cx)
1358 })
1359 .tooltip(Tooltip::text(tooltip_path)),
1360 )
1361 .end_slot(secondary_actions)
1362 .show_end_slot_on_hover()
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 .end_slot(secondary_actions)
1497 .show_end_slot_on_hover()
1498 .into_any_element(),
1499 )
1500 }
1501 }
1502 }
1503
1504 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1505 let focus_handle = self.focus_handle.clone();
1506 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1507 let is_already_open_entry = matches!(
1508 self.filtered_entries.get(self.selected_index),
1509 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
1510 );
1511
1512 if popover_style {
1513 return Some(
1514 v_flex()
1515 .flex_1()
1516 .p_1p5()
1517 .gap_1()
1518 .border_t_1()
1519 .border_color(cx.theme().colors().border_variant)
1520 .child({
1521 let open_action = workspace::Open {
1522 create_new_window: self.create_new_window,
1523 };
1524 Button::new("open_local_folder", "Open Local Project")
1525 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1526 .on_click(move |_, window, cx| {
1527 window.dispatch_action(open_action.boxed_clone(), cx)
1528 })
1529 })
1530 .child(
1531 Button::new("open_remote_folder", "Open Remote Project")
1532 .key_binding(KeyBinding::for_action(
1533 &OpenRemote {
1534 from_existing_connection: false,
1535 create_new_window: false,
1536 },
1537 cx,
1538 ))
1539 .on_click(|_, window, cx| {
1540 window.dispatch_action(
1541 OpenRemote {
1542 from_existing_connection: false,
1543 create_new_window: false,
1544 }
1545 .boxed_clone(),
1546 cx,
1547 )
1548 }),
1549 )
1550 .into_any(),
1551 );
1552 }
1553
1554 Some(
1555 h_flex()
1556 .flex_1()
1557 .p_1p5()
1558 .gap_1()
1559 .justify_end()
1560 .border_t_1()
1561 .border_color(cx.theme().colors().border_variant)
1562 .map(|this| {
1563 if is_already_open_entry {
1564 this.child(
1565 Button::new("activate", "Activate")
1566 .key_binding(KeyBinding::for_action_in(
1567 &menu::Confirm,
1568 &focus_handle,
1569 cx,
1570 ))
1571 .on_click(|_, window, cx| {
1572 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1573 }),
1574 )
1575 } else {
1576 this.child(
1577 Button::new("open_new_window", "New Window")
1578 .key_binding(KeyBinding::for_action_in(
1579 &menu::SecondaryConfirm,
1580 &focus_handle,
1581 cx,
1582 ))
1583 .on_click(|_, window, cx| {
1584 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1585 }),
1586 )
1587 .child(
1588 Button::new("open_here", "Open")
1589 .key_binding(KeyBinding::for_action_in(
1590 &menu::Confirm,
1591 &focus_handle,
1592 cx,
1593 ))
1594 .on_click(|_, window, cx| {
1595 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1596 }),
1597 )
1598 }
1599 })
1600 .child(Divider::vertical())
1601 .child(
1602 PopoverMenu::new("actions-menu-popover")
1603 .with_handle(self.actions_menu_handle.clone())
1604 .anchor(gpui::Corner::BottomRight)
1605 .offset(gpui::Point {
1606 x: px(0.0),
1607 y: px(-2.0),
1608 })
1609 .trigger(
1610 Button::new("actions-trigger", "Actions…")
1611 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1612 .key_binding(KeyBinding::for_action_in(
1613 &ToggleActionsMenu,
1614 &focus_handle,
1615 cx,
1616 )),
1617 )
1618 .menu({
1619 let focus_handle = focus_handle.clone();
1620 let create_new_window = self.create_new_window;
1621
1622 move |window, cx| {
1623 Some(ContextMenu::build(window, cx, {
1624 let focus_handle = focus_handle.clone();
1625 move |menu, _, _| {
1626 menu.context(focus_handle)
1627 .action(
1628 "Open Local Project",
1629 workspace::Open { create_new_window }.boxed_clone(),
1630 )
1631 .action(
1632 "Open Remote Project",
1633 OpenRemote {
1634 from_existing_connection: false,
1635 create_new_window: false,
1636 }
1637 .boxed_clone(),
1638 )
1639 }
1640 }))
1641 }
1642 }),
1643 )
1644 .into_any(),
1645 )
1646 }
1647}
1648
1649pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1650 match options {
1651 None => IconName::Screen,
1652 Some(options) => match options {
1653 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1654 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1655 RemoteConnectionOptions::Docker(_) => IconName::Box,
1656 #[cfg(any(test, feature = "test-support"))]
1657 RemoteConnectionOptions::Mock(_) => IconName::Server,
1658 },
1659 }
1660}
1661
1662// Compute the highlighted text for the name and path
1663pub(crate) fn highlights_for_path(
1664 path: &Path,
1665 match_positions: &Vec<usize>,
1666 path_start_offset: usize,
1667) -> (Option<HighlightedMatch>, HighlightedMatch) {
1668 let path_string = path.to_string_lossy();
1669 let path_text = path_string.to_string();
1670 let path_byte_len = path_text.len();
1671 // Get the subset of match highlight positions that line up with the given path.
1672 // Also adjusts them to start at the path start
1673 let path_positions = match_positions
1674 .iter()
1675 .copied()
1676 .skip_while(|position| *position < path_start_offset)
1677 .take_while(|position| *position < path_start_offset + path_byte_len)
1678 .map(|position| position - path_start_offset)
1679 .collect::<Vec<_>>();
1680
1681 // Again subset the highlight positions to just those that line up with the file_name
1682 // again adjusted to the start of the file_name
1683 let file_name_text_and_positions = path.file_name().map(|file_name| {
1684 let file_name_text = file_name.to_string_lossy().into_owned();
1685 let file_name_start_byte = path_byte_len - file_name_text.len();
1686 let highlight_positions = path_positions
1687 .iter()
1688 .copied()
1689 .skip_while(|position| *position < file_name_start_byte)
1690 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1691 .map(|position| position - file_name_start_byte)
1692 .collect::<Vec<_>>();
1693 HighlightedMatch {
1694 text: file_name_text,
1695 highlight_positions,
1696 color: Color::Default,
1697 }
1698 });
1699
1700 (
1701 file_name_text_and_positions,
1702 HighlightedMatch {
1703 text: path_text,
1704 highlight_positions: path_positions,
1705 color: Color::Default,
1706 },
1707 )
1708}
1709impl RecentProjectsDelegate {
1710 fn add_project_to_workspace(
1711 &mut self,
1712 paths: Vec<PathBuf>,
1713 window: &mut Window,
1714 cx: &mut Context<Picker<Self>>,
1715 ) {
1716 let Some(workspace) = self.workspace.upgrade() else {
1717 return;
1718 };
1719 let open_paths_task = workspace.update(cx, |workspace, cx| {
1720 workspace.open_paths(
1721 paths,
1722 OpenOptions {
1723 visible: Some(OpenVisible::All),
1724 ..Default::default()
1725 },
1726 None,
1727 window,
1728 cx,
1729 )
1730 });
1731 cx.spawn_in(window, async move |picker, cx| {
1732 let _result = open_paths_task.await;
1733 picker
1734 .update_in(cx, |picker, window, cx| {
1735 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1736 return;
1737 };
1738 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1739 let query = picker.query(cx);
1740 picker.update_matches(query, window, cx);
1741 })
1742 .ok();
1743 })
1744 .detach();
1745 }
1746
1747 fn delete_recent_project(
1748 &self,
1749 ix: usize,
1750 window: &mut Window,
1751 cx: &mut Context<Picker<Self>>,
1752 ) {
1753 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1754 self.filtered_entries.get(ix)
1755 {
1756 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1757 let workspace_id = *workspace_id;
1758 let fs = self
1759 .workspace
1760 .upgrade()
1761 .map(|ws| ws.read(cx).app_state().fs.clone());
1762 let db = WorkspaceDb::global(cx);
1763 cx.spawn_in(window, async move |this, cx| {
1764 db.delete_workspace_by_id(workspace_id).await.log_err();
1765 let Some(fs) = fs else { return };
1766 let workspaces = db
1767 .recent_workspaces_on_disk(fs.as_ref())
1768 .await
1769 .unwrap_or_default();
1770 let workspaces =
1771 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1772 this.update_in(cx, move |picker, window, cx| {
1773 picker.delegate.set_workspaces(workspaces);
1774 picker
1775 .delegate
1776 .set_selected_index(ix.saturating_sub(1), window, cx);
1777 picker.delegate.reset_selected_match_index = false;
1778 picker.update_matches(picker.query(cx), window, cx);
1779 // After deleting a project, we want to update the history manager to reflect the change.
1780 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1781 if let Some(history_manager) = HistoryManager::global(cx) {
1782 history_manager
1783 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1784 }
1785 })
1786 .ok();
1787 })
1788 .detach();
1789 }
1790 }
1791
1792 fn remove_sibling_workspace(
1793 &mut self,
1794 workspace_id: WorkspaceId,
1795 window: &mut Window,
1796 cx: &mut Context<Picker<Self>>,
1797 ) {
1798 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1799 cx.defer(move |cx| {
1800 handle
1801 .update(cx, |multi_workspace, window, cx| {
1802 let workspace = multi_workspace
1803 .workspaces()
1804 .iter()
1805 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1806 .cloned();
1807 if let Some(workspace) = workspace {
1808 multi_workspace.remove(&workspace, window, cx);
1809 }
1810 })
1811 .log_err();
1812 });
1813 }
1814
1815 self.sibling_workspace_ids.remove(&workspace_id);
1816 }
1817
1818 fn is_current_workspace(
1819 &self,
1820 workspace_id: WorkspaceId,
1821 cx: &mut Context<Picker<Self>>,
1822 ) -> bool {
1823 if let Some(workspace) = self.workspace.upgrade() {
1824 let workspace = workspace.read(cx);
1825 if Some(workspace_id) == workspace.database_id() {
1826 return true;
1827 }
1828 }
1829
1830 false
1831 }
1832
1833 fn is_sibling_workspace(
1834 &self,
1835 workspace_id: WorkspaceId,
1836 cx: &mut Context<Picker<Self>>,
1837 ) -> bool {
1838 self.sibling_workspace_ids.contains(&workspace_id)
1839 && !self.is_current_workspace(workspace_id, cx)
1840 }
1841
1842 fn is_open_folder(&self, paths: &PathList) -> bool {
1843 if self.open_folders.is_empty() {
1844 return false;
1845 }
1846
1847 for workspace_path in paths.paths() {
1848 for open_folder in &self.open_folders {
1849 if workspace_path == &open_folder.path {
1850 return true;
1851 }
1852 }
1853 }
1854
1855 false
1856 }
1857
1858 fn is_valid_recent_candidate(
1859 &self,
1860 workspace_id: WorkspaceId,
1861 paths: &PathList,
1862 cx: &mut Context<Picker<Self>>,
1863 ) -> bool {
1864 !self.is_current_workspace(workspace_id, cx)
1865 && !self.is_sibling_workspace(workspace_id, cx)
1866 && !self.is_open_folder(paths)
1867 }
1868}
1869
1870#[cfg(test)]
1871mod tests {
1872 use std::path::PathBuf;
1873
1874 use editor::Editor;
1875 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1876
1877 use serde_json::json;
1878 use settings::SettingsStore;
1879 use util::path;
1880 use workspace::{AppState, open_paths};
1881
1882 use super::*;
1883
1884 #[gpui::test]
1885 async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
1886 let app_state = init_test(cx);
1887
1888 cx.update(|cx| {
1889 SettingsStore::update_global(cx, |store, cx| {
1890 store.update_user_settings(cx, |settings| {
1891 settings
1892 .session
1893 .get_or_insert_default()
1894 .restore_unsaved_buffers = Some(false)
1895 });
1896 });
1897 });
1898
1899 app_state
1900 .fs
1901 .as_fake()
1902 .insert_tree(
1903 path!("/dir"),
1904 json!({
1905 "main.ts": "a"
1906 }),
1907 )
1908 .await;
1909 app_state
1910 .fs
1911 .as_fake()
1912 .insert_tree(path!("/test/path"), json!({}))
1913 .await;
1914 cx.update(|cx| {
1915 open_paths(
1916 &[PathBuf::from(path!("/dir/main.ts"))],
1917 app_state,
1918 workspace::OpenOptions::default(),
1919 cx,
1920 )
1921 })
1922 .await
1923 .unwrap();
1924 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1925
1926 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1927 multi_workspace
1928 .update(cx, |multi_workspace, _, cx| {
1929 assert!(!multi_workspace.workspace().read(cx).is_edited())
1930 })
1931 .unwrap();
1932
1933 let editor = multi_workspace
1934 .read_with(cx, |multi_workspace, cx| {
1935 multi_workspace
1936 .workspace()
1937 .read(cx)
1938 .active_item(cx)
1939 .unwrap()
1940 .downcast::<Editor>()
1941 .unwrap()
1942 })
1943 .unwrap();
1944 multi_workspace
1945 .update(cx, |_, window, cx| {
1946 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1947 })
1948 .unwrap();
1949 multi_workspace
1950 .update(cx, |multi_workspace, _, cx| {
1951 assert!(
1952 multi_workspace.workspace().read(cx).is_edited(),
1953 "After inserting more text into the editor without saving, we should have a dirty project"
1954 )
1955 })
1956 .unwrap();
1957
1958 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1959 multi_workspace
1960 .update(cx, |_, _, cx| {
1961 recent_projects_picker.update(cx, |picker, cx| {
1962 assert_eq!(picker.query(cx), "");
1963 let delegate = &mut picker.delegate;
1964 delegate.set_workspaces(vec![(
1965 WorkspaceId::default(),
1966 SerializedWorkspaceLocation::Local,
1967 PathList::new(&[path!("/test/path")]),
1968 Utc::now(),
1969 )]);
1970 delegate.filtered_entries =
1971 vec![ProjectPickerEntry::RecentProject(StringMatch {
1972 candidate_id: 0,
1973 score: 1.0,
1974 positions: Vec::new(),
1975 string: "fake candidate".to_string(),
1976 })];
1977 });
1978 })
1979 .unwrap();
1980
1981 assert!(
1982 !cx.has_pending_prompt(),
1983 "Should have no pending prompt on dirty project before opening the new recent project"
1984 );
1985 let dirty_workspace = multi_workspace
1986 .read_with(cx, |multi_workspace, _cx| {
1987 multi_workspace.workspace().clone()
1988 })
1989 .unwrap();
1990
1991 cx.dispatch_action(*multi_workspace, menu::Confirm);
1992 cx.run_until_parked();
1993
1994 // prepare_to_close triggers a save prompt for the dirty buffer.
1995 // Choose "Don't Save" (index 2) to discard and continue replacing.
1996 assert!(
1997 cx.has_pending_prompt(),
1998 "Should prompt to save dirty buffer before replacing workspace"
1999 );
2000 cx.simulate_prompt_answer("Don't Save");
2001 cx.run_until_parked();
2002
2003 multi_workspace
2004 .update(cx, |multi_workspace, _, cx| {
2005 assert!(
2006 multi_workspace
2007 .workspace()
2008 .read(cx)
2009 .active_modal::<RecentProjects>(cx)
2010 .is_none(),
2011 "Should remove the modal after selecting new recent project"
2012 );
2013
2014 assert!(
2015 !multi_workspace.workspaces().contains(&dirty_workspace),
2016 "The original dirty workspace should have been replaced"
2017 );
2018
2019 assert!(
2020 !multi_workspace.workspace().read(cx).is_edited(),
2021 "The active workspace should be the freshly opened one, not dirty"
2022 );
2023 })
2024 .unwrap();
2025 }
2026
2027 fn open_recent_projects(
2028 multi_workspace: &WindowHandle<MultiWorkspace>,
2029 cx: &mut TestAppContext,
2030 ) -> Entity<Picker<RecentProjectsDelegate>> {
2031 cx.dispatch_action(
2032 (*multi_workspace).into(),
2033 OpenRecent {
2034 create_new_window: false,
2035 },
2036 );
2037 multi_workspace
2038 .update(cx, |multi_workspace, _, cx| {
2039 multi_workspace
2040 .workspace()
2041 .read(cx)
2042 .active_modal::<RecentProjects>(cx)
2043 .unwrap()
2044 .read(cx)
2045 .picker
2046 .clone()
2047 })
2048 .unwrap()
2049 }
2050
2051 #[gpui::test]
2052 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2053 let app_state = init_test(cx);
2054
2055 app_state
2056 .fs
2057 .as_fake()
2058 .insert_tree(
2059 path!("/project"),
2060 json!({
2061 ".devcontainer": {
2062 "devcontainer.json": "{}"
2063 },
2064 "src": {
2065 "main.rs": "fn main() {}"
2066 }
2067 }),
2068 )
2069 .await;
2070
2071 // Open a file path (not a directory) so that the worktree root is a
2072 // file. This means `active_project_directory` returns `None`, which
2073 // causes `DevContainerContext::from_workspace` to return `None`,
2074 // preventing `open_dev_container` from spawning real I/O (docker
2075 // commands, shell environment loading) that is incompatible with the
2076 // test scheduler. The modal is still created and the re-entrancy
2077 // guard that this test validates is still exercised.
2078 cx.update(|cx| {
2079 open_paths(
2080 &[PathBuf::from(path!("/project/src/main.rs"))],
2081 app_state,
2082 workspace::OpenOptions::default(),
2083 cx,
2084 )
2085 })
2086 .await
2087 .unwrap();
2088
2089 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2090 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2091
2092 cx.run_until_parked();
2093
2094 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2095 // -> Workspace::update -> toggle_modal -> new_dev_container.
2096 // Before the fix, this panicked with "cannot read workspace::Workspace while
2097 // it is already being updated" because new_dev_container and open_dev_container
2098 // tried to read the Workspace entity through a WeakEntity handle while it was
2099 // already leased by the outer update.
2100 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2101
2102 multi_workspace
2103 .update(cx, |multi_workspace, _, cx| {
2104 let modal = multi_workspace
2105 .workspace()
2106 .read(cx)
2107 .active_modal::<RemoteServerProjects>(cx);
2108 assert!(
2109 modal.is_some(),
2110 "Dev container modal should be open after dispatching OpenDevContainer"
2111 );
2112 })
2113 .unwrap();
2114 }
2115
2116 #[gpui::test]
2117 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2118 let app_state = init_test(cx);
2119
2120 app_state
2121 .fs
2122 .as_fake()
2123 .insert_tree(
2124 path!("/project"),
2125 json!({
2126 ".devcontainer": {
2127 "rust": {
2128 "devcontainer.json": "{}"
2129 },
2130 "python": {
2131 "devcontainer.json": "{}"
2132 }
2133 },
2134 "src": {
2135 "main.rs": "fn main() {}"
2136 }
2137 }),
2138 )
2139 .await;
2140
2141 cx.update(|cx| {
2142 open_paths(
2143 &[PathBuf::from(path!("/project"))],
2144 app_state,
2145 workspace::OpenOptions::default(),
2146 cx,
2147 )
2148 })
2149 .await
2150 .unwrap();
2151
2152 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2153 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2154
2155 cx.run_until_parked();
2156
2157 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2158
2159 multi_workspace
2160 .update(cx, |multi_workspace, _, cx| {
2161 let modal = multi_workspace
2162 .workspace()
2163 .read(cx)
2164 .active_modal::<RemoteServerProjects>(cx);
2165 assert!(
2166 modal.is_some(),
2167 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2168 );
2169 })
2170 .unwrap();
2171 }
2172
2173 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2174 cx.update(|cx| {
2175 let state = AppState::test(cx);
2176 crate::init(cx);
2177 editor::init(cx);
2178 state
2179 })
2180 }
2181}