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