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