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