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