1mod dev_container_suggest;
2pub mod disconnected_overlay;
3mod remote_connections;
4mod remote_servers;
5mod ssh_config;
6
7use std::{path::PathBuf, sync::Arc};
8
9use fs::Fs;
10
11#[cfg(target_os = "windows")]
12mod wsl_picker;
13
14use remote::RemoteConnectionOptions;
15pub use remote_connection::{RemoteConnectionModal, connect};
16pub use remote_connections::open_remote_project;
17
18use disconnected_overlay::DisconnectedOverlay;
19use fuzzy::{StringMatch, StringMatchCandidate};
20use gpui::{
21 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
22 Subscription, Task, WeakEntity, Window,
23};
24use ordered_float::OrderedFloat;
25use picker::{
26 Picker, PickerDelegate,
27 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
28};
29pub use remote_connections::RemoteSettings;
30pub use remote_servers::RemoteServerProjects;
31use settings::Settings;
32use std::path::Path;
33use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
34use util::{ResultExt, paths::PathExt};
35use workspace::{
36 HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation,
37 WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
38 with_active_or_new_workspace,
39};
40use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
41
42#[derive(Clone, Debug)]
43pub struct RecentProjectEntry {
44 pub name: SharedString,
45 pub full_path: SharedString,
46 pub paths: Vec<PathBuf>,
47 pub workspace_id: WorkspaceId,
48}
49
50pub async fn get_recent_projects(
51 current_workspace_id: Option<WorkspaceId>,
52 limit: Option<usize>,
53 fs: Arc<dyn fs::Fs>,
54) -> Vec<RecentProjectEntry> {
55 let workspaces = WORKSPACE_DB
56 .recent_workspaces_on_disk(fs.as_ref())
57 .await
58 .unwrap_or_default();
59
60 let entries: Vec<RecentProjectEntry> = workspaces
61 .into_iter()
62 .filter(|(id, _, _)| Some(*id) != current_workspace_id)
63 .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
64 .map(|(workspace_id, _, path_list)| {
65 let paths: Vec<PathBuf> = path_list.paths().to_vec();
66 let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
67
68 let name = if ordered_paths.len() == 1 {
69 ordered_paths[0]
70 .file_name()
71 .map(|n| n.to_string_lossy().to_string())
72 .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
73 } else {
74 ordered_paths
75 .iter()
76 .filter_map(|p| p.file_name())
77 .map(|n| n.to_string_lossy().to_string())
78 .collect::<Vec<_>>()
79 .join(", ")
80 };
81
82 let full_path = ordered_paths
83 .iter()
84 .map(|p| p.to_string_lossy().to_string())
85 .collect::<Vec<_>>()
86 .join("\n");
87
88 RecentProjectEntry {
89 name: SharedString::from(name),
90 full_path: SharedString::from(full_path),
91 paths,
92 workspace_id,
93 }
94 })
95 .collect();
96
97 match limit {
98 Some(n) => entries.into_iter().take(n).collect(),
99 None => entries,
100 }
101}
102
103pub async fn delete_recent_project(workspace_id: WorkspaceId) {
104 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
105}
106
107pub fn init(cx: &mut App) {
108 #[cfg(target_os = "windows")]
109 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
110 let create_new_window = open_wsl.create_new_window;
111 with_active_or_new_workspace(cx, move |workspace, window, cx| {
112 use gpui::PathPromptOptions;
113 use project::DirectoryLister;
114
115 let paths = workspace.prompt_for_open_path(
116 PathPromptOptions {
117 files: true,
118 directories: true,
119 multiple: false,
120 prompt: None,
121 },
122 DirectoryLister::Local(
123 workspace.project().clone(),
124 workspace.app_state().fs.clone(),
125 ),
126 window,
127 cx,
128 );
129
130 cx.spawn_in(window, async move |workspace, cx| {
131 use util::paths::SanitizedPath;
132
133 let Some(paths) = paths.await.log_err().flatten() else {
134 return;
135 };
136
137 let paths = paths
138 .into_iter()
139 .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
140 .collect::<Vec<_>>();
141
142 if paths.is_empty() {
143 let message = indoc::indoc! { r#"
144 Invalid path specified when trying to open a folder inside WSL.
145
146 Please note that Zed currently does not support opening network share folders inside wsl.
147 "#};
148
149 let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
150 return;
151 }
152
153 workspace.update_in(cx, |workspace, window, cx| {
154 workspace.toggle_modal(window, cx, |window, cx| {
155 crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
156 });
157 }).log_err();
158 })
159 .detach();
160 });
161 });
162
163 #[cfg(target_os = "windows")]
164 cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
165 let create_new_window = open_wsl.create_new_window;
166 with_active_or_new_workspace(cx, move |workspace, window, cx| {
167 let handle = cx.entity().downgrade();
168 let fs = workspace.project().read(cx).fs().clone();
169 workspace.toggle_modal(window, cx, |window, cx| {
170 RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
171 });
172 });
173 });
174
175 #[cfg(target_os = "windows")]
176 cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
177 let open_wsl = open_wsl.clone();
178 with_active_or_new_workspace(cx, move |workspace, window, cx| {
179 let fs = workspace.project().read(cx).fs().clone();
180 add_wsl_distro(fs, &open_wsl.distro, cx);
181 let open_options = OpenOptions {
182 replace_window: window.window_handle().downcast::<MultiWorkspace>(),
183 ..Default::default()
184 };
185
186 let app_state = workspace.app_state().clone();
187
188 cx.spawn_in(window, async move |_, cx| {
189 open_remote_project(
190 RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
191 open_wsl.paths,
192 app_state,
193 open_options,
194 cx,
195 )
196 .await
197 })
198 .detach();
199 });
200 });
201
202 cx.on_action(|open_recent: &OpenRecent, cx| {
203 let create_new_window = open_recent.create_new_window;
204 with_active_or_new_workspace(cx, move |workspace, window, cx| {
205 let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
206 let focus_handle = workspace.focus_handle(cx);
207 RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
208 return;
209 };
210
211 recent_projects.update(cx, |recent_projects, cx| {
212 recent_projects
213 .picker
214 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
215 });
216 });
217 });
218 cx.on_action(|open_remote: &OpenRemote, cx| {
219 let from_existing_connection = open_remote.from_existing_connection;
220 let create_new_window = open_remote.create_new_window;
221 with_active_or_new_workspace(cx, move |workspace, window, cx| {
222 if from_existing_connection {
223 cx.propagate();
224 return;
225 }
226 let handle = cx.entity().downgrade();
227 let fs = workspace.project().read(cx).fs().clone();
228 workspace.toggle_modal(window, cx, |window, cx| {
229 RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
230 })
231 });
232 });
233
234 cx.observe_new(DisconnectedOverlay::register).detach();
235
236 cx.on_action(|_: &OpenDevContainer, cx| {
237 with_active_or_new_workspace(cx, move |workspace, window, cx| {
238 if !workspace.project().read(cx).is_local() {
239 cx.spawn_in(window, async move |_, cx| {
240 cx.prompt(
241 gpui::PromptLevel::Critical,
242 "Cannot open Dev Container from remote project",
243 None,
244 &["Ok"],
245 )
246 .await
247 .ok();
248 })
249 .detach();
250 return;
251 }
252
253 let fs = workspace.project().read(cx).fs().clone();
254 let handle = cx.entity().downgrade();
255 workspace.toggle_modal(window, cx, |window, cx| {
256 RemoteServerProjects::new_dev_container(fs, window, handle, cx)
257 });
258 });
259 });
260
261 // Subscribe to worktree additions to suggest opening the project in a dev container
262 cx.observe_new(
263 |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
264 let Some(window) = window else {
265 return;
266 };
267 cx.subscribe_in(
268 workspace.project(),
269 window,
270 move |_, project, event, window, cx| {
271 if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
272 event
273 {
274 dev_container_suggest::suggest_on_worktree_updated(
275 *worktree_id,
276 updated_entries,
277 project,
278 window,
279 cx,
280 );
281 }
282 },
283 )
284 .detach();
285 },
286 )
287 .detach();
288}
289
290#[cfg(target_os = "windows")]
291pub fn add_wsl_distro(
292 fs: Arc<dyn project::Fs>,
293 connection_options: &remote::WslConnectionOptions,
294 cx: &App,
295) {
296 use gpui::ReadGlobal;
297 use settings::SettingsStore;
298
299 let distro_name = connection_options.distro_name.clone();
300 let user = connection_options.user.clone();
301 SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
302 let connections = setting
303 .remote
304 .wsl_connections
305 .get_or_insert(Default::default());
306
307 if !connections
308 .iter()
309 .any(|conn| conn.distro_name == distro_name && conn.user == user)
310 {
311 use std::collections::BTreeSet;
312
313 connections.push(settings::WslConnection {
314 distro_name,
315 user,
316 projects: BTreeSet::new(),
317 })
318 }
319 });
320}
321
322pub struct RecentProjects {
323 pub picker: Entity<Picker<RecentProjectsDelegate>>,
324 rem_width: f32,
325 _subscription: Subscription,
326}
327
328impl ModalView for RecentProjects {}
329
330impl RecentProjects {
331 fn new(
332 delegate: RecentProjectsDelegate,
333 fs: Option<Arc<dyn Fs>>,
334 rem_width: f32,
335 window: &mut Window,
336 cx: &mut Context<Self>,
337 ) -> Self {
338 let picker = cx.new(|cx| {
339 // We want to use a list when we render paths, because the items can have different heights (multiple paths).
340 if delegate.render_paths {
341 Picker::list(delegate, window, cx)
342 } else {
343 Picker::uniform_list(delegate, window, cx)
344 }
345 });
346 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
347 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
348 // out workspace locations once the future runs to completion.
349 cx.spawn_in(window, async move |this, cx| {
350 let Some(fs) = fs else { return };
351 let workspaces = WORKSPACE_DB
352 .recent_workspaces_on_disk(fs.as_ref())
353 .await
354 .log_err()
355 .unwrap_or_default();
356 this.update_in(cx, move |this, window, cx| {
357 this.picker.update(cx, move |picker, cx| {
358 picker.delegate.set_workspaces(workspaces);
359 picker.update_matches(picker.query(cx), window, cx)
360 })
361 })
362 .ok();
363 })
364 .detach();
365 Self {
366 picker,
367 rem_width,
368 _subscription,
369 }
370 }
371
372 pub fn open(
373 workspace: &mut Workspace,
374 create_new_window: bool,
375 window: &mut Window,
376 focus_handle: FocusHandle,
377 cx: &mut Context<Workspace>,
378 ) {
379 let weak = cx.entity().downgrade();
380 let fs = Some(workspace.app_state().fs.clone());
381 workspace.toggle_modal(window, cx, |window, cx| {
382 let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle);
383
384 Self::new(delegate, fs, 34., window, cx)
385 })
386 }
387
388 pub fn popover(
389 workspace: WeakEntity<Workspace>,
390 create_new_window: bool,
391 focus_handle: FocusHandle,
392 window: &mut Window,
393 cx: &mut App,
394 ) -> Entity<Self> {
395 let fs = workspace
396 .upgrade()
397 .map(|ws| ws.read(cx).app_state().fs.clone());
398 cx.new(|cx| {
399 let delegate =
400 RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle);
401 let list = Self::new(delegate, fs, 34., window, cx);
402 list.picker.focus_handle(cx).focus(window, cx);
403 list
404 })
405 }
406}
407
408impl EventEmitter<DismissEvent> for RecentProjects {}
409
410impl Focusable for RecentProjects {
411 fn focus_handle(&self, cx: &App) -> FocusHandle {
412 self.picker.focus_handle(cx)
413 }
414}
415
416impl Render for RecentProjects {
417 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
418 v_flex()
419 .key_context("RecentProjects")
420 .w(rems(self.rem_width))
421 .child(self.picker.clone())
422 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
423 this.picker.update(cx, |this, cx| {
424 this.cancel(&Default::default(), window, cx);
425 })
426 }))
427 }
428}
429
430pub struct RecentProjectsDelegate {
431 workspace: WeakEntity<Workspace>,
432 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
433 selected_match_index: usize,
434 matches: Vec<StringMatch>,
435 render_paths: bool,
436 create_new_window: bool,
437 // Flag to reset index when there is a new query vs not reset index when user delete an item
438 reset_selected_match_index: bool,
439 has_any_non_local_projects: bool,
440 focus_handle: FocusHandle,
441}
442
443impl RecentProjectsDelegate {
444 fn new(
445 workspace: WeakEntity<Workspace>,
446 create_new_window: bool,
447 render_paths: bool,
448 focus_handle: FocusHandle,
449 ) -> Self {
450 Self {
451 workspace,
452 workspaces: Vec::new(),
453 selected_match_index: 0,
454 matches: Default::default(),
455 create_new_window,
456 render_paths,
457 reset_selected_match_index: true,
458 has_any_non_local_projects: false,
459 focus_handle,
460 }
461 }
462
463 pub fn set_workspaces(
464 &mut self,
465 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
466 ) {
467 self.workspaces = workspaces;
468 self.has_any_non_local_projects = !self
469 .workspaces
470 .iter()
471 .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
472 }
473}
474impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
475impl PickerDelegate for RecentProjectsDelegate {
476 type ListItem = ListItem;
477
478 fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
479 let (create_window, reuse_window) = if self.create_new_window {
480 (
481 window.keystroke_text_for(&menu::Confirm),
482 window.keystroke_text_for(&menu::SecondaryConfirm),
483 )
484 } else {
485 (
486 window.keystroke_text_for(&menu::SecondaryConfirm),
487 window.keystroke_text_for(&menu::Confirm),
488 )
489 };
490 Arc::from(format!(
491 "{reuse_window} reuses this window, {create_window} opens a new one",
492 ))
493 }
494
495 fn match_count(&self) -> usize {
496 self.matches.len()
497 }
498
499 fn selected_index(&self) -> usize {
500 self.selected_match_index
501 }
502
503 fn set_selected_index(
504 &mut self,
505 ix: usize,
506 _window: &mut Window,
507 _cx: &mut Context<Picker<Self>>,
508 ) {
509 self.selected_match_index = ix;
510 }
511
512 fn update_matches(
513 &mut self,
514 query: String,
515 _: &mut Window,
516 cx: &mut Context<Picker<Self>>,
517 ) -> gpui::Task<()> {
518 let query = query.trim_start();
519 let smart_case = query.chars().any(|c| c.is_uppercase());
520 let candidates = self
521 .workspaces
522 .iter()
523 .enumerate()
524 .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx))
525 .map(|(id, (_, _, paths))| {
526 let combined_string = paths
527 .ordered_paths()
528 .map(|path| path.compact().to_string_lossy().into_owned())
529 .collect::<Vec<_>>()
530 .join("");
531 StringMatchCandidate::new(id, &combined_string)
532 })
533 .collect::<Vec<_>>();
534 self.matches = smol::block_on(fuzzy::match_strings(
535 candidates.as_slice(),
536 query,
537 smart_case,
538 true,
539 100,
540 &Default::default(),
541 cx.background_executor().clone(),
542 ));
543 self.matches.sort_unstable_by(|a, b| {
544 b.score
545 .partial_cmp(&a.score) // Descending score
546 .unwrap_or(std::cmp::Ordering::Equal)
547 .then_with(|| a.candidate_id.cmp(&b.candidate_id)) // Ascending candidate_id for ties
548 });
549
550 if self.reset_selected_match_index {
551 self.selected_match_index = self
552 .matches
553 .iter()
554 .enumerate()
555 .rev()
556 .max_by_key(|(_, m)| OrderedFloat(m.score))
557 .map(|(ix, _)| ix)
558 .unwrap_or(0);
559 }
560 self.reset_selected_match_index = true;
561 Task::ready(())
562 }
563
564 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
565 if let Some((selected_match, workspace)) = self
566 .matches
567 .get(self.selected_index())
568 .zip(self.workspace.upgrade())
569 {
570 let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) =
571 &self.workspaces[selected_match.candidate_id];
572 let replace_current_window = if self.create_new_window {
573 secondary
574 } else {
575 !secondary
576 };
577 workspace.update(cx, |workspace, cx| {
578 if workspace.database_id() == Some(*candidate_workspace_id) {
579 return;
580 }
581 match candidate_workspace_location.clone() {
582 SerializedWorkspaceLocation::Local => {
583 let paths = candidate_workspace_paths.paths().to_vec();
584 if replace_current_window {
585 if let Some(handle) =
586 window.window_handle().downcast::<MultiWorkspace>()
587 {
588 cx.defer(move |cx| {
589 if let Some(task) = handle
590 .update(cx, |multi_workspace, window, cx| {
591 multi_workspace.open_project(paths, window, cx)
592 })
593 .log_err()
594 {
595 task.detach_and_log_err(cx);
596 }
597 });
598 }
599 return;
600 } else {
601 workspace.open_workspace_for_paths(false, paths, window, cx)
602 }
603 }
604 SerializedWorkspaceLocation::Remote(mut connection) => {
605 let app_state = workspace.app_state().clone();
606
607 let replace_window = if replace_current_window {
608 window.window_handle().downcast::<MultiWorkspace>()
609 } else {
610 None
611 };
612
613 let open_options = OpenOptions {
614 replace_window,
615 ..Default::default()
616 };
617
618 if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
619 RemoteSettings::get_global(cx)
620 .fill_connection_options_from_settings(connection);
621 };
622
623 let paths = candidate_workspace_paths.paths().to_vec();
624
625 cx.spawn_in(window, async move |_, cx| {
626 open_remote_project(
627 connection.clone(),
628 paths,
629 app_state,
630 open_options,
631 cx,
632 )
633 .await
634 })
635 }
636 }
637 .detach_and_prompt_err(
638 "Failed to open project",
639 window,
640 cx,
641 |_, _, _| None,
642 );
643 });
644 cx.emit(DismissEvent);
645 }
646 }
647
648 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
649
650 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
651 let text = if self.workspaces.is_empty() {
652 "Recently opened projects will show up here".into()
653 } else {
654 "No matches".into()
655 };
656 Some(text)
657 }
658
659 fn render_match(
660 &self,
661 ix: usize,
662 selected: bool,
663 window: &mut Window,
664 cx: &mut Context<Picker<Self>>,
665 ) -> Option<Self::ListItem> {
666 let hit = self.matches.get(ix)?;
667
668 let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
669
670 let mut path_start_offset = 0;
671
672 let (match_labels, paths): (Vec<_>, Vec<_>) = paths
673 .ordered_paths()
674 .map(|p| p.compact())
675 .map(|path| {
676 let highlighted_text =
677 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
678 path_start_offset += highlighted_text.1.text.len();
679 highlighted_text
680 })
681 .unzip();
682
683 let prefix = match &location {
684 SerializedWorkspaceLocation::Remote(options) => {
685 Some(SharedString::from(options.display_name()))
686 }
687 _ => None,
688 };
689
690 let highlighted_match = HighlightedMatchWithPaths {
691 prefix,
692 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
693 paths,
694 };
695
696 let focus_handle = self.focus_handle.clone();
697
698 let secondary_actions = h_flex()
699 .gap_px()
700 .child(
701 IconButton::new("open_new_window", IconName::ArrowUpRight)
702 .icon_size(IconSize::XSmall)
703 .tooltip({
704 move |_, cx| {
705 Tooltip::for_action_in(
706 "Open Project in New Window",
707 &menu::SecondaryConfirm,
708 &focus_handle,
709 cx,
710 )
711 }
712 })
713 .on_click(cx.listener(move |this, _event, window, cx| {
714 cx.stop_propagation();
715 window.prevent_default();
716 this.delegate.set_selected_index(ix, window, cx);
717 this.delegate.confirm(true, window, cx);
718 })),
719 )
720 .child(
721 IconButton::new("delete", IconName::Close)
722 .icon_size(IconSize::Small)
723 .tooltip(Tooltip::text("Delete from Recent Projects"))
724 .on_click(cx.listener(move |this, _event, window, cx| {
725 cx.stop_propagation();
726 window.prevent_default();
727
728 this.delegate.delete_recent_project(ix, window, cx)
729 })),
730 )
731 .into_any_element();
732
733 Some(
734 ListItem::new(ix)
735 .toggle_state(selected)
736 .inset(true)
737 .spacing(ListItemSpacing::Sparse)
738 .child(
739 h_flex()
740 .id("projecy_info_container")
741 .gap_3()
742 .flex_grow()
743 .when(self.has_any_non_local_projects, |this| {
744 this.child(match location {
745 SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen)
746 .color(Color::Muted)
747 .into_any_element(),
748 SerializedWorkspaceLocation::Remote(options) => {
749 Icon::new(match options {
750 RemoteConnectionOptions::Ssh { .. } => IconName::Server,
751 RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
752 RemoteConnectionOptions::Docker(_) => IconName::Box,
753 #[cfg(any(test, feature = "test-support"))]
754 RemoteConnectionOptions::Mock(_) => IconName::Server,
755 })
756 .color(Color::Muted)
757 .into_any_element()
758 }
759 })
760 })
761 .child({
762 let mut highlighted = highlighted_match.clone();
763 if !self.render_paths {
764 highlighted.paths.clear();
765 }
766 highlighted.render(window, cx)
767 })
768 .tooltip(move |_, cx| {
769 let tooltip_highlighted_location = highlighted_match.clone();
770 cx.new(|_| MatchTooltip {
771 highlighted_location: tooltip_highlighted_location,
772 })
773 .into()
774 }),
775 )
776 .map(|el| {
777 if self.selected_index() == ix {
778 el.end_slot(secondary_actions)
779 } else {
780 el.end_hover_slot(secondary_actions)
781 }
782 }),
783 )
784 }
785
786 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
787 Some(
788 h_flex()
789 .w_full()
790 .p_2()
791 .gap_2()
792 .justify_end()
793 .border_t_1()
794 .border_color(cx.theme().colors().border_variant)
795 .child(
796 Button::new("remote", "Open Remote Folder")
797 .key_binding(KeyBinding::for_action(
798 &OpenRemote {
799 from_existing_connection: false,
800 create_new_window: false,
801 },
802 cx,
803 ))
804 .on_click(|_, window, cx| {
805 window.dispatch_action(
806 OpenRemote {
807 from_existing_connection: false,
808 create_new_window: false,
809 }
810 .boxed_clone(),
811 cx,
812 )
813 }),
814 )
815 .child(
816 Button::new("local", "Open Local Folder")
817 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
818 .on_click(|_, window, cx| {
819 window.dispatch_action(workspace::Open.boxed_clone(), cx)
820 }),
821 )
822 .into_any(),
823 )
824 }
825}
826
827// Compute the highlighted text for the name and path
828fn highlights_for_path(
829 path: &Path,
830 match_positions: &Vec<usize>,
831 path_start_offset: usize,
832) -> (Option<HighlightedMatch>, HighlightedMatch) {
833 let path_string = path.to_string_lossy();
834 let path_text = path_string.to_string();
835 let path_byte_len = path_text.len();
836 // Get the subset of match highlight positions that line up with the given path.
837 // Also adjusts them to start at the path start
838 let path_positions = match_positions
839 .iter()
840 .copied()
841 .skip_while(|position| *position < path_start_offset)
842 .take_while(|position| *position < path_start_offset + path_byte_len)
843 .map(|position| position - path_start_offset)
844 .collect::<Vec<_>>();
845
846 // Again subset the highlight positions to just those that line up with the file_name
847 // again adjusted to the start of the file_name
848 let file_name_text_and_positions = path.file_name().map(|file_name| {
849 let file_name_text = file_name.to_string_lossy().into_owned();
850 let file_name_start_byte = path_byte_len - file_name_text.len();
851 let highlight_positions = path_positions
852 .iter()
853 .copied()
854 .skip_while(|position| *position < file_name_start_byte)
855 .take_while(|position| *position < file_name_start_byte + file_name_text.len())
856 .map(|position| position - file_name_start_byte)
857 .collect::<Vec<_>>();
858 HighlightedMatch {
859 text: file_name_text,
860 highlight_positions,
861 color: Color::Default,
862 }
863 });
864
865 (
866 file_name_text_and_positions,
867 HighlightedMatch {
868 text: path_text,
869 highlight_positions: path_positions,
870 color: Color::Default,
871 },
872 )
873}
874impl RecentProjectsDelegate {
875 fn delete_recent_project(
876 &self,
877 ix: usize,
878 window: &mut Window,
879 cx: &mut Context<Picker<Self>>,
880 ) {
881 if let Some(selected_match) = self.matches.get(ix) {
882 let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
883 let fs = self
884 .workspace
885 .upgrade()
886 .map(|ws| ws.read(cx).app_state().fs.clone());
887 cx.spawn_in(window, async move |this, cx| {
888 WORKSPACE_DB
889 .delete_workspace_by_id(workspace_id)
890 .await
891 .log_err();
892 let Some(fs) = fs else { return };
893 let workspaces = WORKSPACE_DB
894 .recent_workspaces_on_disk(fs.as_ref())
895 .await
896 .unwrap_or_default();
897 this.update_in(cx, move |picker, window, cx| {
898 picker.delegate.set_workspaces(workspaces);
899 picker
900 .delegate
901 .set_selected_index(ix.saturating_sub(1), window, cx);
902 picker.delegate.reset_selected_match_index = false;
903 picker.update_matches(picker.query(cx), window, cx);
904 // After deleting a project, we want to update the history manager to reflect the change.
905 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
906 if let Some(history_manager) = HistoryManager::global(cx) {
907 history_manager
908 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
909 }
910 })
911 .ok();
912 })
913 .detach();
914 }
915 }
916
917 fn is_current_workspace(
918 &self,
919 workspace_id: WorkspaceId,
920 cx: &mut Context<Picker<Self>>,
921 ) -> bool {
922 if let Some(workspace) = self.workspace.upgrade() {
923 let workspace = workspace.read(cx);
924 if Some(workspace_id) == workspace.database_id() {
925 return true;
926 }
927 }
928
929 false
930 }
931}
932struct MatchTooltip {
933 highlighted_location: HighlightedMatchWithPaths,
934}
935
936impl Render for MatchTooltip {
937 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
938 tooltip_container(cx, |div, _| {
939 self.highlighted_location.render_paths_children(div)
940 })
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use std::path::PathBuf;
947
948 use editor::Editor;
949 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
950
951 use serde_json::json;
952 use settings::SettingsStore;
953 use util::path;
954 use workspace::{AppState, open_paths};
955
956 use super::*;
957
958 #[gpui::test]
959 async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
960 let app_state = init_test(cx);
961
962 cx.update(|cx| {
963 SettingsStore::update_global(cx, |store, cx| {
964 store.update_user_settings(cx, |settings| {
965 settings
966 .session
967 .get_or_insert_default()
968 .restore_unsaved_buffers = Some(false)
969 });
970 });
971 });
972
973 app_state
974 .fs
975 .as_fake()
976 .insert_tree(
977 path!("/dir"),
978 json!({
979 "main.ts": "a"
980 }),
981 )
982 .await;
983 app_state
984 .fs
985 .as_fake()
986 .insert_tree(path!("/test/path"), json!({}))
987 .await;
988 cx.update(|cx| {
989 open_paths(
990 &[PathBuf::from(path!("/dir/main.ts"))],
991 app_state,
992 workspace::OpenOptions::default(),
993 cx,
994 )
995 })
996 .await
997 .unwrap();
998 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
999
1000 let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1001 multi_workspace
1002 .update(cx, |multi_workspace, _, cx| {
1003 assert!(!multi_workspace.workspace().read(cx).is_edited())
1004 })
1005 .unwrap();
1006
1007 let editor = multi_workspace
1008 .read_with(cx, |multi_workspace, cx| {
1009 multi_workspace
1010 .workspace()
1011 .read(cx)
1012 .active_item(cx)
1013 .unwrap()
1014 .downcast::<Editor>()
1015 .unwrap()
1016 })
1017 .unwrap();
1018 multi_workspace
1019 .update(cx, |_, window, cx| {
1020 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1021 })
1022 .unwrap();
1023 multi_workspace
1024 .update(cx, |multi_workspace, _, cx| {
1025 assert!(
1026 multi_workspace.workspace().read(cx).is_edited(),
1027 "After inserting more text into the editor without saving, we should have a dirty project"
1028 )
1029 })
1030 .unwrap();
1031
1032 let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1033 multi_workspace
1034 .update(cx, |_, _, cx| {
1035 recent_projects_picker.update(cx, |picker, cx| {
1036 assert_eq!(picker.query(cx), "");
1037 let delegate = &mut picker.delegate;
1038 delegate.matches = vec![StringMatch {
1039 candidate_id: 0,
1040 score: 1.0,
1041 positions: Vec::new(),
1042 string: "fake candidate".to_string(),
1043 }];
1044 delegate.set_workspaces(vec![(
1045 WorkspaceId::default(),
1046 SerializedWorkspaceLocation::Local,
1047 PathList::new(&[path!("/test/path")]),
1048 )]);
1049 });
1050 })
1051 .unwrap();
1052
1053 assert!(
1054 !cx.has_pending_prompt(),
1055 "Should have no pending prompt on dirty project before opening the new recent project"
1056 );
1057 let dirty_workspace = multi_workspace
1058 .read_with(cx, |multi_workspace, _cx| {
1059 multi_workspace.workspace().clone()
1060 })
1061 .unwrap();
1062
1063 cx.dispatch_action(*multi_workspace, menu::Confirm);
1064 cx.run_until_parked();
1065
1066 multi_workspace
1067 .update(cx, |multi_workspace, _, cx| {
1068 assert!(
1069 multi_workspace
1070 .workspace()
1071 .read(cx)
1072 .active_modal::<RecentProjects>(cx)
1073 .is_none(),
1074 "Should remove the modal after selecting new recent project"
1075 );
1076
1077 assert!(
1078 multi_workspace.workspaces().len() >= 2,
1079 "Should have at least 2 workspaces: the dirty one and the newly opened one"
1080 );
1081
1082 assert!(
1083 multi_workspace.workspaces().contains(&dirty_workspace),
1084 "The original dirty workspace should still be present"
1085 );
1086
1087 assert!(
1088 dirty_workspace.read(cx).is_edited(),
1089 "The original workspace should still be dirty"
1090 );
1091 })
1092 .unwrap();
1093
1094 assert!(
1095 !cx.has_pending_prompt(),
1096 "No save prompt in multi-workspace mode — dirty workspace survives in background"
1097 );
1098 }
1099
1100 fn open_recent_projects(
1101 multi_workspace: &WindowHandle<MultiWorkspace>,
1102 cx: &mut TestAppContext,
1103 ) -> Entity<Picker<RecentProjectsDelegate>> {
1104 cx.dispatch_action(
1105 (*multi_workspace).into(),
1106 OpenRecent {
1107 create_new_window: false,
1108 },
1109 );
1110 multi_workspace
1111 .update(cx, |multi_workspace, _, cx| {
1112 multi_workspace
1113 .workspace()
1114 .read(cx)
1115 .active_modal::<RecentProjects>(cx)
1116 .unwrap()
1117 .read(cx)
1118 .picker
1119 .clone()
1120 })
1121 .unwrap()
1122 }
1123
1124 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1125 cx.update(|cx| {
1126 let state = AppState::test(cx);
1127 crate::init(cx);
1128 editor::init(cx);
1129 state
1130 })
1131 }
1132}