1pub mod disconnected_overlay;
2mod remote_servers;
3mod ssh_connections;
4pub use ssh_connections::{is_connecting_over_ssh, open_ssh_project};
5
6use disconnected_overlay::DisconnectedOverlay;
7use fuzzy::{StringMatch, StringMatchCandidate};
8use gpui::{
9 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
10 Subscription, Task, WeakEntity, Window,
11};
12use ordered_float::OrderedFloat;
13use picker::{
14 highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
15 Picker, PickerDelegate,
16};
17pub use remote_servers::RemoteServerProjects;
18use settings::Settings;
19pub use ssh_connections::SshSettings;
20use std::{
21 path::{Path, PathBuf},
22 sync::Arc,
23};
24use ui::{prelude::*, tooltip_container, KeyBinding, ListItem, ListItemSpacing, Tooltip};
25use util::{paths::PathExt, ResultExt};
26use workspace::{
27 CloseIntent, ModalView, OpenOptions, SerializedWorkspaceLocation, Workspace, WorkspaceId,
28 WORKSPACE_DB,
29};
30use zed_actions::{OpenRecent, OpenRemote};
31
32pub fn init(cx: &mut App) {
33 SshSettings::register(cx);
34 cx.observe_new(RecentProjects::register).detach();
35 cx.observe_new(RemoteServerProjects::register).detach();
36 cx.observe_new(DisconnectedOverlay::register).detach();
37}
38
39pub struct RecentProjects {
40 pub picker: Entity<Picker<RecentProjectsDelegate>>,
41 rem_width: f32,
42 _subscription: Subscription,
43}
44
45impl ModalView for RecentProjects {}
46
47impl RecentProjects {
48 fn new(
49 delegate: RecentProjectsDelegate,
50 rem_width: f32,
51 window: &mut Window,
52 cx: &mut Context<Self>,
53 ) -> Self {
54 let picker = cx.new(|cx| {
55 // We want to use a list when we render paths, because the items can have different heights (multiple paths).
56 if delegate.render_paths {
57 Picker::list(delegate, window, cx)
58 } else {
59 Picker::uniform_list(delegate, window, cx)
60 }
61 });
62 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
63 // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
64 // out workspace locations once the future runs to completion.
65 cx.spawn_in(window, |this, mut cx| async move {
66 let workspaces = WORKSPACE_DB
67 .recent_workspaces_on_disk()
68 .await
69 .log_err()
70 .unwrap_or_default();
71 this.update_in(&mut cx, move |this, window, cx| {
72 this.picker.update(cx, move |picker, cx| {
73 picker.delegate.set_workspaces(workspaces);
74 picker.update_matches(picker.query(cx), window, cx)
75 })
76 })
77 .ok()
78 })
79 .detach();
80 Self {
81 picker,
82 rem_width,
83 _subscription,
84 }
85 }
86
87 fn register(
88 workspace: &mut Workspace,
89 _window: Option<&mut Window>,
90 _cx: &mut Context<Workspace>,
91 ) {
92 workspace.register_action(|workspace, open_recent: &OpenRecent, window, cx| {
93 let Some(recent_projects) = workspace.active_modal::<Self>(cx) else {
94 Self::open(workspace, open_recent.create_new_window, window, cx);
95 return;
96 };
97
98 recent_projects.update(cx, |recent_projects, cx| {
99 recent_projects
100 .picker
101 .update(cx, |picker, cx| picker.cycle_selection(window, cx))
102 });
103 });
104 }
105
106 pub fn open(
107 workspace: &mut Workspace,
108 create_new_window: bool,
109 window: &mut Window,
110 cx: &mut Context<Workspace>,
111 ) {
112 let weak = cx.entity().downgrade();
113 workspace.toggle_modal(window, cx, |window, cx| {
114 let delegate = RecentProjectsDelegate::new(weak, create_new_window, true);
115
116 Self::new(delegate, 34., window, cx)
117 })
118 }
119}
120
121impl EventEmitter<DismissEvent> for RecentProjects {}
122
123impl Focusable for RecentProjects {
124 fn focus_handle(&self, cx: &App) -> FocusHandle {
125 self.picker.focus_handle(cx)
126 }
127}
128
129impl Render for RecentProjects {
130 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
131 v_flex()
132 .w(rems(self.rem_width))
133 .child(self.picker.clone())
134 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
135 this.picker.update(cx, |this, cx| {
136 this.cancel(&Default::default(), window, cx);
137 })
138 }))
139 }
140}
141
142pub struct RecentProjectsDelegate {
143 workspace: WeakEntity<Workspace>,
144 workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
145 selected_match_index: usize,
146 matches: Vec<StringMatch>,
147 render_paths: bool,
148 create_new_window: bool,
149 // Flag to reset index when there is a new query vs not reset index when user delete an item
150 reset_selected_match_index: bool,
151 has_any_non_local_projects: bool,
152}
153
154impl RecentProjectsDelegate {
155 fn new(workspace: WeakEntity<Workspace>, create_new_window: bool, render_paths: bool) -> Self {
156 Self {
157 workspace,
158 workspaces: Vec::new(),
159 selected_match_index: 0,
160 matches: Default::default(),
161 create_new_window,
162 render_paths,
163 reset_selected_match_index: true,
164 has_any_non_local_projects: false,
165 }
166 }
167
168 pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) {
169 self.workspaces = workspaces;
170 self.has_any_non_local_projects = !self
171 .workspaces
172 .iter()
173 .all(|(_, location)| matches!(location, SerializedWorkspaceLocation::Local(_, _)));
174 }
175}
176impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
177impl PickerDelegate for RecentProjectsDelegate {
178 type ListItem = ListItem;
179
180 fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
181 let (create_window, reuse_window) = if self.create_new_window {
182 (
183 window.keystroke_text_for(&menu::Confirm),
184 window.keystroke_text_for(&menu::SecondaryConfirm),
185 )
186 } else {
187 (
188 window.keystroke_text_for(&menu::SecondaryConfirm),
189 window.keystroke_text_for(&menu::Confirm),
190 )
191 };
192 Arc::from(format!(
193 "{reuse_window} reuses this window, {create_window} opens a new one",
194 ))
195 }
196
197 fn match_count(&self) -> usize {
198 self.matches.len()
199 }
200
201 fn selected_index(&self) -> usize {
202 self.selected_match_index
203 }
204
205 fn set_selected_index(
206 &mut self,
207 ix: usize,
208 _window: &mut Window,
209 _cx: &mut Context<Picker<Self>>,
210 ) {
211 self.selected_match_index = ix;
212 }
213
214 fn update_matches(
215 &mut self,
216 query: String,
217 _: &mut Window,
218 cx: &mut Context<Picker<Self>>,
219 ) -> gpui::Task<()> {
220 let query = query.trim_start();
221 let smart_case = query.chars().any(|c| c.is_uppercase());
222 let candidates = self
223 .workspaces
224 .iter()
225 .enumerate()
226 .filter(|(_, (id, _))| !self.is_current_workspace(*id, cx))
227 .map(|(id, (_, location))| {
228 let combined_string = location
229 .sorted_paths()
230 .iter()
231 .map(|path| path.compact().to_string_lossy().into_owned())
232 .collect::<Vec<_>>()
233 .join("");
234
235 StringMatchCandidate::new(id, &combined_string)
236 })
237 .collect::<Vec<_>>();
238 self.matches = smol::block_on(fuzzy::match_strings(
239 candidates.as_slice(),
240 query,
241 smart_case,
242 100,
243 &Default::default(),
244 cx.background_executor().clone(),
245 ));
246 self.matches.sort_unstable_by_key(|m| m.candidate_id);
247
248 if self.reset_selected_match_index {
249 self.selected_match_index = self
250 .matches
251 .iter()
252 .enumerate()
253 .rev()
254 .max_by_key(|(_, m)| OrderedFloat(m.score))
255 .map(|(ix, _)| ix)
256 .unwrap_or(0);
257 }
258 self.reset_selected_match_index = true;
259 Task::ready(())
260 }
261
262 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
263 if let Some((selected_match, workspace)) = self
264 .matches
265 .get(self.selected_index())
266 .zip(self.workspace.upgrade())
267 {
268 let (candidate_workspace_id, candidate_workspace_location) =
269 &self.workspaces[selected_match.candidate_id];
270 let replace_current_window = if self.create_new_window {
271 secondary
272 } else {
273 !secondary
274 };
275 workspace
276 .update(cx, |workspace, cx| {
277 if workspace.database_id() == Some(*candidate_workspace_id) {
278 Task::ready(Ok(()))
279 } else {
280 match candidate_workspace_location {
281 SerializedWorkspaceLocation::Local(paths, _) => {
282 let paths = paths.paths().to_vec();
283 if replace_current_window {
284 cx.spawn_in(window, move |workspace, mut cx| async move {
285 let continue_replacing = workspace
286 .update_in(&mut cx, |workspace, window, cx| {
287 workspace.prepare_to_close(
288 CloseIntent::ReplaceWindow,
289 window,
290 cx,
291 )
292 })?
293 .await?;
294 if continue_replacing {
295 workspace
296 .update_in(&mut cx, |workspace, window, cx| {
297 workspace.open_workspace_for_paths(
298 true, paths, window, cx,
299 )
300 })?
301 .await
302 } else {
303 Ok(())
304 }
305 })
306 } else {
307 workspace.open_workspace_for_paths(false, paths, window, cx)
308 }
309 }
310 SerializedWorkspaceLocation::Ssh(ssh_project) => {
311 let app_state = workspace.app_state().clone();
312
313 let replace_window = if replace_current_window {
314 window.window_handle().downcast::<Workspace>()
315 } else {
316 None
317 };
318
319 let open_options = OpenOptions {
320 replace_window,
321 ..Default::default()
322 };
323
324 let connection_options = SshSettings::get_global(cx)
325 .connection_options_for(
326 ssh_project.host.clone(),
327 ssh_project.port,
328 ssh_project.user.clone(),
329 );
330
331 let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
332
333 cx.spawn_in(window, |_, mut cx| async move {
334 open_ssh_project(
335 connection_options,
336 paths,
337 app_state,
338 open_options,
339 &mut cx,
340 )
341 .await
342 })
343 }
344 }
345 }
346 })
347 .detach_and_log_err(cx);
348 cx.emit(DismissEvent);
349 }
350 }
351
352 fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
353
354 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> SharedString {
355 if self.workspaces.is_empty() {
356 "Recently opened projects will show up here".into()
357 } else {
358 "No matches".into()
359 }
360 }
361
362 fn render_match(
363 &self,
364 ix: usize,
365 selected: bool,
366 window: &mut Window,
367 cx: &mut Context<Picker<Self>>,
368 ) -> Option<Self::ListItem> {
369 let hit = self.matches.get(ix)?;
370
371 let (_, location) = self.workspaces.get(hit.candidate_id)?;
372
373 let mut path_start_offset = 0;
374
375 let (match_labels, paths): (Vec<_>, Vec<_>) = location
376 .sorted_paths()
377 .iter()
378 .map(|p| p.compact())
379 .map(|path| {
380 let highlighted_text =
381 highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
382
383 path_start_offset += highlighted_text.1.char_count;
384 highlighted_text
385 })
386 .unzip();
387
388 let highlighted_match = HighlightedMatchWithPaths {
389 match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
390 paths,
391 };
392
393 Some(
394 ListItem::new(ix)
395 .toggle_state(selected)
396 .inset(true)
397 .spacing(ListItemSpacing::Sparse)
398 .child(
399 h_flex()
400 .flex_grow()
401 .gap_3()
402 .when(self.has_any_non_local_projects, |this| {
403 this.child(match location {
404 SerializedWorkspaceLocation::Local(_, _) => {
405 Icon::new(IconName::Screen)
406 .color(Color::Muted)
407 .into_any_element()
408 }
409 SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
410 .color(Color::Muted)
411 .into_any_element(),
412 })
413 })
414 .child({
415 let mut highlighted = highlighted_match.clone();
416 if !self.render_paths {
417 highlighted.paths.clear();
418 }
419 highlighted.render(window, cx)
420 }),
421 )
422 .map(|el| {
423 let delete_button = div()
424 .child(
425 IconButton::new("delete", IconName::Close)
426 .icon_size(IconSize::Small)
427 .on_click(cx.listener(move |this, _event, window, cx| {
428 cx.stop_propagation();
429 window.prevent_default();
430
431 this.delegate.delete_recent_project(ix, window, cx)
432 }))
433 .tooltip(Tooltip::text("Delete from Recent Projects...")),
434 )
435 .into_any_element();
436
437 if self.selected_index() == ix {
438 el.end_slot::<AnyElement>(delete_button)
439 } else {
440 el.end_hover_slot::<AnyElement>(delete_button)
441 }
442 })
443 .tooltip(move |_, cx| {
444 let tooltip_highlighted_location = highlighted_match.clone();
445 cx.new(|_| MatchTooltip {
446 highlighted_location: tooltip_highlighted_location,
447 })
448 .into()
449 }),
450 )
451 }
452
453 fn render_footer(
454 &self,
455 window: &mut Window,
456 cx: &mut Context<Picker<Self>>,
457 ) -> Option<AnyElement> {
458 Some(
459 h_flex()
460 .w_full()
461 .p_2()
462 .gap_2()
463 .justify_end()
464 .border_t_1()
465 .border_color(cx.theme().colors().border_variant)
466 .child(
467 Button::new("remote", "Open Remote Folder")
468 .key_binding(KeyBinding::for_action(&OpenRemote, window, cx))
469 .on_click(|_, window, cx| {
470 window.dispatch_action(OpenRemote.boxed_clone(), cx)
471 }),
472 )
473 .child(
474 Button::new("local", "Open Local Folder")
475 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
476 .on_click(|_, window, cx| {
477 window.dispatch_action(workspace::Open.boxed_clone(), cx)
478 }),
479 )
480 .into_any(),
481 )
482 }
483}
484
485// Compute the highlighted text for the name and path
486fn highlights_for_path(
487 path: &Path,
488 match_positions: &Vec<usize>,
489 path_start_offset: usize,
490) -> (Option<HighlightedMatch>, HighlightedMatch) {
491 let path_string = path.to_string_lossy();
492 let path_char_count = path_string.chars().count();
493 // Get the subset of match highlight positions that line up with the given path.
494 // Also adjusts them to start at the path start
495 let path_positions = match_positions
496 .iter()
497 .copied()
498 .skip_while(|position| *position < path_start_offset)
499 .take_while(|position| *position < path_start_offset + path_char_count)
500 .map(|position| position - path_start_offset)
501 .collect::<Vec<_>>();
502
503 // Again subset the highlight positions to just those that line up with the file_name
504 // again adjusted to the start of the file_name
505 let file_name_text_and_positions = path.file_name().map(|file_name| {
506 let text = file_name.to_string_lossy();
507 let char_count = text.chars().count();
508 let file_name_start = path_char_count - char_count;
509 let highlight_positions = path_positions
510 .iter()
511 .copied()
512 .skip_while(|position| *position < file_name_start)
513 .take_while(|position| *position < file_name_start + char_count)
514 .map(|position| position - file_name_start)
515 .collect::<Vec<_>>();
516 HighlightedMatch {
517 text: text.to_string(),
518 highlight_positions,
519 char_count,
520 color: Color::Default,
521 }
522 });
523
524 (
525 file_name_text_and_positions,
526 HighlightedMatch {
527 text: path_string.to_string(),
528 highlight_positions: path_positions,
529 char_count: path_char_count,
530 color: Color::Default,
531 },
532 )
533}
534impl RecentProjectsDelegate {
535 fn delete_recent_project(
536 &self,
537 ix: usize,
538 window: &mut Window,
539 cx: &mut Context<Picker<Self>>,
540 ) {
541 if let Some(selected_match) = self.matches.get(ix) {
542 let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
543 cx.spawn_in(window, move |this, mut cx| async move {
544 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
545 let workspaces = WORKSPACE_DB
546 .recent_workspaces_on_disk()
547 .await
548 .unwrap_or_default();
549 this.update_in(&mut cx, move |picker, window, cx| {
550 picker.delegate.set_workspaces(workspaces);
551 picker
552 .delegate
553 .set_selected_index(ix.saturating_sub(1), window, cx);
554 picker.delegate.reset_selected_match_index = false;
555 picker.update_matches(picker.query(cx), window, cx)
556 })
557 })
558 .detach();
559 }
560 }
561
562 fn is_current_workspace(
563 &self,
564 workspace_id: WorkspaceId,
565 cx: &mut Context<Picker<Self>>,
566 ) -> bool {
567 if let Some(workspace) = self.workspace.upgrade() {
568 let workspace = workspace.read(cx);
569 if Some(workspace_id) == workspace.database_id() {
570 return true;
571 }
572 }
573
574 false
575 }
576}
577struct MatchTooltip {
578 highlighted_location: HighlightedMatchWithPaths,
579}
580
581impl Render for MatchTooltip {
582 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
583 tooltip_container(window, cx, |div, _, _| {
584 self.highlighted_location.render_paths_children(div)
585 })
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use std::path::PathBuf;
592
593 use editor::Editor;
594 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
595 use project::{project_settings::ProjectSettings, Project};
596 use serde_json::json;
597 use settings::SettingsStore;
598 use util::path;
599 use workspace::{open_paths, AppState};
600
601 use super::*;
602
603 #[gpui::test]
604 async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
605 let app_state = init_test(cx);
606
607 cx.update(|cx| {
608 SettingsStore::update_global(cx, |store, cx| {
609 store.update_user_settings::<ProjectSettings>(cx, |settings| {
610 settings.session.restore_unsaved_buffers = false
611 });
612 });
613 });
614
615 app_state
616 .fs
617 .as_fake()
618 .insert_tree(
619 path!("/dir"),
620 json!({
621 "main.ts": "a"
622 }),
623 )
624 .await;
625 cx.update(|cx| {
626 open_paths(
627 &[PathBuf::from(path!("/dir/main.ts"))],
628 app_state,
629 workspace::OpenOptions::default(),
630 cx,
631 )
632 })
633 .await
634 .unwrap();
635 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
636
637 let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
638 workspace
639 .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
640 .unwrap();
641
642 let editor = workspace
643 .read_with(cx, |workspace, cx| {
644 workspace
645 .active_item(cx)
646 .unwrap()
647 .downcast::<Editor>()
648 .unwrap()
649 })
650 .unwrap();
651 workspace
652 .update(cx, |_, window, cx| {
653 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
654 })
655 .unwrap();
656 workspace
657 .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
658 .unwrap();
659
660 let recent_projects_picker = open_recent_projects(&workspace, cx);
661 workspace
662 .update(cx, |_, _, cx| {
663 recent_projects_picker.update(cx, |picker, cx| {
664 assert_eq!(picker.query(cx), "");
665 let delegate = &mut picker.delegate;
666 delegate.matches = vec![StringMatch {
667 candidate_id: 0,
668 score: 1.0,
669 positions: Vec::new(),
670 string: "fake candidate".to_string(),
671 }];
672 delegate.set_workspaces(vec![(
673 WorkspaceId::default(),
674 SerializedWorkspaceLocation::from_local_paths(vec![path!("/test/path/")]),
675 )]);
676 });
677 })
678 .unwrap();
679
680 assert!(
681 !cx.has_pending_prompt(),
682 "Should have no pending prompt on dirty project before opening the new recent project"
683 );
684 cx.dispatch_action(*workspace, menu::Confirm);
685 workspace
686 .update(cx, |workspace, _, cx| {
687 assert!(
688 workspace.active_modal::<RecentProjects>(cx).is_none(),
689 "Should remove the modal after selecting new recent project"
690 )
691 })
692 .unwrap();
693 assert!(
694 cx.has_pending_prompt(),
695 "Dirty workspace should prompt before opening the new recent project"
696 );
697 cx.simulate_prompt_answer("Cancel");
698 assert!(
699 !cx.has_pending_prompt(),
700 "Should have no pending prompt after cancelling"
701 );
702 workspace
703 .update(cx, |workspace, _, _| {
704 assert!(
705 workspace.is_edited(),
706 "Should be in the same dirty project after cancelling"
707 )
708 })
709 .unwrap();
710 }
711
712 fn open_recent_projects(
713 workspace: &WindowHandle<Workspace>,
714 cx: &mut TestAppContext,
715 ) -> Entity<Picker<RecentProjectsDelegate>> {
716 cx.dispatch_action(
717 (*workspace).into(),
718 OpenRecent {
719 create_new_window: false,
720 },
721 );
722 workspace
723 .update(cx, |workspace, _, cx| {
724 workspace
725 .active_modal::<RecentProjects>(cx)
726 .unwrap()
727 .read(cx)
728 .picker
729 .clone()
730 })
731 .unwrap()
732 }
733
734 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
735 cx.update(|cx| {
736 let state = AppState::test(cx);
737 language::init(cx);
738 crate::init(cx);
739 editor::init(cx);
740 workspace::init_settings(cx);
741 Project::init_settings(cx);
742 state
743 })
744 }
745}