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