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