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