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_nucleo::{StringMatch, StringMatchCandidate, match_strings};
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 ButtonLike, ContextMenu, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing,
45 ListSubHeader, 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_project_workspaces(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_project_workspaces(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_paths_to_project(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 case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
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 match_strings(
954 &candidates,
955 query,
956 case,
957 fuzzy_nucleo::LengthPenalty::On,
958 100,
959 )
960 };
961
962 let project_group_candidates: Vec<_> = self
963 .window_project_groups
964 .iter()
965 .enumerate()
966 .map(|(id, key)| {
967 let combined_string = key
968 .path_list()
969 .ordered_paths()
970 .map(|path| path.compact().to_string_lossy().into_owned())
971 .collect::<Vec<_>>()
972 .join("");
973 StringMatchCandidate::new(id, &combined_string)
974 })
975 .collect();
976
977 let project_group_matches = match_strings(
978 &project_group_candidates,
979 query,
980 case,
981 fuzzy_nucleo::LengthPenalty::On,
982 100,
983 );
984
985 // Build candidates for recent projects (not current, not sibling, not open folder)
986 let recent_candidates: Vec<_> = self
987 .workspaces
988 .iter()
989 .enumerate()
990 .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
991 .map(|(id, (_, _, paths, _))| {
992 let combined_string = paths
993 .ordered_paths()
994 .map(|path| path.compact().to_string_lossy().into_owned())
995 .collect::<Vec<_>>()
996 .join("");
997 StringMatchCandidate::new(id, &combined_string)
998 })
999 .collect();
1000
1001 let recent_matches = match_strings(
1002 &recent_candidates,
1003 query,
1004 case,
1005 fuzzy_nucleo::LengthPenalty::On,
1006 100,
1007 );
1008
1009 let mut entries = Vec::new();
1010
1011 if !self.open_folders.is_empty() {
1012 let matched_folders: Vec<_> = if is_empty_query {
1013 (0..self.open_folders.len())
1014 .map(|i| (i, Vec::new()))
1015 .collect()
1016 } else {
1017 folder_matches
1018 .iter()
1019 .map(|m| (m.candidate_id, m.positions.clone()))
1020 .collect()
1021 };
1022
1023 if !matched_folders.is_empty() {
1024 entries.push(ProjectPickerEntry::Header("Current Folders".into()));
1025 for (index, positions) in matched_folders {
1026 entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1027 }
1028 }
1029 }
1030
1031 let has_projects_to_show = if is_empty_query {
1032 !project_group_candidates.is_empty()
1033 } else {
1034 !project_group_matches.is_empty()
1035 };
1036
1037 if has_projects_to_show {
1038 entries.push(ProjectPickerEntry::Header("This Window".into()));
1039
1040 if is_empty_query {
1041 for id in 0..self.window_project_groups.len() {
1042 entries.push(ProjectPickerEntry::ProjectGroup(StringMatch {
1043 candidate_id: id,
1044 score: 0.0,
1045 positions: Vec::new(),
1046 string: Default::default(),
1047 }));
1048 }
1049 } else {
1050 for m in project_group_matches {
1051 entries.push(ProjectPickerEntry::ProjectGroup(m));
1052 }
1053 }
1054 }
1055
1056 let has_recent_to_show = if is_empty_query {
1057 !recent_candidates.is_empty()
1058 } else {
1059 !recent_matches.is_empty()
1060 };
1061
1062 if has_recent_to_show {
1063 entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1064
1065 if is_empty_query {
1066 for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1067 if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1068 entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1069 candidate_id: id,
1070 score: 0.0,
1071 positions: Vec::new(),
1072 string: Default::default(),
1073 }));
1074 }
1075 }
1076 } else {
1077 for m in recent_matches {
1078 entries.push(ProjectPickerEntry::RecentProject(m));
1079 }
1080 }
1081 }
1082
1083 self.filtered_entries = entries;
1084
1085 if self.reset_selected_match_index {
1086 self.selected_index = self
1087 .filtered_entries
1088 .iter()
1089 .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1090 .unwrap_or(0);
1091 }
1092 self.reset_selected_match_index = true;
1093 Task::ready(())
1094 }
1095
1096 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1097 match self.filtered_entries.get(self.selected_index) {
1098 Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1099 let Some(folder) = self.open_folders.get(*index) else {
1100 return;
1101 };
1102 let worktree_id = folder.worktree_id;
1103 if let Some(workspace) = self.workspace.upgrade() {
1104 workspace.update(cx, |workspace, cx| {
1105 let git_store = workspace.project().read(cx).git_store().clone();
1106 git_store.update(cx, |git_store, cx| {
1107 git_store.set_active_repo_for_worktree(worktree_id, cx);
1108 });
1109 });
1110 }
1111 cx.emit(DismissEvent);
1112 }
1113 Some(ProjectPickerEntry::ProjectGroup(selected_match)) => {
1114 let Some(key) = self.window_project_groups.get(selected_match.candidate_id) else {
1115 return;
1116 };
1117
1118 let key = key.clone();
1119 let path_list = key.path_list().clone();
1120 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1121 cx.defer(move |cx| {
1122 if let Some(task) = handle
1123 .update(cx, |multi_workspace, window, cx| {
1124 multi_workspace.find_or_create_local_workspace(
1125 path_list,
1126 Some(key.clone()),
1127 &[],
1128 None,
1129 OpenMode::Activate,
1130 window,
1131 cx,
1132 )
1133 })
1134 .log_err()
1135 {
1136 task.detach_and_log_err(cx);
1137 }
1138 });
1139 }
1140 cx.emit(DismissEvent);
1141 }
1142 Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1143 let Some(workspace) = self.workspace.upgrade() else {
1144 return;
1145 };
1146 let Some((
1147 candidate_workspace_id,
1148 candidate_workspace_location,
1149 candidate_workspace_paths,
1150 _,
1151 )) = self.workspaces.get(selected_match.candidate_id)
1152 else {
1153 return;
1154 };
1155
1156 let replace_current_window = self.create_new_window == secondary;
1157 let candidate_workspace_id = *candidate_workspace_id;
1158 let candidate_workspace_location = candidate_workspace_location.clone();
1159 let candidate_workspace_paths = candidate_workspace_paths.clone();
1160
1161 workspace.update(cx, |workspace, cx| {
1162 if workspace.database_id() == Some(candidate_workspace_id) {
1163 return;
1164 }
1165 match candidate_workspace_location {
1166 SerializedWorkspaceLocation::Local => {
1167 let paths = candidate_workspace_paths.paths().to_vec();
1168 if replace_current_window {
1169 if let Some(handle) =
1170 window.window_handle().downcast::<MultiWorkspace>()
1171 {
1172 cx.defer(move |cx| {
1173 if let Some(task) = handle
1174 .update(cx, |multi_workspace, window, cx| {
1175 multi_workspace.open_project(
1176 paths,
1177 OpenMode::Activate,
1178 window,
1179 cx,
1180 )
1181 })
1182 .log_err()
1183 {
1184 task.detach_and_log_err(cx);
1185 }
1186 });
1187 }
1188 return;
1189 } else {
1190 workspace
1191 .open_workspace_for_paths(
1192 OpenMode::NewWindow,
1193 paths,
1194 window,
1195 cx,
1196 )
1197 .detach_and_prompt_err(
1198 "Failed to open project",
1199 window,
1200 cx,
1201 |_, _, _| None,
1202 );
1203 }
1204 }
1205 SerializedWorkspaceLocation::Remote(mut connection) => {
1206 let app_state = workspace.app_state().clone();
1207 let replace_window = if replace_current_window {
1208 window.window_handle().downcast::<MultiWorkspace>()
1209 } else {
1210 None
1211 };
1212 let open_options = OpenOptions {
1213 requesting_window: replace_window,
1214 ..Default::default()
1215 };
1216 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1217 RemoteSettings::get_global(cx)
1218 .fill_connection_options_from_settings(connection);
1219 };
1220 let paths = candidate_workspace_paths.paths().to_vec();
1221 cx.spawn_in(window, async move |_, cx| {
1222 open_remote_project(
1223 connection.clone(),
1224 paths,
1225 app_state,
1226 open_options,
1227 cx,
1228 )
1229 .await
1230 })
1231 .detach_and_prompt_err(
1232 "Failed to open project",
1233 window,
1234 cx,
1235 |_, _, _| None,
1236 );
1237 }
1238 }
1239 });
1240 cx.emit(DismissEvent);
1241 }
1242 _ => {}
1243 }
1244 }
1245
1246 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1247
1248 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1249 let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1250 "Recently opened projects will show up here".into()
1251 } else {
1252 "No matches".into()
1253 };
1254 Some(text)
1255 }
1256
1257 fn render_match(
1258 &self,
1259 ix: usize,
1260 selected: bool,
1261 window: &mut Window,
1262 cx: &mut Context<Picker<Self>>,
1263 ) -> Option<Self::ListItem> {
1264 match self.filtered_entries.get(ix)? {
1265 ProjectPickerEntry::Header(title) => Some(
1266 v_flex()
1267 .w_full()
1268 .gap_1()
1269 .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1270 .child(ListSubHeader::new(title.clone()).inset(true))
1271 .into_any_element(),
1272 ),
1273 ProjectPickerEntry::OpenFolder { index, positions } => {
1274 let folder = self.open_folders.get(*index)?;
1275 let name = folder.name.clone();
1276 let path = folder.path.compact();
1277 let branch = folder.branch.clone();
1278 let is_active = folder.is_active;
1279 let worktree_id = folder.worktree_id;
1280 let positions = positions.clone();
1281 let show_path = self.style == ProjectPickerStyle::Modal;
1282
1283 let secondary_actions = h_flex()
1284 .gap_1()
1285 .child(
1286 IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1287 .icon_size(IconSize::Small)
1288 .tooltip(Tooltip::text("Remove Folder from Project"))
1289 .on_click(cx.listener(move |picker, _, window, cx| {
1290 let Some(workspace) = picker.delegate.workspace.upgrade() else {
1291 return;
1292 };
1293 workspace.update(cx, |workspace, cx| {
1294 let project = workspace.project().clone();
1295 project.update(cx, |project, cx| {
1296 project.remove_worktree(worktree_id, cx);
1297 });
1298 });
1299 picker.delegate.open_folders =
1300 get_open_folders(workspace.read(cx), cx);
1301 let query = picker.query(cx);
1302 picker.update_matches(query, window, cx);
1303 })),
1304 )
1305 .into_any_element();
1306
1307 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1308
1309 let tooltip_path: SharedString = path.to_string_lossy().to_string().into();
1310 let tooltip_branch = branch.clone();
1311
1312 Some(
1313 ListItem::new(ix)
1314 .toggle_state(selected)
1315 .inset(true)
1316 .spacing(ListItemSpacing::Sparse)
1317 .child(
1318 h_flex()
1319 .id("open_folder_item")
1320 .w_full()
1321 .gap_2p5()
1322 .when(self.has_any_non_local_projects, |this| {
1323 this.child(Icon::new(icon).color(Color::Muted))
1324 })
1325 .child(
1326 v_flex()
1327 .min_w_0()
1328 .child(
1329 h_flex()
1330 .gap_1()
1331 .child(HighlightedLabel::new(
1332 name.to_string(),
1333 positions,
1334 ))
1335 .when_some(branch, |this, branch| {
1336 this.child(
1337 Label::new(branch)
1338 .color(Color::Muted)
1339 .truncate(),
1340 )
1341 })
1342 .when(is_active, |this| {
1343 this.child(
1344 Icon::new(IconName::Check)
1345 .size(IconSize::Small)
1346 .color(Color::Accent),
1347 )
1348 }),
1349 )
1350 .when(show_path, |this| {
1351 this.child(
1352 Label::new(path.to_string_lossy().to_string())
1353 .size(LabelSize::Small)
1354 .color(Color::Muted),
1355 )
1356 }),
1357 )
1358 .when(!show_path, |this| {
1359 this.tooltip(move |_, cx| {
1360 if let Some(branch) = tooltip_branch.clone() {
1361 Tooltip::with_meta(
1362 format!("{}/{}", name, branch),
1363 None,
1364 tooltip_path.clone(),
1365 cx,
1366 )
1367 } else {
1368 Tooltip::simple(tooltip_path.clone(), cx)
1369 }
1370 })
1371 }),
1372 )
1373 .end_slot(secondary_actions)
1374 .show_end_slot_on_hover()
1375 .into_any_element(),
1376 )
1377 }
1378 ProjectPickerEntry::ProjectGroup(hit) => {
1379 let key = self.window_project_groups.get(hit.candidate_id)?;
1380 let is_active = self.is_active_project_group(key, cx);
1381 let paths = key.path_list();
1382 let ordered_paths: Vec<_> = paths
1383 .ordered_paths()
1384 .map(|p| p.compact().to_string_lossy().to_string())
1385 .collect();
1386 let tooltip_path: SharedString = ordered_paths.join("\n").into();
1387 let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1388
1389 let mut path_start_offset = 0;
1390 let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
1391 .ordered_paths()
1392 .map(|p| p.compact())
1393 .map(|path| {
1394 let highlighted_text =
1395 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1396 path_start_offset += highlighted_text.1.text.len();
1397 highlighted_text
1398 })
1399 .unzip();
1400
1401 let highlighted_match = HighlightedMatchWithPaths {
1402 prefix: None,
1403 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1404 paths: path_highlights,
1405 active: is_active,
1406 };
1407
1408 let project_group_key = key.clone();
1409 let secondary_actions = h_flex()
1410 .gap_1()
1411 .when(!is_active, |this| {
1412 this.child(
1413 IconButton::new("remove_open_project", IconName::Close)
1414 .icon_size(IconSize::Small)
1415 .tooltip(Tooltip::text("Remove Project from Window"))
1416 .on_click({
1417 let project_group_key = project_group_key.clone();
1418 cx.listener(move |picker, _, window, cx| {
1419 cx.stop_propagation();
1420 window.prevent_default();
1421 picker.delegate.remove_project_group(
1422 project_group_key.clone(),
1423 window,
1424 cx,
1425 );
1426 let query = picker.query(cx);
1427 picker.update_matches(query, window, cx);
1428 })
1429 }),
1430 )
1431 })
1432 .into_any_element();
1433
1434 Some(
1435 ListItem::new(ix)
1436 .toggle_state(selected)
1437 .inset(true)
1438 .spacing(ListItemSpacing::Sparse)
1439 .child(
1440 h_flex()
1441 .id("open_project_info_container")
1442 .gap_2p5()
1443 .when(self.has_any_non_local_projects, |this| {
1444 this.child(Icon::new(icon).color(Color::Muted))
1445 })
1446 .child({
1447 let mut highlighted = highlighted_match;
1448 if !self.render_paths {
1449 highlighted.paths.clear();
1450 }
1451 highlighted.render(window, cx)
1452 })
1453 .tooltip(Tooltip::text(tooltip_path)),
1454 )
1455 .end_slot(secondary_actions)
1456 .show_end_slot_on_hover()
1457 .into_any_element(),
1458 )
1459 }
1460 ProjectPickerEntry::RecentProject(hit) => {
1461 let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1462 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1463 let paths_to_add = paths.paths().to_vec();
1464 let ordered_paths: Vec<_> = paths
1465 .ordered_paths()
1466 .map(|p| p.compact().to_string_lossy().to_string())
1467 .collect();
1468 let tooltip_path: SharedString = match &location {
1469 SerializedWorkspaceLocation::Remote(options) => {
1470 let host = options.display_name();
1471 if ordered_paths.len() == 1 {
1472 format!("{} ({})", ordered_paths[0], host).into()
1473 } else {
1474 format!("{}\n({})", ordered_paths.join("\n"), host).into()
1475 }
1476 }
1477 _ => ordered_paths.join("\n").into(),
1478 };
1479
1480 let mut path_start_offset = 0;
1481 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1482 .ordered_paths()
1483 .map(|p| p.compact())
1484 .map(|path| {
1485 let highlighted_text =
1486 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1487 path_start_offset += highlighted_text.1.text.len();
1488 highlighted_text
1489 })
1490 .unzip();
1491
1492 let tooltip_title = if paths.len() > 1 {
1493 "Add Folders to this Project"
1494 } else {
1495 "Add Folder to this Project"
1496 };
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 active: false,
1510 };
1511
1512 let focus_handle = self.focus_handle.clone();
1513
1514 let secondary_actions = h_flex()
1515 .gap_px()
1516 .when(is_local, |this| {
1517 this.child(
1518 IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1519 .icon_size(IconSize::Small)
1520 .tooltip(move |_, cx| {
1521 Tooltip::with_meta(
1522 tooltip_title,
1523 None,
1524 "As a multi-root folder",
1525 cx,
1526 )
1527 })
1528 .on_click({
1529 let paths_to_add = paths_to_add.clone();
1530 cx.listener(move |picker, _event, window, cx| {
1531 cx.stop_propagation();
1532 window.prevent_default();
1533 picker.delegate.add_paths_to_project(
1534 paths_to_add.clone(),
1535 window,
1536 cx,
1537 );
1538 })
1539 }),
1540 )
1541 })
1542 .child(
1543 IconButton::new("open_new_window", IconName::OpenNewWindow)
1544 .icon_size(IconSize::Small)
1545 .tooltip({
1546 move |_, cx| {
1547 Tooltip::for_action_in(
1548 "Open Project in New Window",
1549 &menu::SecondaryConfirm,
1550 &focus_handle,
1551 cx,
1552 )
1553 }
1554 })
1555 .on_click(cx.listener(move |this, _event, window, cx| {
1556 cx.stop_propagation();
1557 window.prevent_default();
1558 this.delegate.set_selected_index(ix, window, cx);
1559 this.delegate.confirm(true, window, cx);
1560 })),
1561 )
1562 .child(
1563 IconButton::new("delete", IconName::Close)
1564 .icon_size(IconSize::Small)
1565 .tooltip(Tooltip::text("Delete from Recent Projects"))
1566 .on_click(cx.listener(move |this, _event, window, cx| {
1567 cx.stop_propagation();
1568 window.prevent_default();
1569 this.delegate.delete_recent_project(ix, window, cx)
1570 })),
1571 )
1572 .into_any_element();
1573
1574 let icon = icon_for_remote_connection(match location {
1575 SerializedWorkspaceLocation::Local => None,
1576 SerializedWorkspaceLocation::Remote(options) => Some(options),
1577 });
1578
1579 Some(
1580 ListItem::new(ix)
1581 .toggle_state(selected)
1582 .inset(true)
1583 .spacing(ListItemSpacing::Sparse)
1584 .child(
1585 h_flex()
1586 .id("project_info_container")
1587 .gap_2p5()
1588 .flex_grow()
1589 .when(self.has_any_non_local_projects, |this| {
1590 this.child(Icon::new(icon).color(Color::Muted))
1591 })
1592 .child({
1593 let mut highlighted = highlighted_match;
1594 if !self.render_paths {
1595 highlighted.paths.clear();
1596 }
1597 highlighted.render(window, cx)
1598 })
1599 .tooltip(move |_, cx| {
1600 Tooltip::with_meta(
1601 "Open Project in This Window",
1602 None,
1603 tooltip_path.clone(),
1604 cx,
1605 )
1606 }),
1607 )
1608 .end_slot(secondary_actions)
1609 .show_end_slot_on_hover()
1610 .into_any_element(),
1611 )
1612 }
1613 }
1614 }
1615
1616 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1617 let focus_handle = self.focus_handle.clone();
1618 let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1619 let is_already_open_entry = matches!(
1620 self.filtered_entries.get(self.selected_index),
1621 Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
1622 );
1623
1624 if popover_style {
1625 return Some(
1626 v_flex()
1627 .flex_1()
1628 .p_1p5()
1629 .gap_1()
1630 .border_t_1()
1631 .border_color(cx.theme().colors().border_variant)
1632 .child({
1633 ButtonLike::new("open_local_folder")
1634 .child(
1635 h_flex()
1636 .w_full()
1637 .gap_1()
1638 .justify_between()
1639 .child(Label::new("Open Local Folders"))
1640 .child(KeyBinding::for_action_in(
1641 &workspace::Open {
1642 create_new_window: self.create_new_window,
1643 },
1644 &focus_handle,
1645 cx,
1646 )),
1647 )
1648 .on_click({
1649 let workspace = self.workspace.clone();
1650 let create_new_window = self.create_new_window;
1651 move |_, window, cx| {
1652 open_local_project(
1653 workspace.clone(),
1654 create_new_window,
1655 window,
1656 cx,
1657 );
1658 }
1659 })
1660 })
1661 .child(
1662 ButtonLike::new("open_remote_folder")
1663 .child(
1664 h_flex()
1665 .w_full()
1666 .gap_1()
1667 .justify_between()
1668 .child(Label::new("Open Remote Folder"))
1669 .child(KeyBinding::for_action(
1670 &OpenRemote {
1671 from_existing_connection: false,
1672 create_new_window: false,
1673 },
1674 cx,
1675 )),
1676 )
1677 .on_click(|_, window, cx| {
1678 window.dispatch_action(
1679 OpenRemote {
1680 from_existing_connection: false,
1681 create_new_window: false,
1682 }
1683 .boxed_clone(),
1684 cx,
1685 )
1686 }),
1687 )
1688 .into_any(),
1689 );
1690 }
1691
1692 let selected_entry = self.filtered_entries.get(self.selected_index);
1693
1694 let is_current_workspace_entry =
1695 if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
1696 self.window_project_groups
1697 .get(hit.candidate_id)
1698 .is_some_and(|key| self.is_active_project_group(key, cx))
1699 } else {
1700 false
1701 };
1702
1703 let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1704 Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1705 Button::new("remove_selected", "Remove Folder")
1706 .key_binding(KeyBinding::for_action_in(
1707 &RemoveSelected,
1708 &focus_handle,
1709 cx,
1710 ))
1711 .on_click(|_, window, cx| {
1712 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1713 })
1714 .into_any_element(),
1715 ),
1716 Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
1717 Button::new("remove_selected", "Remove from Window")
1718 .key_binding(KeyBinding::for_action_in(
1719 &RemoveSelected,
1720 &focus_handle,
1721 cx,
1722 ))
1723 .on_click(|_, window, cx| {
1724 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1725 })
1726 .into_any_element(),
1727 ),
1728 Some(ProjectPickerEntry::RecentProject(_)) => Some(
1729 Button::new("delete_recent", "Delete")
1730 .key_binding(KeyBinding::for_action_in(
1731 &RemoveSelected,
1732 &focus_handle,
1733 cx,
1734 ))
1735 .on_click(|_, window, cx| {
1736 window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1737 })
1738 .into_any_element(),
1739 ),
1740 _ => None,
1741 };
1742
1743 Some(
1744 h_flex()
1745 .flex_1()
1746 .p_1p5()
1747 .gap_1()
1748 .justify_end()
1749 .border_t_1()
1750 .border_color(cx.theme().colors().border_variant)
1751 .when_some(secondary_footer_actions, |this, actions| {
1752 this.child(actions)
1753 })
1754 .map(|this| {
1755 if is_already_open_entry {
1756 this.child(
1757 Button::new("activate", "Activate")
1758 .key_binding(KeyBinding::for_action_in(
1759 &menu::Confirm,
1760 &focus_handle,
1761 cx,
1762 ))
1763 .on_click(|_, window, cx| {
1764 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1765 }),
1766 )
1767 } else {
1768 this.child(
1769 Button::new("open_new_window", "New Window")
1770 .key_binding(KeyBinding::for_action_in(
1771 &menu::SecondaryConfirm,
1772 &focus_handle,
1773 cx,
1774 ))
1775 .on_click(|_, window, cx| {
1776 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1777 }),
1778 )
1779 .child(
1780 Button::new("open_here", "Open")
1781 .key_binding(KeyBinding::for_action_in(
1782 &menu::Confirm,
1783 &focus_handle,
1784 cx,
1785 ))
1786 .on_click(|_, window, cx| {
1787 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1788 }),
1789 )
1790 }
1791 })
1792 .child(Divider::vertical())
1793 .child(
1794 PopoverMenu::new("actions-menu-popover")
1795 .with_handle(self.actions_menu_handle.clone())
1796 .anchor(gpui::Anchor::BottomRight)
1797 .offset(gpui::Point {
1798 x: px(0.0),
1799 y: px(-2.0),
1800 })
1801 .trigger(
1802 Button::new("actions-trigger", "Actions")
1803 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1804 .key_binding(KeyBinding::for_action_in(
1805 &ToggleActionsMenu,
1806 &focus_handle,
1807 cx,
1808 )),
1809 )
1810 .menu({
1811 let focus_handle = focus_handle.clone();
1812 let workspace_handle = self.workspace.clone();
1813 let create_new_window = self.create_new_window;
1814 let open_action = workspace::Open { create_new_window };
1815 let show_add_to_workspace = match selected_entry {
1816 Some(ProjectPickerEntry::RecentProject(hit)) => self
1817 .workspaces
1818 .get(hit.candidate_id)
1819 .map(|(_, loc, ..)| {
1820 matches!(loc, SerializedWorkspaceLocation::Local)
1821 })
1822 .unwrap_or(false),
1823 _ => false,
1824 };
1825
1826 move |window, cx| {
1827 Some(ContextMenu::build(window, cx, {
1828 let focus_handle = focus_handle.clone();
1829 let workspace_handle = workspace_handle.clone();
1830 let open_action = open_action.clone();
1831 move |menu, _, _| {
1832 menu.context(focus_handle)
1833 .when(show_add_to_workspace, |menu| {
1834 menu.action(
1835 "Add Folder to this Project",
1836 AddToWorkspace.boxed_clone(),
1837 )
1838 .separator()
1839 })
1840 .entry(
1841 "Open Local Folders",
1842 Some(open_action.boxed_clone()),
1843 {
1844 let workspace_handle = workspace_handle.clone();
1845 move |window, cx| {
1846 open_local_project(
1847 workspace_handle.clone(),
1848 create_new_window,
1849 window,
1850 cx,
1851 );
1852 }
1853 },
1854 )
1855 .action(
1856 "Open Remote Folder",
1857 OpenRemote {
1858 from_existing_connection: false,
1859 create_new_window: false,
1860 }
1861 .boxed_clone(),
1862 )
1863 }
1864 }))
1865 }
1866 }),
1867 )
1868 .into_any(),
1869 )
1870 }
1871}
1872
1873pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1874 match options {
1875 None => IconName::Screen,
1876 Some(options) => match options {
1877 RemoteConnectionOptions::Ssh(_) => IconName::Server,
1878 RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1879 RemoteConnectionOptions::Docker(_) => IconName::Box,
1880 #[cfg(any(test, feature = "test-support"))]
1881 RemoteConnectionOptions::Mock(_) => IconName::Server,
1882 },
1883 }
1884}
1885
1886// Compute the highlighted text for the name and path
1887pub(crate) fn highlights_for_path(
1888 path: &Path,
1889 match_positions: &Vec<usize>,
1890 path_start_offset: usize,
1891) -> (Option<HighlightedMatch>, HighlightedMatch) {
1892 let path_string = path.to_string_lossy();
1893 let path_text = path_string.to_string();
1894 let path_byte_len = path_text.len();
1895 // Get the subset of match highlight positions that line up with the given path.
1896 // Also adjusts them to start at the path start
1897 let path_positions = match_positions
1898 .iter()
1899 .copied()
1900 .skip_while(|position| *position < path_start_offset)
1901 .take_while(|position| *position < path_start_offset + path_byte_len)
1902 .map(|position| position - path_start_offset)
1903 .collect::<Vec<_>>();
1904
1905 // Again subset the highlight positions to just those that line up with the file_name
1906 // again adjusted to the start of the file_name
1907 let file_name_text_and_positions = path.file_name().map(|file_name| {
1908 let file_name_text = file_name.to_string_lossy().into_owned();
1909 let file_name_start_byte = path_byte_len - file_name_text.len();
1910 let highlight_positions = path_positions
1911 .iter()
1912 .copied()
1913 .skip_while(|position| *position < file_name_start_byte)
1914 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1915 .map(|position| position - file_name_start_byte)
1916 .collect::<Vec<_>>();
1917 HighlightedMatch {
1918 text: file_name_text,
1919 highlight_positions,
1920 color: Color::Default,
1921 }
1922 });
1923
1924 (
1925 file_name_text_and_positions,
1926 HighlightedMatch {
1927 text: path_text,
1928 highlight_positions: path_positions,
1929 color: Color::Default,
1930 },
1931 )
1932}
1933fn open_local_project(
1934 workspace: WeakEntity<Workspace>,
1935 create_new_window: bool,
1936 window: &mut Window,
1937 cx: &mut App,
1938) {
1939 use gpui::PathPromptOptions;
1940 use project::DirectoryLister;
1941
1942 let Some(workspace) = workspace.upgrade() else {
1943 return;
1944 };
1945
1946 let paths = workspace.update(cx, |workspace, cx| {
1947 workspace.prompt_for_open_path(
1948 PathPromptOptions {
1949 files: true,
1950 directories: true,
1951 multiple: true,
1952 prompt: None,
1953 },
1954 DirectoryLister::Local(
1955 workspace.project().clone(),
1956 workspace.app_state().fs.clone(),
1957 ),
1958 window,
1959 cx,
1960 )
1961 });
1962
1963 let multi_workspace_handle = window.window_handle().downcast::<MultiWorkspace>();
1964 window
1965 .spawn(cx, async move |cx| {
1966 let Some(paths) = paths.await.log_err().flatten() else {
1967 return;
1968 };
1969 if !create_new_window {
1970 if let Some(handle) = multi_workspace_handle {
1971 if let Some(task) = handle
1972 .update(cx, |multi_workspace, window, cx| {
1973 multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
1974 })
1975 .log_err()
1976 {
1977 task.await.log_err();
1978 }
1979 return;
1980 }
1981 }
1982 if let Some(task) = workspace
1983 .update_in(cx, |workspace, window, cx| {
1984 workspace.open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx)
1985 })
1986 .log_err()
1987 {
1988 task.await.log_err();
1989 }
1990 })
1991 .detach();
1992}
1993
1994impl RecentProjectsDelegate {
1995 fn add_paths_to_project(
1996 &mut self,
1997 paths: Vec<PathBuf>,
1998 window: &mut Window,
1999 cx: &mut Context<Picker<Self>>,
2000 ) {
2001 let Some(workspace) = self.workspace.upgrade() else {
2002 return;
2003 };
2004 let open_paths_task = workspace.update(cx, |workspace, cx| {
2005 workspace.open_paths(
2006 paths,
2007 OpenOptions {
2008 visible: Some(OpenVisible::All),
2009 ..Default::default()
2010 },
2011 None,
2012 window,
2013 cx,
2014 )
2015 });
2016 cx.spawn_in(window, async move |picker, cx| {
2017 let _result = open_paths_task.await;
2018 picker
2019 .update_in(cx, |picker, window, cx| {
2020 let Some(workspace) = picker.delegate.workspace.upgrade() else {
2021 return;
2022 };
2023 picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
2024 let query = picker.query(cx);
2025 picker.update_matches(query, window, cx);
2026 })
2027 .ok();
2028 })
2029 .detach();
2030 }
2031
2032 fn delete_recent_project(
2033 &self,
2034 ix: usize,
2035 window: &mut Window,
2036 cx: &mut Context<Picker<Self>>,
2037 ) {
2038 if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
2039 self.filtered_entries.get(ix)
2040 {
2041 let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
2042 let workspace_id = *workspace_id;
2043 let fs = self
2044 .workspace
2045 .upgrade()
2046 .map(|ws| ws.read(cx).app_state().fs.clone());
2047 let db = WorkspaceDb::global(cx);
2048 cx.spawn_in(window, async move |this, cx| {
2049 db.delete_workspace_by_id(workspace_id).await.log_err();
2050 let Some(fs) = fs else { return };
2051 let workspaces = db
2052 .recent_project_workspaces(fs.as_ref())
2053 .await
2054 .unwrap_or_default();
2055 let workspaces =
2056 workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
2057 this.update_in(cx, move |picker, window, cx| {
2058 picker.delegate.set_workspaces(workspaces);
2059 picker
2060 .delegate
2061 .set_selected_index(ix.saturating_sub(1), window, cx);
2062 picker.delegate.reset_selected_match_index = false;
2063 picker.update_matches(picker.query(cx), window, cx);
2064 // After deleting a project, we want to update the history manager to reflect the change.
2065 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
2066 if let Some(history_manager) = HistoryManager::global(cx) {
2067 history_manager
2068 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
2069 }
2070 })
2071 .ok();
2072 })
2073 .detach();
2074 }
2075 }
2076
2077 fn remove_project_group(
2078 &mut self,
2079 key: ProjectGroupKey,
2080 window: &mut Window,
2081 cx: &mut Context<Picker<Self>>,
2082 ) {
2083 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
2084 let key_for_remove = key.clone();
2085 cx.defer(move |cx| {
2086 handle
2087 .update(cx, |multi_workspace, window, cx| {
2088 multi_workspace
2089 .remove_project_group(&key_for_remove, window, cx)
2090 .detach_and_log_err(cx);
2091 })
2092 .log_err();
2093 });
2094 }
2095
2096 self.window_project_groups.retain(|k| k != &key);
2097 }
2098
2099 fn is_current_workspace(
2100 &self,
2101 workspace_id: WorkspaceId,
2102 cx: &mut Context<Picker<Self>>,
2103 ) -> bool {
2104 if let Some(workspace) = self.workspace.upgrade() {
2105 let workspace = workspace.read(cx);
2106 if Some(workspace_id) == workspace.database_id() {
2107 return true;
2108 }
2109 }
2110
2111 false
2112 }
2113
2114 fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
2115 if let Some(workspace) = self.workspace.upgrade() {
2116 return workspace.read(cx).project_group_key(cx) == *key;
2117 }
2118 false
2119 }
2120
2121 fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
2122 self.window_project_groups
2123 .iter()
2124 .any(|key| key.path_list() == paths)
2125 }
2126
2127 fn is_open_folder(&self, paths: &PathList) -> bool {
2128 if self.open_folders.is_empty() {
2129 return false;
2130 }
2131
2132 for workspace_path in paths.paths() {
2133 for open_folder in &self.open_folders {
2134 if workspace_path == &open_folder.path {
2135 return true;
2136 }
2137 }
2138 }
2139
2140 false
2141 }
2142
2143 fn is_valid_recent_candidate(
2144 &self,
2145 workspace_id: WorkspaceId,
2146 paths: &PathList,
2147 cx: &mut Context<Picker<Self>>,
2148 ) -> bool {
2149 !self.is_current_workspace(workspace_id, cx)
2150 && !self.is_in_current_window_groups(paths)
2151 && !self.is_open_folder(paths)
2152 }
2153}
2154
2155#[cfg(test)]
2156mod tests {
2157 use gpui::{TestAppContext, UpdateGlobal};
2158
2159 use serde_json::json;
2160 use settings::SettingsStore;
2161 use util::path;
2162 use workspace::{AppState, open_paths};
2163
2164 use super::*;
2165
2166 #[gpui::test]
2167 async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2168 let app_state = init_test(cx);
2169
2170 app_state
2171 .fs
2172 .as_fake()
2173 .insert_tree(
2174 path!("/project"),
2175 json!({
2176 ".devcontainer": {
2177 "devcontainer.json": "{}"
2178 },
2179 "src": {
2180 "main.rs": "fn main() {}"
2181 }
2182 }),
2183 )
2184 .await;
2185
2186 // Open a file path (not a directory) so that the worktree root is a
2187 // file. This means `active_project_directory` returns `None`, which
2188 // causes `DevContainerContext::from_workspace` to return `None`,
2189 // preventing `open_dev_container` from spawning real I/O (docker
2190 // commands, shell environment loading) that is incompatible with the
2191 // test scheduler. The modal is still created and the re-entrancy
2192 // guard that this test validates is still exercised.
2193 cx.update(|cx| {
2194 open_paths(
2195 &[PathBuf::from(path!("/project/src/main.rs"))],
2196 app_state,
2197 workspace::OpenOptions::default(),
2198 cx,
2199 )
2200 })
2201 .await
2202 .unwrap();
2203
2204 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2205 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2206
2207 cx.run_until_parked();
2208
2209 // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2210 // -> Workspace::update -> toggle_modal -> new_dev_container.
2211 // Before the fix, this panicked with "cannot read workspace::Workspace while
2212 // it is already being updated" because new_dev_container and open_dev_container
2213 // tried to read the Workspace entity through a WeakEntity handle while it was
2214 // already leased by the outer update.
2215 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2216
2217 multi_workspace
2218 .update(cx, |multi_workspace, _, cx| {
2219 let modal = multi_workspace
2220 .workspace()
2221 .read(cx)
2222 .active_modal::<RemoteServerProjects>(cx);
2223 assert!(
2224 modal.is_some(),
2225 "Dev container modal should be open after dispatching OpenDevContainer"
2226 );
2227 })
2228 .unwrap();
2229 }
2230
2231 #[gpui::test]
2232 async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2233 let app_state = init_test(cx);
2234
2235 app_state
2236 .fs
2237 .as_fake()
2238 .insert_tree(
2239 path!("/project"),
2240 json!({
2241 ".devcontainer": {
2242 "rust": {
2243 "devcontainer.json": "{}"
2244 },
2245 "python": {
2246 "devcontainer.json": "{}"
2247 }
2248 },
2249 "src": {
2250 "main.rs": "fn main() {}"
2251 }
2252 }),
2253 )
2254 .await;
2255
2256 cx.update(|cx| {
2257 open_paths(
2258 &[PathBuf::from(path!("/project"))],
2259 app_state,
2260 workspace::OpenOptions::default(),
2261 cx,
2262 )
2263 })
2264 .await
2265 .unwrap();
2266
2267 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2268 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2269
2270 cx.run_until_parked();
2271
2272 cx.dispatch_action(*multi_workspace, OpenDevContainer);
2273
2274 multi_workspace
2275 .update(cx, |multi_workspace, _, cx| {
2276 let modal = multi_workspace
2277 .workspace()
2278 .read(cx)
2279 .active_modal::<RemoteServerProjects>(cx);
2280 assert!(
2281 modal.is_some(),
2282 "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2283 );
2284 })
2285 .unwrap();
2286 }
2287
2288 #[gpui::test]
2289 async fn test_open_local_project_reuses_multi_workspace_window(cx: &mut TestAppContext) {
2290 let app_state = init_test(cx);
2291
2292 // Disable system path prompts so the injected mock is used.
2293 cx.update(|cx| {
2294 SettingsStore::update_global(cx, |store, cx| {
2295 store.update_user_settings(cx, |settings| {
2296 settings.workspace.use_system_path_prompts = Some(false);
2297 });
2298 });
2299 });
2300
2301 app_state
2302 .fs
2303 .as_fake()
2304 .insert_tree(
2305 path!("/initial-project"),
2306 json!({ "src": { "main.rs": "" } }),
2307 )
2308 .await;
2309 app_state
2310 .fs
2311 .as_fake()
2312 .insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
2313 .await;
2314
2315 cx.update(|cx| {
2316 open_paths(
2317 &[PathBuf::from(path!("/initial-project"))],
2318 app_state.clone(),
2319 workspace::OpenOptions::default(),
2320 cx,
2321 )
2322 })
2323 .await
2324 .unwrap();
2325
2326 let initial_window_count = cx.update(|cx| cx.windows().len());
2327 assert_eq!(initial_window_count, 1);
2328
2329 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2330 cx.run_until_parked();
2331
2332 let workspace = multi_workspace
2333 .read_with(cx, |mw, _| mw.workspace().clone())
2334 .unwrap();
2335
2336 // Set up the prompt mock to return the new project path.
2337 workspace.update(cx, |workspace, _cx| {
2338 workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
2339 let (tx, rx) = futures::channel::oneshot::channel();
2340 tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
2341 .ok();
2342 rx
2343 }));
2344 });
2345
2346 // Call open_local_project with create_new_window: false.
2347 let weak_workspace = workspace.downgrade();
2348 multi_workspace
2349 .update(cx, |_, window, cx| {
2350 open_local_project(weak_workspace, false, window, cx);
2351 })
2352 .unwrap();
2353
2354 cx.run_until_parked();
2355
2356 // Should NOT have opened a new window.
2357 let final_window_count = cx.update(|cx| cx.windows().len());
2358 assert_eq!(
2359 final_window_count, initial_window_count,
2360 "open_local_project with create_new_window=false should reuse the current multi-workspace window"
2361 );
2362 }
2363
2364 #[gpui::test]
2365 async fn test_open_local_project_new_window_creates_new_window(cx: &mut TestAppContext) {
2366 let app_state = init_test(cx);
2367
2368 // Disable system path prompts so the injected mock is used.
2369 cx.update(|cx| {
2370 SettingsStore::update_global(cx, |store, cx| {
2371 store.update_user_settings(cx, |settings| {
2372 settings.workspace.use_system_path_prompts = Some(false);
2373 });
2374 });
2375 });
2376
2377 app_state
2378 .fs
2379 .as_fake()
2380 .insert_tree(
2381 path!("/initial-project"),
2382 json!({ "src": { "main.rs": "" } }),
2383 )
2384 .await;
2385 app_state
2386 .fs
2387 .as_fake()
2388 .insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
2389 .await;
2390
2391 cx.update(|cx| {
2392 open_paths(
2393 &[PathBuf::from(path!("/initial-project"))],
2394 app_state.clone(),
2395 workspace::OpenOptions::default(),
2396 cx,
2397 )
2398 })
2399 .await
2400 .unwrap();
2401
2402 let initial_window_count = cx.update(|cx| cx.windows().len());
2403 assert_eq!(initial_window_count, 1);
2404
2405 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2406 cx.run_until_parked();
2407
2408 let workspace = multi_workspace
2409 .read_with(cx, |mw, _| mw.workspace().clone())
2410 .unwrap();
2411
2412 // Set up the prompt mock to return the new project path.
2413 workspace.update(cx, |workspace, _cx| {
2414 workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
2415 let (tx, rx) = futures::channel::oneshot::channel();
2416 tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
2417 .ok();
2418 rx
2419 }));
2420 });
2421
2422 // Call open_local_project with create_new_window: true.
2423 let weak_workspace = workspace.downgrade();
2424 multi_workspace
2425 .update(cx, |_, window, cx| {
2426 open_local_project(weak_workspace, true, window, cx);
2427 })
2428 .unwrap();
2429
2430 cx.run_until_parked();
2431
2432 // Should have opened a new window.
2433 let final_window_count = cx.update(|cx| cx.windows().len());
2434 assert_eq!(
2435 final_window_count,
2436 initial_window_count + 1,
2437 "open_local_project with create_new_window=true should open a new window"
2438 );
2439 }
2440
2441 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2442 cx.update(|cx| {
2443 let state = AppState::test(cx);
2444 crate::init(cx);
2445 editor::init(cx);
2446 state
2447 })
2448 }
2449}