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