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