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(&OpenRemote, window, cx))
472 .on_click(|_, window, cx| {
473 window.dispatch_action(OpenRemote.boxed_clone(), cx)
474 }),
475 )
476 .child(
477 Button::new("local", "Open Local Folder")
478 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
479 .on_click(|_, window, cx| {
480 window.dispatch_action(workspace::Open.boxed_clone(), cx)
481 }),
482 )
483 .into_any(),
484 )
485 }
486}
487
488// Compute the highlighted text for the name and path
489fn highlights_for_path(
490 path: &Path,
491 match_positions: &Vec<usize>,
492 path_start_offset: usize,
493) -> (Option<HighlightedMatch>, HighlightedMatch) {
494 let path_string = path.to_string_lossy();
495 let path_char_count = path_string.chars().count();
496 // Get the subset of match highlight positions that line up with the given path.
497 // Also adjusts them to start at the path start
498 let path_positions = match_positions
499 .iter()
500 .copied()
501 .skip_while(|position| *position < path_start_offset)
502 .take_while(|position| *position < path_start_offset + path_char_count)
503 .map(|position| position - path_start_offset)
504 .collect::<Vec<_>>();
505
506 // Again subset the highlight positions to just those that line up with the file_name
507 // again adjusted to the start of the file_name
508 let file_name_text_and_positions = path.file_name().map(|file_name| {
509 let text = file_name.to_string_lossy();
510 let char_count = text.chars().count();
511 let file_name_start = path_char_count - char_count;
512 let highlight_positions = path_positions
513 .iter()
514 .copied()
515 .skip_while(|position| *position < file_name_start)
516 .take_while(|position| *position < file_name_start + char_count)
517 .map(|position| position - file_name_start)
518 .collect::<Vec<_>>();
519 HighlightedMatch {
520 text: text.to_string(),
521 highlight_positions,
522 char_count,
523 color: Color::Default,
524 }
525 });
526
527 (
528 file_name_text_and_positions,
529 HighlightedMatch {
530 text: path_string.to_string(),
531 highlight_positions: path_positions,
532 char_count: path_char_count,
533 color: Color::Default,
534 },
535 )
536}
537impl RecentProjectsDelegate {
538 fn delete_recent_project(
539 &self,
540 ix: usize,
541 window: &mut Window,
542 cx: &mut Context<Picker<Self>>,
543 ) {
544 if let Some(selected_match) = self.matches.get(ix) {
545 let (workspace_id, _) = self.workspaces[selected_match.candidate_id];
546 cx.spawn_in(window, async move |this, cx| {
547 let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
548 let workspaces = WORKSPACE_DB
549 .recent_workspaces_on_disk()
550 .await
551 .unwrap_or_default();
552 this.update_in(cx, move |picker, window, cx| {
553 picker.delegate.set_workspaces(workspaces);
554 picker
555 .delegate
556 .set_selected_index(ix.saturating_sub(1), window, cx);
557 picker.delegate.reset_selected_match_index = false;
558 picker.update_matches(picker.query(cx), window, cx);
559 // After deleting a project, we want to update the history manager to reflect the change.
560 // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
561 if let Some(history_manager) = HistoryManager::global(cx) {
562 history_manager
563 .update(cx, |this, cx| this.delete_history(workspace_id, cx));
564 }
565 })
566 })
567 .detach();
568 }
569 }
570
571 fn is_current_workspace(
572 &self,
573 workspace_id: WorkspaceId,
574 cx: &mut Context<Picker<Self>>,
575 ) -> bool {
576 if let Some(workspace) = self.workspace.upgrade() {
577 let workspace = workspace.read(cx);
578 if Some(workspace_id) == workspace.database_id() {
579 return true;
580 }
581 }
582
583 false
584 }
585}
586struct MatchTooltip {
587 highlighted_location: HighlightedMatchWithPaths,
588}
589
590impl Render for MatchTooltip {
591 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
592 tooltip_container(window, cx, |div, _, _| {
593 self.highlighted_location.render_paths_children(div)
594 })
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use std::path::PathBuf;
601
602 use dap::debugger_settings::DebuggerSettings;
603 use editor::Editor;
604 use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
605 use project::{Project, project_settings::ProjectSettings};
606 use serde_json::json;
607 use settings::SettingsStore;
608 use util::path;
609 use workspace::{AppState, open_paths};
610
611 use super::*;
612
613 #[gpui::test]
614 async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
615 let app_state = init_test(cx);
616
617 cx.update(|cx| {
618 SettingsStore::update_global(cx, |store, cx| {
619 store.update_user_settings::<ProjectSettings>(cx, |settings| {
620 settings.session.restore_unsaved_buffers = false
621 });
622 });
623 });
624
625 app_state
626 .fs
627 .as_fake()
628 .insert_tree(
629 path!("/dir"),
630 json!({
631 "main.ts": "a"
632 }),
633 )
634 .await;
635 cx.update(|cx| {
636 open_paths(
637 &[PathBuf::from(path!("/dir/main.ts"))],
638 app_state,
639 workspace::OpenOptions::default(),
640 cx,
641 )
642 })
643 .await
644 .unwrap();
645 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
646
647 let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
648 workspace
649 .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
650 .unwrap();
651
652 let editor = workspace
653 .read_with(cx, |workspace, cx| {
654 workspace
655 .active_item(cx)
656 .unwrap()
657 .downcast::<Editor>()
658 .unwrap()
659 })
660 .unwrap();
661 workspace
662 .update(cx, |_, window, cx| {
663 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
664 })
665 .unwrap();
666 workspace
667 .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
668 .unwrap();
669
670 let recent_projects_picker = open_recent_projects(&workspace, cx);
671 workspace
672 .update(cx, |_, _, cx| {
673 recent_projects_picker.update(cx, |picker, cx| {
674 assert_eq!(picker.query(cx), "");
675 let delegate = &mut picker.delegate;
676 delegate.matches = vec![StringMatch {
677 candidate_id: 0,
678 score: 1.0,
679 positions: Vec::new(),
680 string: "fake candidate".to_string(),
681 }];
682 delegate.set_workspaces(vec![(
683 WorkspaceId::default(),
684 SerializedWorkspaceLocation::from_local_paths(vec![path!("/test/path/")]),
685 )]);
686 });
687 })
688 .unwrap();
689
690 assert!(
691 !cx.has_pending_prompt(),
692 "Should have no pending prompt on dirty project before opening the new recent project"
693 );
694 cx.dispatch_action(*workspace, menu::Confirm);
695 workspace
696 .update(cx, |workspace, _, cx| {
697 assert!(
698 workspace.active_modal::<RecentProjects>(cx).is_none(),
699 "Should remove the modal after selecting new recent project"
700 )
701 })
702 .unwrap();
703 assert!(
704 cx.has_pending_prompt(),
705 "Dirty workspace should prompt before opening the new recent project"
706 );
707 cx.simulate_prompt_answer("Cancel");
708 assert!(
709 !cx.has_pending_prompt(),
710 "Should have no pending prompt after cancelling"
711 );
712 workspace
713 .update(cx, |workspace, _, _| {
714 assert!(
715 workspace.is_edited(),
716 "Should be in the same dirty project after cancelling"
717 )
718 })
719 .unwrap();
720 }
721
722 fn open_recent_projects(
723 workspace: &WindowHandle<Workspace>,
724 cx: &mut TestAppContext,
725 ) -> Entity<Picker<RecentProjectsDelegate>> {
726 cx.dispatch_action(
727 (*workspace).into(),
728 OpenRecent {
729 create_new_window: false,
730 },
731 );
732 workspace
733 .update(cx, |workspace, _, cx| {
734 workspace
735 .active_modal::<RecentProjects>(cx)
736 .unwrap()
737 .read(cx)
738 .picker
739 .clone()
740 })
741 .unwrap()
742 }
743
744 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
745 cx.update(|cx| {
746 let state = AppState::test(cx);
747 language::init(cx);
748 crate::init(cx);
749 editor::init(cx);
750 workspace::init_settings(cx);
751 DebuggerSettings::register(cx);
752 Project::init_settings(cx);
753 state
754 })
755 }
756}