1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5pub mod sidebar_recent_projects;
6mod ssh_config;
7
8use std::{
9 collections::HashSet,
10 path::{Path, PathBuf},
11 sync::Arc,
12};
13
14use chrono::{DateTime, Utc};
15
16use fs::Fs;
17
18#[cfg(target_os = "windows")]
19mod wsl_picker;
20
21use remote::RemoteConnectionOptions;
22pub use remote_connection::{RemoteConnectionModal, connect};
23pub use remote_connections::{navigate_to_positions, open_remote_project};
24
25use disconnected_overlay::DisconnectedOverlay;
26use fuzzy::{StringMatch, StringMatchCandidate};
27use gpui::{
28 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
29 Subscription, Task, WeakEntity, Window, actions, px,
30};
31
32use picker::{
33 Picker, PickerDelegate,
34 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
35};
36use project::{Worktree, git_store::Repository};
37pub use remote_connections::RemoteSettings;
38pub use remote_servers::RemoteServerProjects;
39use settings::{Settings, WorktreeId};
40use ui_input::ErasedEditor;
41
42use dev_container::{DevContainerContext, find_devcontainer_configs};
43use ui::{
44 ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
45 PopoverMenuHandle, TintColor, Tooltip, prelude::*,
46};
47use util::{ResultExt, paths::PathExt};
48use workspace::{
49 HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList,
50 SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
51 notifications::DetachAndPromptErr, with_active_or_new_workspace,
52};
53use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
54
55actions!(
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 OpenProject(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 all_paths: Vec<PathBuf> = filtered
110 .iter()
111 .flat_map(|(_, _, path_list, _)| path_list.paths().iter().cloned())
112 .collect();
113 let path_details =
114 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
115 project::path_suffix(path, detail)
116 });
117 let path_detail_map: std::collections::HashMap<PathBuf, usize> =
118 all_paths.into_iter().zip(path_details).collect();
119
120 let entries: Vec<RecentProjectEntry> = filtered
121 .into_iter()
122 .map(|(workspace_id, _, path_list, timestamp)| {
123 let paths: Vec<PathBuf> = path_list.paths().to_vec();
124 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
125
126 let name = ordered_paths
127 .iter()
128 .map(|p| {
129 let detail = path_detail_map.get(*p).copied().unwrap_or(0);
130 project::path_suffix(p, detail)
131 })
132 .filter(|s| !s.is_empty())
133 .collect::<Vec<_>>()
134 .join(", ");
135
136 let full_path = ordered_paths
137 .iter()
138 .map(|p| p.to_string_lossy().to_string())
139 .collect::<Vec<_>>()
140 .join("\n");
141
142 RecentProjectEntry {
143 name: SharedString::from(name),
144 full_path: SharedString::from(full_path),
145 paths,
146 workspace_id,
147 timestamp,
148 }
149 })
150 .collect();
151
152 match limit {
153 Some(n) => entries.into_iter().take(n).collect(),
154 None => entries,
155 }
156}
157
158pub async fn delete_recent_project(workspace_id: WorkspaceId, db: &WorkspaceDb) {
159 let _ = db.delete_workspace_by_id(workspace_id).await;
160}
161
162fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
163 let project = workspace.project().read(cx);
164 let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
165
166 if visible_worktrees.len() <= 1 {
167 return Vec::new();
168 }
169
170 let active_worktree_id = workspace.active_worktree_override().or_else(|| {
171 if let Some(repo) = project.active_repository(cx) {
172 let repo = repo.read(cx);
173 let repo_path = &repo.work_directory_abs_path;
174 for worktree in project.visible_worktrees(cx) {
175 let worktree_path = worktree.read(cx).abs_path();
176 if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
177 return Some(worktree.read(cx).id());
178 }
179 }
180 }
181 project
182 .visible_worktrees(cx)
183 .next()
184 .map(|wt| wt.read(cx).id())
185 });
186
187 let all_paths: Vec<PathBuf> = visible_worktrees
188 .iter()
189 .map(|wt| wt.read(cx).abs_path().to_path_buf())
190 .collect();
191 let path_details =
192 util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
193 project::path_suffix(path, detail)
194 });
195 let path_detail_map: std::collections::HashMap<PathBuf, usize> =
196 all_paths.into_iter().zip(path_details).collect();
197
198 let git_store = project.git_store().read(cx);
199 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
200
201 let mut entries: Vec<OpenFolderEntry> = visible_worktrees
202 .into_iter()
203 .map(|worktree| {
204 let worktree_ref = worktree.read(cx);
205 let worktree_id = worktree_ref.id();
206 let path = worktree_ref.abs_path().to_path_buf();
207 let detail = path_detail_map.get(&path).copied().unwrap_or(0);
208 let name = SharedString::from(project::path_suffix(&path, detail));
209 let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
210 let is_active = active_worktree_id == Some(worktree_id);
211 OpenFolderEntry {
212 worktree_id,
213 name,
214 path,
215 branch,
216 is_active,
217 }
218 })
219 .collect();
220
221 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
222 entries
223}
224
225fn get_branch_for_worktree(
226 worktree: &Worktree,
227 repositories: &[Entity<Repository>],
228 cx: &App,
229) -> Option<SharedString> {
230 let worktree_abs_path = worktree.abs_path();
231 repositories
232 .iter()
233 .filter(|repo| {
234 let repo_path = &repo.read(cx).work_directory_abs_path;
235 *repo_path == worktree_abs_path || worktree_abs_path.starts_with(repo_path.as_ref())
236 })
237 .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
238 .and_then(|repo| {
239 repo.read(cx)
240 .branch
241 .as_ref()
242 .map(|branch| SharedString::from(branch.name().to_string()))
243 })
244}
245
246pub fn init(cx: &mut App) {
247 #[cfg(target_os = "windows")]
248 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
249 let create_new_window = open_wsl.create_new_window;
250 with_active_or_new_workspace(cx, move |workspace, window, cx| {
251 use gpui::PathPromptOptions;
252 use project::DirectoryLister;
253
254 let paths = workspace.prompt_for_open_path(
255 PathPromptOptions {
256 files: true,
257 directories: true,
258 multiple: false,
259 prompt: None,
260 },
261 DirectoryLister::Local(
262 workspace.project().clone(),
263 workspace.app_state().fs.clone(),
264 ),
265 window,
266 cx,
267 );
268
269 let app_state = workspace.app_state().clone();
270 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
271
272 cx.spawn_in(window, async move |workspace, cx| {
273 use util::paths::SanitizedPath;
274
275 let Some(paths) = paths.await.log_err().flatten() else {
276 return;
277 };
278
279 let wsl_path = paths
280 .iter()
281 .find_map(util::paths::WslPath::from_path);
282
283 if let Some(util::paths::WslPath { distro, path }) = wsl_path {
284 use remote::WslConnectionOptions;
285
286 let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
287 distro_name: distro.to_string(),
288 user: None,
289 });
290
291 let requesting_window = match create_new_window {
292 false => window_handle,
293 true => None,
294 };
295
296 let open_options = workspace::OpenOptions {
297 requesting_window,
298 ..Default::default()
299 };
300
301 open_remote_project(connection_options, vec![path.into()], app_state, open_options, cx).await.log_err();
302 return;
303 }
304
305 let paths = paths
306 .into_iter()
307 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
308 .collect::<Vec<_>>();
309
310 if paths.is_empty() {
311 let message = indoc::indoc! { r#"
312 Invalid path specified when trying to open a folder inside WSL.
313
314 Please note that Zed currently does not support opening network share folders inside wsl.
315 "#};
316
317 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
318 return;
319 }
320
321 workspace.update_in(cx, |workspace, window, cx| {
322 workspace.toggle_modal(window, cx, |window, cx| {
323 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
324 });
325 }).log_err();
326 })
327 .detach();
328 });
329 });
330
331 #[cfg(target_os = "windows")]
332 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
333 let create_new_window = open_wsl.create_new_window;
334 with_active_or_new_workspace(cx, move |workspace, window, cx| {
335 let handle = cx.entity().downgrade();
336 let fs = workspace.project().read(cx).fs().clone();
337 workspace.toggle_modal(window, cx, |window, cx| {
338 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
339 });
340 });
341 });
342
343 #[cfg(target_os = "windows")]
344 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
345 let open_wsl = open_wsl.clone();
346 with_active_or_new_workspace(cx, move |workspace, window, cx| {
347 let fs = workspace.project().read(cx).fs().clone();
348 add_wsl_distro(fs, &open_wsl.distro, cx);
349 let open_options = OpenOptions {
350 requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
351 ..Default::default()
352 };
353
354 let app_state = workspace.app_state().clone();
355
356 cx.spawn_in(window, async move |_, cx| {
357 open_remote_project(
358 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
359 open_wsl.paths,
360 app_state,
361 open_options,
362 cx,
363 )
364 .await
365 })
366 .detach();
367 });
368 });
369
370 cx.on_action(|open_recent: &OpenRecent, cx| {
371 let create_new_window = open_recent.create_new_window;
372
373 match cx
374 .active_window()
375 .and_then(|w| w.downcast::<MultiWorkspace>())
376 {
377 Some(multi_workspace) => {
378 cx.defer(move |cx| {
379 multi_workspace
380 .update(cx, |multi_workspace, window, cx| {
381 let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
382 .workspaces()
383 .filter_map(|ws| ws.read(cx).database_id())
384 .collect();
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 sibling_workspace_ids,
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 HashSet::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 sibling_workspace_ids: HashSet<WorkspaceId>,
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 sibling_workspace_ids,
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 sibling_workspace_ids: HashSet<WorkspaceId>,
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 sibling_workspace_ids,
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::OpenProject(hit)) => {
742 if let Some((workspace_id, ..)) =
743 picker.delegate.workspaces.get(hit.candidate_id)
744 {
745 let workspace_id = *workspace_id;
746 picker
747 .delegate
748 .remove_sibling_workspace(workspace_id, window, cx);
749 let query = picker.query(cx);
750 picker.update_matches(query, window, cx);
751 }
752 }
753 Some(ProjectPickerEntry::RecentProject(_)) => {
754 picker.delegate.delete_recent_project(ix, window, cx);
755 }
756 _ => {}
757 }
758 });
759 }
760
761 fn handle_add_to_workspace(
762 &mut self,
763 _: &AddToWorkspace,
764 window: &mut Window,
765 cx: &mut Context<Self>,
766 ) {
767 self.picker.update(cx, |picker, cx| {
768 let ix = picker.delegate.selected_index;
769
770 if let Some(ProjectPickerEntry::RecentProject(hit)) =
771 picker.delegate.filtered_entries.get(ix)
772 {
773 if let Some((_, location, paths, _)) =
774 picker.delegate.workspaces.get(hit.candidate_id)
775 {
776 if matches!(location, SerializedWorkspaceLocation::Local) {
777 let paths_to_add = paths.paths().to_vec();
778 picker
779 .delegate
780 .add_project_to_workspace(paths_to_add, window, cx);
781 }
782 }
783 }
784 });
785 }
786}
787
788impl EventEmitter<DismissEvent> for RecentProjects {}
789
790impl Focusable for RecentProjects {
791 fn focus_handle(&self, cx: &App) -> FocusHandle {
792 self.picker.focus_handle(cx)
793 }
794}
795
796impl Render for RecentProjects {
797 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
798 v_flex()
799 .key_context("RecentProjects")
800 .on_action(cx.listener(Self::handle_toggle_open_menu))
801 .on_action(cx.listener(Self::handle_remove_selected))
802 .on_action(cx.listener(Self::handle_add_to_workspace))
803 .w(rems(self.rem_width))
804 .child(self.picker.clone())
805 }
806}
807
808pub struct RecentProjectsDelegate {
809 workspace: WeakEntity<Workspace>,
810 open_folders: Vec<OpenFolderEntry>,
811 sibling_workspace_ids: HashSet<WorkspaceId>,
812 workspaces: Vec<(
813 WorkspaceId,
814 SerializedWorkspaceLocation,
815 PathList,
816 DateTime<Utc>,
817 )>,
818 filtered_entries: Vec<ProjectPickerEntry>,
819 selected_index: usize,
820 render_paths: bool,
821 create_new_window: bool,
822 // Flag to reset index when there is a new query vs not reset index when user delete an item
823 reset_selected_match_index: bool,
824 has_any_non_local_projects: bool,
825 project_connection_options: Option<RemoteConnectionOptions>,
826 focus_handle: FocusHandle,
827 style: ProjectPickerStyle,
828 actions_menu_handle: PopoverMenuHandle<ContextMenu>,
829}
830
831impl RecentProjectsDelegate {
832 fn new(
833 workspace: WeakEntity<Workspace>,
834 create_new_window: bool,
835 focus_handle: FocusHandle,
836 open_folders: Vec<OpenFolderEntry>,
837 sibling_workspace_ids: HashSet<WorkspaceId>,
838 project_connection_options: Option<RemoteConnectionOptions>,
839 style: ProjectPickerStyle,
840 ) -> Self {
841 let render_paths = style == ProjectPickerStyle::Modal;
842 Self {
843 workspace,
844 open_folders,
845 sibling_workspace_ids,
846 workspaces: Vec::new(),
847 filtered_entries: Vec::new(),
848 selected_index: 0,
849 create_new_window,
850 render_paths,
851 reset_selected_match_index: true,
852 has_any_non_local_projects: project_connection_options.is_some(),
853 project_connection_options,
854 focus_handle,
855 style,
856 actions_menu_handle: PopoverMenuHandle::default(),
857 }
858 }
859
860 pub fn set_workspaces(
861 &mut self,
862 workspaces: Vec<(
863 WorkspaceId,
864 SerializedWorkspaceLocation,
865 PathList,
866 DateTime<Utc>,
867 )>,
868 ) {
869 self.workspaces = workspaces;
870 let has_non_local_recent = !self
871 .workspaces
872 .iter()
873 .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
874 self.has_any_non_local_projects =
875 self.project_connection_options.is_some() || has_non_local_recent;
876 }
877}
878impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
879impl PickerDelegate for RecentProjectsDelegate {
880 type ListItem = AnyElement;
881
882 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
883 "Search projects…".into()
884 }
885
886 fn render_editor(
887 &self,
888 editor: &Arc<dyn ErasedEditor>,
889 window: &mut Window,
890 cx: &mut Context<Picker<Self>>,
891 ) -> Div {
892 h_flex()
893 .flex_none()
894 .h_9()
895 .px_2p5()
896 .justify_between()
897 .border_b_1()
898 .border_color(cx.theme().colors().border_variant)
899 .child(editor.render(window, cx))
900 }
901
902 fn match_count(&self) -> usize {
903 self.filtered_entries.len()
904 }
905
906 fn selected_index(&self) -> usize {
907 self.selected_index
908 }
909
910 fn set_selected_index(
911 &mut self,
912 ix: usize,
913 _window: &mut Window,
914 _cx: &mut Context<Picker<Self>>,
915 ) {
916 self.selected_index = ix;
917 }
918
919 fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
920 matches!(
921 self.filtered_entries.get(ix),
922 Some(
923 ProjectPickerEntry::OpenFolder { .. }
924 | ProjectPickerEntry::OpenProject(_)
925 | ProjectPickerEntry::RecentProject(_)
926 )
927 )
928 }
929
930 fn update_matches(
931 &mut self,
932 query: String,
933 _: &mut Window,
934 cx: &mut Context<Picker<Self>>,
935 ) -> gpui::Task<()> {
936 let query = query.trim_start();
937 let smart_case = query.chars().any(|c| c.is_uppercase());
938 let is_empty_query = query.is_empty();
939
940 let folder_matches = if self.open_folders.is_empty() {
941 Vec::new()
942 } else {
943 let candidates: Vec<_> = self
944 .open_folders
945 .iter()
946 .enumerate()
947 .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
948 .collect();
949
950 smol::block_on(fuzzy::match_strings(
951 &candidates,
952 query,
953 smart_case,
954 true,
955 100,
956 &Default::default(),
957 cx.background_executor().clone(),
958 ))
959 };
960
961 let sibling_candidates: Vec<_> = self
962 .workspaces
963 .iter()
964 .enumerate()
965 .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id, cx))
966 .map(|(id, (_, _, paths, _))| {
967 let combined_string = paths
968 .ordered_paths()
969 .map(|path| path.compact().to_string_lossy().into_owned())
970 .collect::<Vec<_>>()
971 .join("");
972 StringMatchCandidate::new(id, &combined_string)
973 })
974 .collect();
975
976 let mut sibling_matches = smol::block_on(fuzzy::match_strings(
977 &sibling_candidates,
978 query,
979 smart_case,
980 true,
981 100,
982 &Default::default(),
983 cx.background_executor().clone(),
984 ));
985 sibling_matches.sort_unstable_by(|a, b| {
986 b.score
987 .partial_cmp(&a.score)
988 .unwrap_or(std::cmp::Ordering::Equal)
989 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
990 });
991
992 // Build candidates for recent projects (not current, not sibling, not open folder)
993 let recent_candidates: Vec<_> = self
994 .workspaces
995 .iter()
996 .enumerate()
997 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
998 .map(|(id, (_, _, paths, _))| {
999 let combined_string = paths
1000 .ordered_paths()
1001 .map(|path| path.compact().to_string_lossy().into_owned())
1002 .collect::<Vec<_>>()
1003 .join("");
1004 StringMatchCandidate::new(id, &combined_string)
1005 })
1006 .collect();
1007
1008 let mut recent_matches = smol::block_on(fuzzy::match_strings(
1009 &recent_candidates,
1010 query,
1011 smart_case,
1012 true,
1013 100,
1014 &Default::default(),
1015 cx.background_executor().clone(),
1016 ));
1017 recent_matches.sort_unstable_by(|a, b| {
1018 b.score
1019 .partial_cmp(&a.score)
1020 .unwrap_or(std::cmp::Ordering::Equal)
1021 .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1022 });
1023
1024 let mut entries = Vec::new();
1025
1026 if !self.open_folders.is_empty() {
1027 let matched_folders: Vec<_> = if is_empty_query {
1028 (0..self.open_folders.len())
1029 .map(|i| (i, Vec::new()))
1030 .collect()
1031 } else {
1032 folder_matches
1033 .iter()
1034 .map(|m| (m.candidate_id, m.positions.clone()))
1035 .collect()
1036 };
1037
1038 for (index, positions) in matched_folders {
1039 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1040 }
1041 }
1042
1043 let has_siblings_to_show = if is_empty_query {
1044 !sibling_candidates.is_empty()
1045 } else {
1046 !sibling_matches.is_empty()
1047 };
1048
1049 if has_siblings_to_show {
1050 entries.push(ProjectPickerEntry::Header("This Window".into()));
1051
1052 if is_empty_query {
1053 for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
1054 if self.is_sibling_workspace(*workspace_id, cx) {
1055 entries.push(ProjectPickerEntry::OpenProject(StringMatch {
1056 candidate_id: id,
1057 score: 0.0,
1058 positions: Vec::new(),
1059 string: String::new(),
1060 }));
1061 }
1062 }
1063 } else {
1064 for m in sibling_matches {
1065 entries.push(ProjectPickerEntry::OpenProject(m));
1066 }
1067 }
1068 }
1069
1070 let has_recent_to_show = if is_empty_query {
1071 !recent_candidates.is_empty()
1072 } else {
1073 !recent_matches.is_empty()
1074 };
1075
1076 if has_recent_to_show {
1077 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1078
1079 if is_empty_query {
1080 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1081 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1082 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1083 candidate_id: id,
1084 score: 0.0,
1085 positions: Vec::new(),
1086 string: String::new(),
1087 }));
1088 }
1089 }
1090 } else {
1091 for m in recent_matches {
1092 entries.push(ProjectPickerEntry::RecentProject(m));
1093 }
1094 }
1095 }
1096
1097 self.filtered_entries = entries;
1098
1099 if self.reset_selected_match_index {
1100 self.selected_index = self
1101 .filtered_entries
1102 .iter()
1103 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1104 .unwrap_or(0);
1105 }
1106 self.reset_selected_match_index = true;
1107 Task::ready(())
1108 }
1109
1110 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1111 match self.filtered_entries.get(self.selected_index) {
1112 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1113 let Some(folder) = self.open_folders.get(*index) else {
1114 return;
1115 };
1116 let worktree_id = folder.worktree_id;
1117 if let Some(workspace) = self.workspace.upgrade() {
1118 workspace.update(cx, |workspace, cx| {
1119 workspace.set_active_worktree_override(Some(worktree_id), cx);
1120 });
1121 }
1122 cx.emit(DismissEvent);
1123 }
1124 Some(ProjectPickerEntry::OpenProject(selected_match)) => {
1125 let Some((workspace_id, _, _, _)) =
1126 self.workspaces.get(selected_match.candidate_id)
1127 else {
1128 return;
1129 };
1130 let workspace_id = *workspace_id;
1131
1132 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1133 cx.defer(move |cx| {
1134 handle
1135 .update(cx, |multi_workspace, window, cx| {
1136 let workspace = multi_workspace
1137 .workspaces()
1138 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1139 .cloned();
1140 if let Some(workspace) = workspace {
1141 multi_workspace.activate(workspace, window, cx);
1142 }
1143 })
1144 .log_err();
1145 });
1146 }
1147 cx.emit(DismissEvent);
1148 }
1149 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1150 let Some(workspace) = self.workspace.upgrade() else {
1151 return;
1152 };
1153 let Some((
1154 candidate_workspace_id,
1155 candidate_workspace_location,
1156 candidate_workspace_paths,
1157 _,
1158 )) = self.workspaces.get(selected_match.candidate_id)
1159 else {
1160 return;
1161 };
1162
1163 let replace_current_window = self.create_new_window == secondary;
1164 let candidate_workspace_id = *candidate_workspace_id;
1165 let candidate_workspace_location = candidate_workspace_location.clone();
1166 let candidate_workspace_paths = candidate_workspace_paths.clone();
1167
1168 workspace.update(cx, |workspace, cx| {
1169 if workspace.database_id() == Some(candidate_workspace_id) {
1170 return;
1171 }
1172 match candidate_workspace_location {
1173 SerializedWorkspaceLocation::Local => {
1174 let paths = candidate_workspace_paths.paths().to_vec();
1175 if replace_current_window {
1176 if let Some(handle) =
1177 window.window_handle().downcast::<MultiWorkspace>()
1178 {
1179 cx.defer(move |cx| {
1180 if let Some(task) = handle
1181 .update(cx, |multi_workspace, window, cx| {
1182 multi_workspace.open_project(
1183 paths,
1184 OpenMode::Activate,
1185 window,
1186 cx,
1187 )
1188 })
1189 .log_err()
1190 {
1191 task.detach_and_log_err(cx);
1192 }
1193 });
1194 }
1195 return;
1196 } else {
1197 workspace
1198 .open_workspace_for_paths(
1199 OpenMode::NewWindow,
1200 paths,
1201 window,
1202 cx,
1203 )
1204 .detach_and_prompt_err(
1205 "Failed to open project",
1206 window,
1207 cx,
1208 |_, _, _| None,
1209 );
1210 }
1211 }
1212 SerializedWorkspaceLocation::Remote(mut connection) => {
1213 let app_state = workspace.app_state().clone();
1214 let replace_window = if replace_current_window {
1215 window.window_handle().downcast::<MultiWorkspace>()
1216 } else {
1217 None
1218 };
1219 let open_options = OpenOptions {
1220 requesting_window: replace_window,
1221 ..Default::default()
1222 };
1223 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1224 RemoteSettings::get_global(cx)
1225 .fill_connection_options_from_settings(connection);
1226 };
1227 let paths = candidate_workspace_paths.paths().to_vec();
1228 cx.spawn_in(window, async move |_, cx| {
1229 open_remote_project(
1230 connection.clone(),
1231 paths,
1232 app_state,
1233 open_options,
1234 cx,
1235 )
1236 .await
1237 })
1238 .detach_and_prompt_err(
1239 "Failed to open project",
1240 window,
1241 cx,
1242 |_, _, _| None,
1243 );
1244 }
1245 }
1246 });
1247 cx.emit(DismissEvent);
1248 }
1249 _ => {}
1250 }
1251 }
1252
1253 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1254
1255 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1256 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1257 "Recently opened projects will show up here".into()
1258 } else {
1259 "No matches".into()
1260 };
1261 Some(text)
1262 }
1263
1264 fn render_match(
1265 &self,
1266 ix: usize,
1267 selected: bool,
1268 window: &mut Window,
1269 cx: &mut Context<Picker<Self>>,
1270 ) -> Option<Self::ListItem> {
1271 match self.filtered_entries.get(ix)? {
1272 ProjectPickerEntry::Header(title) => Some(
1273 v_flex()
1274 .w_full()
1275 .gap_1()
1276 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1277 .child(ListSubHeader::new(title.clone()).inset(true))
1278 .into_any_element(),
1279 ),
1280 ProjectPickerEntry::OpenFolder { index, positions } => {
1281 let folder = self.open_folders.get(*index)?;
1282 let name = folder.name.clone();
1283 let path = folder.path.compact();
1284 let branch = folder.branch.clone();
1285 let is_active = folder.is_active;
1286 let worktree_id = folder.worktree_id;
1287 let positions = positions.clone();
1288 let show_path = self.style == ProjectPickerStyle::Modal;
1289
1290 let secondary_actions = h_flex()
1291 .gap_1()
1292 .child(
1293 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1294 .icon_size(IconSize::Small)
1295 .tooltip(Tooltip::text("Remove Folder from Workspace"))
1296 .on_click(cx.listener(move |picker, _, window, cx| {
1297 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1298 return;
1299 };
1300 workspace.update(cx, |workspace, cx| {
1301 let project = workspace.project().clone();
1302 project.update(cx, |project, cx| {
1303 project.remove_worktree(worktree_id, cx);
1304 });
1305 });
1306 picker.delegate.open_folders =
1307 get_open_folders(workspace.read(cx), cx);
1308 let query = picker.query(cx);
1309 picker.update_matches(query, window, cx);
1310 })),
1311 )
1312 .into_any_element();
1313
1314 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1315
1316 Some(
1317 ListItem::new(ix)
1318 .toggle_state(selected)
1319 .inset(true)
1320 .spacing(ListItemSpacing::Sparse)
1321 .child(
1322 h_flex()
1323 .id("open_folder_item")
1324 .gap_3()
1325 .flex_grow()
1326 .when(self.has_any_non_local_projects, |this| {
1327 this.child(Icon::new(icon).color(Color::Muted))
1328 })
1329 .child(
1330 v_flex()
1331 .child(
1332 h_flex()
1333 .gap_1()
1334 .child({
1335 let highlighted = HighlightedMatch {
1336 text: name.to_string(),
1337 highlight_positions: positions,
1338 color: Color::Default,
1339 };
1340 highlighted.render(window, cx)
1341 })
1342 .when_some(branch, |this, branch| {
1343 this.child(
1344 Label::new(branch).color(Color::Muted),
1345 )
1346 })
1347 .when(is_active, |this| {
1348 this.child(
1349 Icon::new(IconName::Check)
1350 .size(IconSize::Small)
1351 .color(Color::Accent),
1352 )
1353 }),
1354 )
1355 .when(show_path, |this| {
1356 this.child(
1357 Label::new(path.to_string_lossy().to_string())
1358 .size(LabelSize::Small)
1359 .color(Color::Muted),
1360 )
1361 }),
1362 )
1363 .when(!show_path, |this| {
1364 this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1365 }),
1366 )
1367 .end_slot(secondary_actions)
1368 .show_end_slot_on_hover()
1369 .into_any_element(),
1370 )
1371 }
1372 ProjectPickerEntry::OpenProject(hit) => {
1373 let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1374 let workspace_id = *workspace_id;
1375 let ordered_paths: Vec<_> = paths
1376 .ordered_paths()
1377 .map(|p| p.compact().to_string_lossy().to_string())
1378 .collect();
1379 let tooltip_path: SharedString = match &location {
1380 SerializedWorkspaceLocation::Remote(options) => {
1381 let host = options.display_name();
1382 if ordered_paths.len() == 1 {
1383 format!("{} ({})", ordered_paths[0], host).into()
1384 } else {
1385 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1386 }
1387 }
1388 _ => ordered_paths.join("\n").into(),
1389 };
1390
1391 let mut path_start_offset = 0;
1392 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1393 .ordered_paths()
1394 .map(|p| p.compact())
1395 .map(|path| {
1396 let highlighted_text =
1397 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1398 path_start_offset += highlighted_text.1.text.len();
1399 highlighted_text
1400 })
1401 .unzip();
1402
1403 let prefix = match &location {
1404 SerializedWorkspaceLocation::Remote(options) => {
1405 Some(SharedString::from(options.display_name()))
1406 }
1407 _ => None,
1408 };
1409
1410 let highlighted_match = HighlightedMatchWithPaths {
1411 prefix,
1412 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1413 paths,
1414 };
1415
1416 let icon = icon_for_remote_connection(match location {
1417 SerializedWorkspaceLocation::Local => None,
1418 SerializedWorkspaceLocation::Remote(options) => Some(options),
1419 });
1420
1421 let secondary_actions = h_flex()
1422 .gap_1()
1423 .child(
1424 IconButton::new("remove_open_project", IconName::Close)
1425 .icon_size(IconSize::Small)
1426 .tooltip(Tooltip::text("Remove Project from Window"))
1427 .on_click(cx.listener(move |picker, _, window, cx| {
1428 cx.stop_propagation();
1429 window.prevent_default();
1430 picker
1431 .delegate
1432 .remove_sibling_workspace(workspace_id, window, cx);
1433 let query = picker.query(cx);
1434 picker.update_matches(query, window, cx);
1435 })),
1436 )
1437 .into_any_element();
1438
1439 Some(
1440 ListItem::new(ix)
1441 .toggle_state(selected)
1442 .inset(true)
1443 .spacing(ListItemSpacing::Sparse)
1444 .child(
1445 h_flex()
1446 .id("open_project_info_container")
1447 .gap_3()
1448 .flex_grow()
1449 .when(self.has_any_non_local_projects, |this| {
1450 this.child(Icon::new(icon).color(Color::Muted))
1451 })
1452 .child({
1453 let mut highlighted = highlighted_match;
1454 if !self.render_paths {
1455 highlighted.paths.clear();
1456 }
1457 highlighted.render(window, cx)
1458 })
1459 .tooltip(Tooltip::text(tooltip_path)),
1460 )
1461 .end_slot(secondary_actions)
1462 .show_end_slot_on_hover()
1463 .into_any_element(),
1464 )
1465 }
1466 ProjectPickerEntry::RecentProject(hit) => {
1467 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1468 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1469 let paths_to_add = paths.paths().to_vec();
1470 let ordered_paths: Vec<_> = paths
1471 .ordered_paths()
1472 .map(|p| p.compact().to_string_lossy().to_string())
1473 .collect();
1474 let tooltip_path: SharedString = match &location {
1475 SerializedWorkspaceLocation::Remote(options) => {
1476 let host = options.display_name();
1477 if ordered_paths.len() == 1 {
1478 format!("{} ({})", ordered_paths[0], host).into()
1479 } else {
1480 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1481 }
1482 }
1483 _ => ordered_paths.join("\n").into(),
1484 };
1485
1486 let mut path_start_offset = 0;
1487 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1488 .ordered_paths()
1489 .map(|p| p.compact())
1490 .map(|path| {
1491 let highlighted_text =
1492 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1493 path_start_offset += highlighted_text.1.text.len();
1494 highlighted_text
1495 })
1496 .unzip();
1497
1498 let prefix = match &location {
1499 SerializedWorkspaceLocation::Remote(options) => {
1500 Some(SharedString::from(options.display_name()))
1501 }
1502 _ => None,
1503 };
1504
1505 let highlighted_match = HighlightedMatchWithPaths {
1506 prefix,
1507 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1508 paths,
1509 };
1510
1511 let focus_handle = self.focus_handle.clone();
1512
1513 let secondary_actions = h_flex()
1514 .gap_px()
1515 .when(is_local, |this| {
1516 this.child(
1517 IconButton::new("add_to_workspace", IconName::FolderPlus)
1518 .icon_size(IconSize::Small)
1519 .tooltip(Tooltip::text("Add Project to this Workspace"))
1520 .on_click({
1521 let paths_to_add = paths_to_add.clone();
1522 cx.listener(move |picker, _event, window, cx| {
1523 cx.stop_propagation();
1524 window.prevent_default();
1525 picker.delegate.add_project_to_workspace(
1526 paths_to_add.clone(),
1527 window,
1528 cx,
1529 );
1530 })
1531 }),
1532 )
1533 })
1534 .child(
1535 IconButton::new("open_new_window", IconName::ArrowUpRight)
1536 .icon_size(IconSize::XSmall)
1537 .tooltip({
1538 move |_, cx| {
1539 Tooltip::for_action_in(
1540 "Open Project in New Window",
1541 &menu::SecondaryConfirm,
1542 &focus_handle,
1543 cx,
1544 )
1545 }
1546 })
1547 .on_click(cx.listener(move |this, _event, window, cx| {
1548 cx.stop_propagation();
1549 window.prevent_default();
1550 this.delegate.set_selected_index(ix, window, cx);
1551 this.delegate.confirm(true, window, cx);
1552 })),
1553 )
1554 .child(
1555 IconButton::new("delete", IconName::Close)
1556 .icon_size(IconSize::Small)
1557 .tooltip(Tooltip::text("Delete from Recent Projects"))
1558 .on_click(cx.listener(move |this, _event, window, cx| {
1559 cx.stop_propagation();
1560 window.prevent_default();
1561 this.delegate.delete_recent_project(ix, window, cx)
1562 })),
1563 )
1564 .into_any_element();
1565
1566 let icon = icon_for_remote_connection(match location {
1567 SerializedWorkspaceLocation::Local => None,
1568 SerializedWorkspaceLocation::Remote(options) => Some(options),
1569 });
1570
1571 Some(
1572 ListItem::new(ix)
1573 .toggle_state(selected)
1574 .inset(true)
1575 .spacing(ListItemSpacing::Sparse)
1576 .child(
1577 h_flex()
1578 .id("project_info_container")
1579 .gap_3()
1580 .flex_grow()
1581 .when(self.has_any_non_local_projects, |this| {
1582 this.child(Icon::new(icon).color(Color::Muted))
1583 })
1584 .child({
1585 let mut highlighted = highlighted_match;
1586 if !self.render_paths {
1587 highlighted.paths.clear();
1588 }
1589 highlighted.render(window, cx)
1590 })
1591 .tooltip(Tooltip::text(tooltip_path)),
1592 )
1593 .end_slot(secondary_actions)
1594 .show_end_slot_on_hover()
1595 .into_any_element(),
1596 )
1597 }
1598 }
1599 }
1600
1601 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1602 let focus_handle = self.focus_handle.clone();
1603 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1604 let is_already_open_entry = matches!(
1605 self.filtered_entries.get(self.selected_index),
1606 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
1607 );
1608
1609 if popover_style {
1610 return Some(
1611 v_flex()
1612 .flex_1()
1613 .p_1p5()
1614 .gap_1()
1615 .border_t_1()
1616 .border_color(cx.theme().colors().border_variant)
1617 .child({
1618 let open_action = workspace::Open::default();
1619 Button::new("open_local_folder", "Open Local Project")
1620 .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1621 .on_click(move |_, window, cx| {
1622 window.dispatch_action(open_action.boxed_clone(), cx)
1623 })
1624 })
1625 .child(
1626 Button::new("open_remote_folder", "Open Remote Project")
1627 .key_binding(KeyBinding::for_action(
1628 &OpenRemote {
1629 from_existing_connection: false,
1630 create_new_window: false,
1631 },
1632 cx,
1633 ))
1634 .on_click(|_, window, cx| {
1635 window.dispatch_action(
1636 OpenRemote {
1637 from_existing_connection: false,
1638 create_new_window: false,
1639 }
1640 .boxed_clone(),
1641 cx,
1642 )
1643 }),
1644 )
1645 .into_any(),
1646 );
1647 }
1648
1649 let selected_entry = self.filtered_entries.get(self.selected_index);
1650
1651 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1652 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_)) => {
1653 let label = if matches!(selected_entry, Some(ProjectPickerEntry::OpenFolder { .. }))
1654 {
1655 "Remove Folder"
1656 } else {
1657 "Remove from Window"
1658 };
1659 Some(
1660 Button::new("remove_selected", label)
1661 .key_binding(KeyBinding::for_action_in(
1662 &RemoveSelected,
1663 &focus_handle,
1664 cx,
1665 ))
1666 .on_click(|_, window, cx| {
1667 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1668 })
1669 .into_any_element(),
1670 )
1671 }
1672 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1673 Button::new("delete_recent", "Delete")
1674 .key_binding(KeyBinding::for_action_in(
1675 &RemoveSelected,
1676 &focus_handle,
1677 cx,
1678 ))
1679 .on_click(|_, window, cx| {
1680 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1681 })
1682 .into_any_element(),
1683 ),
1684 _ => None,
1685 };
1686
1687 Some(
1688 h_flex()
1689 .flex_1()
1690 .p_1p5()
1691 .gap_1()
1692 .justify_end()
1693 .border_t_1()
1694 .border_color(cx.theme().colors().border_variant)
1695 .when_some(secondary_footer_actions, |this, actions| {
1696 this.child(actions)
1697 })
1698 .map(|this| {
1699 if is_already_open_entry {
1700 this.child(
1701 Button::new("activate", "Activate")
1702 .key_binding(KeyBinding::for_action_in(
1703 &menu::Confirm,
1704 &focus_handle,
1705 cx,
1706 ))
1707 .on_click(|_, window, cx| {
1708 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1709 }),
1710 )
1711 } else {
1712 this.child(
1713 Button::new("open_new_window", "New Window")
1714 .key_binding(KeyBinding::for_action_in(
1715 &menu::SecondaryConfirm,
1716 &focus_handle,
1717 cx,
1718 ))
1719 .on_click(|_, window, cx| {
1720 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1721 }),
1722 )
1723 .child(
1724 Button::new("open_here", "Open")
1725 .key_binding(KeyBinding::for_action_in(
1726 &menu::Confirm,
1727 &focus_handle,
1728 cx,
1729 ))
1730 .on_click(|_, window, cx| {
1731 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1732 }),
1733 )
1734 }
1735 })
1736 .child(Divider::vertical())
1737 .child(
1738 PopoverMenu::new("actions-menu-popover")
1739 .with_handle(self.actions_menu_handle.clone())
1740 .anchor(gpui::Corner::BottomRight)
1741 .offset(gpui::Point {
1742 x: px(0.0),
1743 y: px(-2.0),
1744 })
1745 .trigger(
1746 Button::new("actions-trigger", "Actions")
1747 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1748 .key_binding(KeyBinding::for_action_in(
1749 &ToggleActionsMenu,
1750 &focus_handle,
1751 cx,
1752 )),
1753 )
1754 .menu({
1755 let focus_handle = focus_handle.clone();
1756 let show_add_to_workspace = match selected_entry {
1757 Some(ProjectPickerEntry::RecentProject(hit)) => self
1758 .workspaces
1759 .get(hit.candidate_id)
1760 .map(|(_, loc, ..)| {
1761 matches!(loc, SerializedWorkspaceLocation::Local)
1762 })
1763 .unwrap_or(false),
1764 _ => false,
1765 };
1766
1767 move |window, cx| {
1768 Some(ContextMenu::build(window, cx, {
1769 let focus_handle = focus_handle.clone();
1770 move |menu, _, _| {
1771 menu.context(focus_handle)
1772 .when(show_add_to_workspace, |menu| {
1773 menu.action(
1774 "Add to Workspace",
1775 AddToWorkspace.boxed_clone(),
1776 )
1777 .separator()
1778 })
1779 .action(
1780 "Open Local Project",
1781 workspace::Open::default().boxed_clone(),
1782 )
1783 .action(
1784 "Open Remote Project",
1785 OpenRemote {
1786 from_existing_connection: false,
1787 create_new_window: false,
1788 }
1789 .boxed_clone(),
1790 )
1791 }
1792 }))
1793 }
1794 }),
1795 )
1796 .into_any(),
1797 )
1798 }
1799}
1800
1801pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1802 match options {
1803 None => IconName::Screen,
1804 Some(options) => match options {
1805 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1806 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1807 RemoteConnectionOptions::Docker(_) => IconName::Box,
1808 #[cfg(any(test, feature = "test-support"))]
1809 RemoteConnectionOptions::Mock(_) => IconName::Server,
1810 },
1811 }
1812}
1813
1814// Compute the highlighted text for the name and path
1815pub(crate) fn highlights_for_path(
1816 path: &Path,
1817 match_positions: &Vec<usize>,
1818 path_start_offset: usize,
1819) -> (Option<HighlightedMatch>, HighlightedMatch) {
1820 let path_string = path.to_string_lossy();
1821 let path_text = path_string.to_string();
1822 let path_byte_len = path_text.len();
1823 // Get the subset of match highlight positions that line up with the given path.
1824 // Also adjusts them to start at the path start
1825 let path_positions = match_positions
1826 .iter()
1827 .copied()
1828 .skip_while(|position| *position < path_start_offset)
1829 .take_while(|position| *position < path_start_offset + path_byte_len)
1830 .map(|position| position - path_start_offset)
1831 .collect::<Vec<_>>();
1832
1833 // Again subset the highlight positions to just those that line up with the file_name
1834 // again adjusted to the start of the file_name
1835 let file_name_text_and_positions = path.file_name().map(|file_name| {
1836 let file_name_text = file_name.to_string_lossy().into_owned();
1837 let file_name_start_byte = path_byte_len - file_name_text.len();
1838 let highlight_positions = path_positions
1839 .iter()
1840 .copied()
1841 .skip_while(|position| *position < file_name_start_byte)
1842 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1843 .map(|position| position - file_name_start_byte)
1844 .collect::<Vec<_>>();
1845 HighlightedMatch {
1846 text: file_name_text,
1847 highlight_positions,
1848 color: Color::Default,
1849 }
1850 });
1851
1852 (
1853 file_name_text_and_positions,
1854 HighlightedMatch {
1855 text: path_text,
1856 highlight_positions: path_positions,
1857 color: Color::Default,
1858 },
1859 )
1860}
1861impl RecentProjectsDelegate {
1862 fn add_project_to_workspace(
1863 &mut self,
1864 paths: Vec<PathBuf>,
1865 window: &mut Window,
1866 cx: &mut Context<Picker<Self>>,
1867 ) {
1868 let Some(workspace) = self.workspace.upgrade() else {
1869 return;
1870 };
1871 let open_paths_task = workspace.update(cx, |workspace, cx| {
1872 workspace.open_paths(
1873 paths,
1874 OpenOptions {
1875 visible: Some(OpenVisible::All),
1876 ..Default::default()
1877 },
1878 None,
1879 window,
1880 cx,
1881 )
1882 });
1883 cx.spawn_in(window, async move |picker, cx| {
1884 let _result = open_paths_task.await;
1885 picker
1886 .update_in(cx, |picker, window, cx| {
1887 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1888 return;
1889 };
1890 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1891 let query = picker.query(cx);
1892 picker.update_matches(query, window, cx);
1893 })
1894 .ok();
1895 })
1896 .detach();
1897 }
1898
1899 fn delete_recent_project(
1900 &self,
1901 ix: usize,
1902 window: &mut Window,
1903 cx: &mut Context<Picker<Self>>,
1904 ) {
1905 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1906 self.filtered_entries.get(ix)
1907 {
1908 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1909 let workspace_id = *workspace_id;
1910 let fs = self
1911 .workspace
1912 .upgrade()
1913 .map(|ws| ws.read(cx).app_state().fs.clone());
1914 let db = WorkspaceDb::global(cx);
1915 cx.spawn_in(window, async move |this, cx| {
1916 db.delete_workspace_by_id(workspace_id).await.log_err();
1917 let Some(fs) = fs else { return };
1918 let workspaces = db
1919 .recent_workspaces_on_disk(fs.as_ref())
1920 .await
1921 .unwrap_or_default();
1922 let workspaces =
1923 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1924 this.update_in(cx, move |picker, window, cx| {
1925 picker.delegate.set_workspaces(workspaces);
1926 picker
1927 .delegate
1928 .set_selected_index(ix.saturating_sub(1), window, cx);
1929 picker.delegate.reset_selected_match_index = false;
1930 picker.update_matches(picker.query(cx), window, cx);
1931 // After deleting a project, we want to update the history manager to reflect the change.
1932 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1933 if let Some(history_manager) = HistoryManager::global(cx) {
1934 history_manager
1935 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1936 }
1937 })
1938 .ok();
1939 })
1940 .detach();
1941 }
1942 }
1943
1944 fn remove_sibling_workspace(
1945 &mut self,
1946 workspace_id: WorkspaceId,
1947 window: &mut Window,
1948 cx: &mut Context<Picker<Self>>,
1949 ) {
1950 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1951 cx.defer(move |cx| {
1952 handle
1953 .update(cx, |multi_workspace, window, cx| {
1954 let workspace = multi_workspace
1955 .workspaces()
1956 .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1957 .cloned();
1958 if let Some(workspace) = workspace {
1959 multi_workspace.remove(&workspace, window, cx);
1960 }
1961 })
1962 .log_err();
1963 });
1964 }
1965
1966 self.sibling_workspace_ids.remove(&workspace_id);
1967 }
1968
1969 fn is_current_workspace(
1970 &self,
1971 workspace_id: WorkspaceId,
1972 cx: &mut Context<Picker<Self>>,
1973 ) -> bool {
1974 if let Some(workspace) = self.workspace.upgrade() {
1975 let workspace = workspace.read(cx);
1976 if Some(workspace_id) == workspace.database_id() {
1977 return true;
1978 }
1979 }
1980
1981 false
1982 }
1983
1984 fn is_sibling_workspace(
1985 &self,
1986 workspace_id: WorkspaceId,
1987 cx: &mut Context<Picker<Self>>,
1988 ) -> bool {
1989 self.sibling_workspace_ids.contains(&workspace_id)
1990 && !self.is_current_workspace(workspace_id, cx)
1991 }
1992
1993 fn is_open_folder(&self, paths: &PathList) -> bool {
1994 if self.open_folders.is_empty() {
1995 return false;
1996 }
1997
1998 for workspace_path in paths.paths() {
1999 for open_folder in &self.open_folders {
2000 if workspace_path == &open_folder.path {
2001 return true;
2002 }
2003 }
2004 }
2005
2006 false
2007 }
2008
2009 fn is_valid_recent_candidate(
2010 &self,
2011 workspace_id: WorkspaceId,
2012 paths: &PathList,
2013 cx: &mut Context<Picker<Self>>,
2014 ) -> bool {
2015 !self.is_current_workspace(workspace_id, cx)
2016 && !self.is_sibling_workspace(workspace_id, cx)
2017 && !self.is_open_folder(paths)
2018 }
2019}
2020
2021#[cfg(test)]
2022mod tests {
2023 use std::path::PathBuf;
2024
2025 use editor::Editor;
2026 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
2027
2028 use serde_json::json;
2029 use settings::SettingsStore;
2030 use util::path;
2031 use workspace::{AppState, open_paths};
2032
2033 use super::*;
2034
2035 #[gpui::test]
2036 async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
2037 let app_state = init_test(cx);
2038
2039 cx.update(|cx| {
2040 SettingsStore::update_global(cx, |store, cx| {
2041 store.update_user_settings(cx, |settings| {
2042 settings
2043 .session
2044 .get_or_insert_default()
2045 .restore_unsaved_buffers = Some(false)
2046 });
2047 });
2048 });
2049
2050 app_state
2051 .fs
2052 .as_fake()
2053 .insert_tree(
2054 path!("/dir"),
2055 json!({
2056 "main.ts": "a"
2057 }),
2058 )
2059 .await;
2060 app_state
2061 .fs
2062 .as_fake()
2063 .insert_tree(path!("/test/path"), json!({}))
2064 .await;
2065 cx.update(|cx| {
2066 open_paths(
2067 &[PathBuf::from(path!("/dir/main.ts"))],
2068 app_state,
2069 workspace::OpenOptions::default(),
2070 cx,
2071 )
2072 })
2073 .await
2074 .unwrap();
2075 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2076
2077 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2078 multi_workspace
2079 .update(cx, |multi_workspace, _, cx| {
2080 multi_workspace.open_sidebar(cx);
2081 })
2082 .unwrap();
2083 multi_workspace
2084 .update(cx, |multi_workspace, _, cx| {
2085 assert!(!multi_workspace.workspace().read(cx).is_edited())
2086 })
2087 .unwrap();
2088
2089 let editor = multi_workspace
2090 .read_with(cx, |multi_workspace, cx| {
2091 multi_workspace
2092 .workspace()
2093 .read(cx)
2094 .active_item(cx)
2095 .unwrap()
2096 .downcast::<Editor>()
2097 .unwrap()
2098 })
2099 .unwrap();
2100 multi_workspace
2101 .update(cx, |_, window, cx| {
2102 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
2103 })
2104 .unwrap();
2105 multi_workspace
2106 .update(cx, |multi_workspace, _, cx| {
2107 assert!(
2108 multi_workspace.workspace().read(cx).is_edited(),
2109 "After inserting more text into the editor without saving, we should have a dirty project"
2110 )
2111 })
2112 .unwrap();
2113
2114 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
2115 multi_workspace
2116 .update(cx, |_, _, cx| {
2117 recent_projects_picker.update(cx, |picker, cx| {
2118 assert_eq!(picker.query(cx), "");
2119 let delegate = &mut picker.delegate;
2120 delegate.set_workspaces(vec![(
2121 WorkspaceId::default(),
2122 SerializedWorkspaceLocation::Local,
2123 PathList::new(&[path!("/test/path")]),
2124 Utc::now(),
2125 )]);
2126 delegate.filtered_entries =
2127 vec![ProjectPickerEntry::RecentProject(StringMatch {
2128 candidate_id: 0,
2129 score: 1.0,
2130 positions: Vec::new(),
2131 string: "fake candidate".to_string(),
2132 })];
2133 });
2134 })
2135 .unwrap();
2136
2137 assert!(
2138 !cx.has_pending_prompt(),
2139 "Should have no pending prompt on dirty project before opening the new recent project"
2140 );
2141 let dirty_workspace = multi_workspace
2142 .read_with(cx, |multi_workspace, _cx| {
2143 multi_workspace.workspace().clone()
2144 })
2145 .unwrap();
2146
2147 cx.dispatch_action(*multi_workspace, menu::Confirm);
2148 cx.run_until_parked();
2149
2150 // In multi-workspace mode, the dirty workspace is kept and a new one is
2151 // opened alongside it — no save prompt needed.
2152 assert!(
2153 !cx.has_pending_prompt(),
2154 "Should not prompt in multi-workspace mode — dirty workspace is kept"
2155 );
2156
2157 multi_workspace
2158 .update(cx, |multi_workspace, _, cx| {
2159 assert!(
2160 multi_workspace
2161 .workspace()
2162 .read(cx)
2163 .active_modal::<RecentProjects>(cx)
2164 .is_none(),
2165 "Should remove the modal after selecting new recent project"
2166 );
2167
2168 assert!(
2169 multi_workspace.workspaces().any(|w| w == &dirty_workspace),
2170 "The dirty workspace should still be present in multi-workspace mode"
2171 );
2172
2173 assert!(
2174 !multi_workspace.workspace().read(cx).is_edited(),
2175 "The active workspace should be the freshly opened one, not dirty"
2176 );
2177 })
2178 .unwrap();
2179 }
2180
2181 fn open_recent_projects(
2182 multi_workspace: &WindowHandle<MultiWorkspace>,
2183 cx: &mut TestAppContext,
2184 ) -> Entity<Picker<RecentProjectsDelegate>> {
2185 cx.dispatch_action(
2186 (*multi_workspace).into(),
2187 OpenRecent {
2188 create_new_window: false,
2189 },
2190 );
2191 multi_workspace
2192 .update(cx, |multi_workspace, _, cx| {
2193 multi_workspace
2194 .workspace()
2195 .read(cx)
2196 .active_modal::<RecentProjects>(cx)
2197 .unwrap()
2198 .read(cx)
2199 .picker
2200 .clone()
2201 })
2202 .unwrap()
2203 }
2204
2205 #[gpui::test]
2206 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2207 let app_state = init_test(cx);
2208
2209 app_state
2210 .fs
2211 .as_fake()
2212 .insert_tree(
2213 path!("/project"),
2214 json!({
2215 ".devcontainer": {
2216 "devcontainer.json": "{}"
2217 },
2218 "src": {
2219 "main.rs": "fn main() {}"
2220 }
2221 }),
2222 )
2223 .await;
2224
2225 // Open a file path (not a directory) so that the worktree root is a
2226 // file. This means `active_project_directory` returns `None`, which
2227 // causes `DevContainerContext::from_workspace` to return `None`,
2228 // preventing `open_dev_container` from spawning real I/O (docker
2229 // commands, shell environment loading) that is incompatible with the
2230 // test scheduler. The modal is still created and the re-entrancy
2231 // guard that this test validates is still exercised.
2232 cx.update(|cx| {
2233 open_paths(
2234 &[PathBuf::from(path!("/project/src/main.rs"))],
2235 app_state,
2236 workspace::OpenOptions::default(),
2237 cx,
2238 )
2239 })
2240 .await
2241 .unwrap();
2242
2243 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2244 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2245
2246 cx.run_until_parked();
2247
2248 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2249 // -> Workspace::update -> toggle_modal -> new_dev_container.
2250 // Before the fix, this panicked with "cannot read workspace::Workspace while
2251 // it is already being updated" because new_dev_container and open_dev_container
2252 // tried to read the Workspace entity through a WeakEntity handle while it was
2253 // already leased by the outer update.
2254 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2255
2256 multi_workspace
2257 .update(cx, |multi_workspace, _, cx| {
2258 let modal = multi_workspace
2259 .workspace()
2260 .read(cx)
2261 .active_modal::<RemoteServerProjects>(cx);
2262 assert!(
2263 modal.is_some(),
2264 "Dev container modal should be open after dispatching OpenDevContainer"
2265 );
2266 })
2267 .unwrap();
2268 }
2269
2270 #[gpui::test]
2271 async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2272 let app_state = init_test(cx);
2273
2274 app_state
2275 .fs
2276 .as_fake()
2277 .insert_tree(
2278 path!("/project"),
2279 json!({
2280 ".devcontainer": {
2281 "devcontainer.json": "{}"
2282 },
2283 "src": {
2284 "main.rs": "fn main() {}"
2285 }
2286 }),
2287 )
2288 .await;
2289
2290 cx.update(|cx| {
2291 open_paths(
2292 &[PathBuf::from(path!("/project"))],
2293 app_state,
2294 workspace::OpenOptions::default(),
2295 cx,
2296 )
2297 })
2298 .await
2299 .unwrap();
2300
2301 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2302 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2303
2304 cx.run_until_parked();
2305
2306 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2307
2308 multi_workspace
2309 .update(cx, |multi_workspace, _, cx| {
2310 assert!(
2311 multi_workspace
2312 .active_modal::<RemoteServerProjects>(cx)
2313 .is_some(),
2314 "Dev container modal should be open"
2315 );
2316 })
2317 .unwrap();
2318
2319 // Click outside the modal (on the backdrop) to try to dismiss it
2320 let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2321 vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2322
2323 multi_workspace
2324 .update(cx, |multi_workspace, _, cx| {
2325 assert!(
2326 multi_workspace
2327 .active_modal::<RemoteServerProjects>(cx)
2328 .is_some(),
2329 "Dev container modal should remain open during creation"
2330 );
2331 })
2332 .unwrap();
2333 }
2334
2335 #[gpui::test]
2336 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2337 let app_state = init_test(cx);
2338
2339 app_state
2340 .fs
2341 .as_fake()
2342 .insert_tree(
2343 path!("/project"),
2344 json!({
2345 ".devcontainer": {
2346 "rust": {
2347 "devcontainer.json": "{}"
2348 },
2349 "python": {
2350 "devcontainer.json": "{}"
2351 }
2352 },
2353 "src": {
2354 "main.rs": "fn main() {}"
2355 }
2356 }),
2357 )
2358 .await;
2359
2360 cx.update(|cx| {
2361 open_paths(
2362 &[PathBuf::from(path!("/project"))],
2363 app_state,
2364 workspace::OpenOptions::default(),
2365 cx,
2366 )
2367 })
2368 .await
2369 .unwrap();
2370
2371 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2372 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2373
2374 cx.run_until_parked();
2375
2376 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2377
2378 multi_workspace
2379 .update(cx, |multi_workspace, _, cx| {
2380 let modal = multi_workspace
2381 .workspace()
2382 .read(cx)
2383 .active_modal::<RemoteServerProjects>(cx);
2384 assert!(
2385 modal.is_some(),
2386 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2387 );
2388 })
2389 .unwrap();
2390 }
2391
2392 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2393 cx.update(|cx| {
2394 let state = AppState::test(cx);
2395 crate::init(cx);
2396 editor::init(cx);
2397 state
2398 })
2399 }
2400}