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