1use std::any::Any;
2use std::collections::BTreeSet;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use editor::Editor;
7use file_finder::OpenPathDelegate;
8use futures::FutureExt;
9use futures::channel::oneshot;
10use futures::future::Shared;
11use gpui::ClipboardItem;
12use gpui::Task;
13use gpui::WeakEntity;
14use gpui::canvas;
15use gpui::{
16 AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
17 PromptLevel, ScrollHandle, Window,
18};
19use picker::Picker;
20use project::Project;
21use remote::SshConnectionOptions;
22use remote::SshRemoteClient;
23use remote::ssh_session::ConnectionIdentifier;
24use settings::Settings;
25use settings::update_settings_file;
26use ui::Navigable;
27use ui::NavigableEntry;
28use ui::{
29 IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar, ScrollbarState,
30 Section, Tooltip, prelude::*,
31};
32use util::ResultExt;
33use workspace::OpenOptions;
34use workspace::Toast;
35use workspace::notifications::NotificationId;
36use workspace::{
37 ModalView, Workspace, notifications::DetachAndPromptErr,
38 open_ssh_project_with_existing_connection,
39};
40
41use crate::OpenRemote;
42use crate::ssh_connections::RemoteSettingsContent;
43use crate::ssh_connections::SshConnection;
44use crate::ssh_connections::SshConnectionHeader;
45use crate::ssh_connections::SshConnectionModal;
46use crate::ssh_connections::SshProject;
47use crate::ssh_connections::SshPrompt;
48use crate::ssh_connections::SshSettings;
49use crate::ssh_connections::connect_over_ssh;
50use crate::ssh_connections::open_ssh_project;
51
52mod navigation_base {}
53pub struct RemoteServerProjects {
54 mode: Mode,
55 focus_handle: FocusHandle,
56 workspace: WeakEntity<Workspace>,
57 retained_connections: Vec<Entity<SshRemoteClient>>,
58}
59
60struct CreateRemoteServer {
61 address_editor: Entity<Editor>,
62 address_error: Option<SharedString>,
63 ssh_prompt: Option<Entity<SshPrompt>>,
64 _creating: Option<Task<Option<()>>>,
65}
66
67impl CreateRemoteServer {
68 fn new(window: &mut Window, cx: &mut App) -> Self {
69 let address_editor = cx.new(|cx| Editor::single_line(window, cx));
70 address_editor.update(cx, |this, cx| {
71 this.focus_handle(cx).focus(window);
72 });
73 Self {
74 address_editor,
75 address_error: None,
76 ssh_prompt: None,
77 _creating: None,
78 }
79 }
80}
81
82struct ProjectPicker {
83 connection_string: SharedString,
84 nickname: Option<SharedString>,
85 picker: Entity<Picker<OpenPathDelegate>>,
86 _path_task: Shared<Task<Option<()>>>,
87}
88
89struct EditNicknameState {
90 index: usize,
91 editor: Entity<Editor>,
92}
93
94impl EditNicknameState {
95 fn new(index: usize, window: &mut Window, cx: &mut App) -> Self {
96 let this = Self {
97 index,
98 editor: cx.new(|cx| Editor::single_line(window, cx)),
99 };
100 let starting_text = SshSettings::get_global(cx)
101 .ssh_connections()
102 .nth(index)
103 .and_then(|state| state.nickname.clone())
104 .filter(|text| !text.is_empty());
105 this.editor.update(cx, |this, cx| {
106 this.set_placeholder_text("Add a nickname for this server", cx);
107 if let Some(starting_text) = starting_text {
108 this.set_text(starting_text, window, cx);
109 }
110 });
111 this.editor.focus_handle(cx).focus(window);
112 this
113 }
114}
115
116impl Focusable for ProjectPicker {
117 fn focus_handle(&self, cx: &App) -> FocusHandle {
118 self.picker.focus_handle(cx)
119 }
120}
121
122impl ProjectPicker {
123 fn new(
124 ix: usize,
125 connection: SshConnectionOptions,
126 project: Entity<Project>,
127 workspace: WeakEntity<Workspace>,
128 window: &mut Window,
129 cx: &mut Context<RemoteServerProjects>,
130 ) -> Entity<Self> {
131 let (tx, rx) = oneshot::channel();
132 let lister = project::DirectoryLister::Project(project.clone());
133 let query = lister.default_query(cx);
134 let delegate = file_finder::OpenPathDelegate::new(tx, lister);
135
136 let picker = cx.new(|cx| {
137 let picker = Picker::uniform_list(delegate, window, cx)
138 .width(rems(34.))
139 .modal(false);
140 picker.set_query(query, window, cx);
141 picker
142 });
143 let connection_string = connection.connection_string().into();
144 let nickname = connection.nickname.clone().map(|nick| nick.into());
145 let _path_task = cx
146 .spawn_in(window, {
147 let workspace = workspace.clone();
148 async move |this, cx| {
149 let Ok(Some(paths)) = rx.await else {
150 workspace
151 .update_in(cx, |workspace, window, cx| {
152 let weak = cx.entity().downgrade();
153 workspace.toggle_modal(window, cx, |window, cx| {
154 RemoteServerProjects::new(window, cx, weak)
155 });
156 })
157 .log_err()?;
158 return None;
159 };
160
161 let app_state = workspace
162 .update(cx, |workspace, _| workspace.app_state().clone())
163 .ok()?;
164
165 cx.update(|_, cx| {
166 let fs = app_state.fs.clone();
167 update_settings_file::<SshSettings>(fs, cx, {
168 let paths = paths
169 .iter()
170 .map(|path| path.to_string_lossy().to_string())
171 .collect();
172 move |setting, _| {
173 if let Some(server) = setting
174 .ssh_connections
175 .as_mut()
176 .and_then(|connections| connections.get_mut(ix))
177 {
178 server.projects.insert(SshProject { paths });
179 }
180 }
181 });
182 })
183 .log_err();
184
185 let options = cx
186 .update(|_, cx| (app_state.build_window_options)(None, cx))
187 .log_err()?;
188 let window = cx
189 .open_window(options, |window, cx| {
190 cx.new(|cx| {
191 telemetry::event!("SSH Project Created");
192 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
193 })
194 })
195 .log_err()?;
196
197 open_ssh_project_with_existing_connection(
198 connection, project, paths, app_state, window, cx,
199 )
200 .await
201 .log_err();
202
203 this.update(cx, |_, cx| {
204 cx.emit(DismissEvent);
205 })
206 .ok();
207 Some(())
208 }
209 })
210 .shared();
211 cx.new(|_| Self {
212 _path_task,
213 picker,
214 connection_string,
215 nickname,
216 })
217 }
218}
219
220impl gpui::Render for ProjectPicker {
221 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
222 v_flex()
223 .child(
224 SshConnectionHeader {
225 connection_string: self.connection_string.clone(),
226 paths: Default::default(),
227 nickname: self.nickname.clone(),
228 }
229 .render(window, cx),
230 )
231 .child(
232 div()
233 .border_t_1()
234 .border_color(cx.theme().colors().border_variant)
235 .child(self.picker.clone()),
236 )
237 }
238}
239
240#[derive(Clone)]
241struct ProjectEntry {
242 open_folder: NavigableEntry,
243 projects: Vec<(NavigableEntry, SshProject)>,
244 configure: NavigableEntry,
245 connection: SshConnection,
246}
247
248#[derive(Clone)]
249struct DefaultState {
250 scrollbar: ScrollbarState,
251 add_new_server: NavigableEntry,
252 servers: Vec<ProjectEntry>,
253}
254impl DefaultState {
255 fn new(cx: &mut App) -> Self {
256 let handle = ScrollHandle::new();
257 let scrollbar = ScrollbarState::new(handle.clone());
258 let add_new_server = NavigableEntry::new(&handle, cx);
259 let servers = SshSettings::get_global(cx)
260 .ssh_connections()
261 .map(|connection| {
262 let open_folder = NavigableEntry::new(&handle, cx);
263 let configure = NavigableEntry::new(&handle, cx);
264 let projects = connection
265 .projects
266 .iter()
267 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
268 .collect();
269 ProjectEntry {
270 open_folder,
271 configure,
272 projects,
273 connection,
274 }
275 })
276 .collect();
277 Self {
278 scrollbar,
279 add_new_server,
280 servers,
281 }
282 }
283}
284
285#[derive(Clone)]
286struct ViewServerOptionsState {
287 server_index: usize,
288 connection: SshConnection,
289 entries: [NavigableEntry; 4],
290}
291enum Mode {
292 Default(DefaultState),
293 ViewServerOptions(ViewServerOptionsState),
294 EditNickname(EditNicknameState),
295 ProjectPicker(Entity<ProjectPicker>),
296 CreateRemoteServer(CreateRemoteServer),
297}
298
299impl Mode {
300 fn default_mode(cx: &mut App) -> Self {
301 Self::Default(DefaultState::new(cx))
302 }
303}
304impl RemoteServerProjects {
305 pub fn register(
306 workspace: &mut Workspace,
307 _window: Option<&mut Window>,
308 _: &mut Context<Workspace>,
309 ) {
310 workspace.register_action(|workspace, _: &OpenRemote, window, cx| {
311 let handle = cx.entity().downgrade();
312 workspace.toggle_modal(window, cx, |window, cx| Self::new(window, cx, handle))
313 });
314 }
315
316 pub fn open(workspace: Entity<Workspace>, window: &mut Window, cx: &mut App) {
317 workspace.update(cx, |workspace, cx| {
318 let handle = cx.entity().downgrade();
319 workspace.toggle_modal(window, cx, |window, cx| Self::new(window, cx, handle))
320 })
321 }
322
323 pub fn new(
324 window: &mut Window,
325 cx: &mut Context<Self>,
326 workspace: WeakEntity<Workspace>,
327 ) -> Self {
328 let focus_handle = cx.focus_handle();
329
330 let mut base_style = window.text_style();
331 base_style.refine(&gpui::TextStyleRefinement {
332 color: Some(cx.theme().colors().editor_foreground),
333 ..Default::default()
334 });
335
336 Self {
337 mode: Mode::default_mode(cx),
338 focus_handle,
339 workspace,
340 retained_connections: Vec::new(),
341 }
342 }
343
344 pub fn project_picker(
345 ix: usize,
346 connection_options: remote::SshConnectionOptions,
347 project: Entity<Project>,
348 window: &mut Window,
349 cx: &mut Context<Self>,
350 workspace: WeakEntity<Workspace>,
351 ) -> Self {
352 let mut this = Self::new(window, cx, workspace.clone());
353 this.mode = Mode::ProjectPicker(ProjectPicker::new(
354 ix,
355 connection_options,
356 project,
357 workspace,
358 window,
359 cx,
360 ));
361 cx.notify();
362
363 this
364 }
365
366 fn create_ssh_server(
367 &mut self,
368 editor: Entity<Editor>,
369 window: &mut Window,
370 cx: &mut Context<Self>,
371 ) {
372 let input = get_text(&editor, cx);
373 if input.is_empty() {
374 return;
375 }
376
377 let connection_options = match SshConnectionOptions::parse_command_line(&input) {
378 Ok(c) => c,
379 Err(e) => {
380 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
381 address_editor: editor,
382 address_error: Some(format!("could not parse: {:?}", e).into()),
383 ssh_prompt: None,
384 _creating: None,
385 });
386 return;
387 }
388 };
389 let ssh_prompt = cx.new(|cx| SshPrompt::new(&connection_options, window, cx));
390
391 let connection = connect_over_ssh(
392 ConnectionIdentifier::setup(),
393 connection_options.clone(),
394 ssh_prompt.clone(),
395 window,
396 cx,
397 )
398 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
399
400 let address_editor = editor.clone();
401 let creating = cx.spawn(async move |this, cx| {
402 match connection.await {
403 Some(Some(client)) => this
404 .update(cx, |this, cx| {
405 telemetry::event!("SSH Server Created");
406 this.retained_connections.push(client);
407 this.add_ssh_server(connection_options, cx);
408 this.mode = Mode::default_mode(cx);
409 cx.notify()
410 })
411 .log_err(),
412 _ => this
413 .update(cx, |this, cx| {
414 address_editor.update(cx, |this, _| {
415 this.set_read_only(false);
416 });
417 this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
418 address_editor,
419 address_error: None,
420 ssh_prompt: None,
421 _creating: None,
422 });
423 cx.notify()
424 })
425 .log_err(),
426 };
427 None
428 });
429
430 editor.update(cx, |this, _| {
431 this.set_read_only(true);
432 });
433 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
434 address_editor: editor,
435 address_error: None,
436 ssh_prompt: Some(ssh_prompt.clone()),
437 _creating: Some(creating),
438 });
439 }
440
441 fn view_server_options(
442 &mut self,
443 (server_index, connection): (usize, SshConnection),
444 window: &mut Window,
445 cx: &mut Context<Self>,
446 ) {
447 self.mode = Mode::ViewServerOptions(ViewServerOptionsState {
448 server_index,
449 connection,
450 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
451 });
452 self.focus_handle(cx).focus(window);
453 cx.notify();
454 }
455
456 fn create_ssh_project(
457 &mut self,
458 ix: usize,
459 ssh_connection: SshConnection,
460 window: &mut Window,
461 cx: &mut Context<Self>,
462 ) {
463 let Some(workspace) = self.workspace.upgrade() else {
464 return;
465 };
466
467 let connection_options = ssh_connection.into();
468 workspace.update(cx, |_, cx| {
469 cx.defer_in(window, move |workspace, window, cx| {
470 workspace.toggle_modal(window, cx, |window, cx| {
471 SshConnectionModal::new(&connection_options, Vec::new(), window, cx)
472 });
473 let prompt = workspace
474 .active_modal::<SshConnectionModal>(cx)
475 .unwrap()
476 .read(cx)
477 .prompt
478 .clone();
479
480 let connect = connect_over_ssh(
481 ConnectionIdentifier::setup(),
482 connection_options.clone(),
483 prompt,
484 window,
485 cx,
486 )
487 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
488
489 cx.spawn_in(window, async move |workspace, cx| {
490 let session = connect.await;
491
492 workspace
493 .update(cx, |workspace, cx| {
494 if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
495 prompt.update(cx, |prompt, cx| prompt.finished(cx))
496 }
497 })
498 .ok();
499
500 let Some(Some(session)) = session else {
501 workspace
502 .update_in(cx, |workspace, window, cx| {
503 let weak = cx.entity().downgrade();
504 workspace.toggle_modal(window, cx, |window, cx| {
505 RemoteServerProjects::new(window, cx, weak)
506 });
507 })
508 .log_err();
509 return;
510 };
511
512 workspace
513 .update_in(cx, |workspace, window, cx| {
514 let app_state = workspace.app_state().clone();
515 let weak = cx.entity().downgrade();
516 let project = project::Project::ssh(
517 session,
518 app_state.client.clone(),
519 app_state.node_runtime.clone(),
520 app_state.user_store.clone(),
521 app_state.languages.clone(),
522 app_state.fs.clone(),
523 cx,
524 );
525 workspace.toggle_modal(window, cx, |window, cx| {
526 RemoteServerProjects::project_picker(
527 ix,
528 connection_options,
529 project,
530 window,
531 cx,
532 weak,
533 )
534 });
535 })
536 .ok();
537 })
538 .detach()
539 })
540 })
541 }
542
543 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
544 match &self.mode {
545 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
546 Mode::ProjectPicker(_) => {}
547 Mode::CreateRemoteServer(state) => {
548 if let Some(prompt) = state.ssh_prompt.as_ref() {
549 prompt.update(cx, |prompt, cx| {
550 prompt.confirm(window, cx);
551 });
552 return;
553 }
554
555 self.create_ssh_server(state.address_editor.clone(), window, cx);
556 }
557 Mode::EditNickname(state) => {
558 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
559 let index = state.index;
560 self.update_settings_file(cx, move |setting, _| {
561 if let Some(connections) = setting.ssh_connections.as_mut() {
562 if let Some(connection) = connections.get_mut(index) {
563 connection.nickname = text;
564 }
565 }
566 });
567 self.mode = Mode::default_mode(cx);
568 self.focus_handle.focus(window);
569 }
570 }
571 }
572
573 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
574 match &self.mode {
575 Mode::Default(_) => cx.emit(DismissEvent),
576 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
577 let new_state = CreateRemoteServer::new(window, cx);
578 let old_prompt = state.address_editor.read(cx).text(cx);
579 new_state.address_editor.update(cx, |this, cx| {
580 this.set_text(old_prompt, window, cx);
581 });
582
583 self.mode = Mode::CreateRemoteServer(new_state);
584 cx.notify();
585 }
586 _ => {
587 self.mode = Mode::default_mode(cx);
588 self.focus_handle(cx).focus(window);
589 cx.notify();
590 }
591 }
592 }
593
594 fn render_ssh_connection(
595 &mut self,
596 ix: usize,
597 ssh_server: ProjectEntry,
598 window: &mut Window,
599 cx: &mut Context<Self>,
600 ) -> impl IntoElement {
601 let (main_label, aux_label) = if let Some(nickname) = ssh_server.connection.nickname.clone()
602 {
603 let aux_label = SharedString::from(format!("({})", ssh_server.connection.host));
604 (nickname.into(), Some(aux_label))
605 } else {
606 (ssh_server.connection.host.clone(), None)
607 };
608 v_flex()
609 .w_full()
610 .child(ListSeparator)
611 .child(
612 h_flex()
613 .group("ssh-server")
614 .w_full()
615 .pt_0p5()
616 .px_3()
617 .gap_1()
618 .overflow_hidden()
619 .child(
620 div().max_w_96().overflow_hidden().text_ellipsis().child(
621 Label::new(main_label)
622 .size(LabelSize::Small)
623 .color(Color::Muted),
624 ),
625 )
626 .children(
627 aux_label.map(|label| {
628 Label::new(label).size(LabelSize::Small).color(Color::Muted)
629 }),
630 ),
631 )
632 .child(
633 List::new()
634 .empty_message("No projects.")
635 .children(ssh_server.projects.iter().enumerate().map(|(pix, p)| {
636 v_flex().gap_0p5().child(self.render_ssh_project(
637 ix,
638 &ssh_server,
639 pix,
640 p,
641 window,
642 cx,
643 ))
644 }))
645 .child(
646 h_flex()
647 .id(("new-remote-project-container", ix))
648 .track_focus(&ssh_server.open_folder.focus_handle)
649 .anchor_scroll(ssh_server.open_folder.scroll_anchor.clone())
650 .on_action(cx.listener({
651 let ssh_connection = ssh_server.clone();
652 move |this, _: &menu::Confirm, window, cx| {
653 this.create_ssh_project(
654 ix,
655 ssh_connection.connection.clone(),
656 window,
657 cx,
658 );
659 }
660 }))
661 .child(
662 ListItem::new(("new-remote-project", ix))
663 .toggle_state(
664 ssh_server
665 .open_folder
666 .focus_handle
667 .contains_focused(window, cx),
668 )
669 .inset(true)
670 .spacing(ui::ListItemSpacing::Sparse)
671 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
672 .child(Label::new("Open Folder"))
673 .on_click(cx.listener({
674 let ssh_connection = ssh_server.clone();
675 move |this, _, window, cx| {
676 this.create_ssh_project(
677 ix,
678 ssh_connection.connection.clone(),
679 window,
680 cx,
681 );
682 }
683 })),
684 ),
685 )
686 .child(
687 h_flex()
688 .id(("server-options-container", ix))
689 .track_focus(&ssh_server.configure.focus_handle)
690 .anchor_scroll(ssh_server.configure.scroll_anchor.clone())
691 .on_action(cx.listener({
692 let ssh_connection = ssh_server.clone();
693 move |this, _: &menu::Confirm, window, cx| {
694 this.view_server_options(
695 (ix, ssh_connection.connection.clone()),
696 window,
697 cx,
698 );
699 }
700 }))
701 .child(
702 ListItem::new(("server-options", ix))
703 .toggle_state(
704 ssh_server
705 .configure
706 .focus_handle
707 .contains_focused(window, cx),
708 )
709 .inset(true)
710 .spacing(ui::ListItemSpacing::Sparse)
711 .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
712 .child(Label::new("View Server Options"))
713 .on_click(cx.listener({
714 let ssh_connection = ssh_server.clone();
715 move |this, _, window, cx| {
716 this.view_server_options(
717 (ix, ssh_connection.connection.clone()),
718 window,
719 cx,
720 );
721 }
722 })),
723 ),
724 ),
725 )
726 }
727
728 fn render_ssh_project(
729 &mut self,
730 server_ix: usize,
731 server: &ProjectEntry,
732 ix: usize,
733 (navigation, project): &(NavigableEntry, SshProject),
734 window: &mut Window,
735 cx: &mut Context<Self>,
736 ) -> impl IntoElement {
737 let server = server.clone();
738 let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
739 let container_element_id_base =
740 SharedString::from(format!("remote-project-container-{element_id_base}"));
741
742 let callback = Arc::new({
743 let project = project.clone();
744 move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
745 let Some(app_state) = this
746 .workspace
747 .update(cx, |workspace, _| workspace.app_state().clone())
748 .log_err()
749 else {
750 return;
751 };
752 let project = project.clone();
753 let server = server.connection.clone();
754 cx.emit(DismissEvent);
755 cx.spawn_in(window, async move |_, cx| {
756 let result = open_ssh_project(
757 server.into(),
758 project.paths.into_iter().map(PathBuf::from).collect(),
759 app_state,
760 OpenOptions::default(),
761 cx,
762 )
763 .await;
764 if let Err(e) = result {
765 log::error!("Failed to connect: {:?}", e);
766 cx.prompt(
767 gpui::PromptLevel::Critical,
768 "Failed to connect",
769 Some(&e.to_string()),
770 &["Ok"],
771 )
772 .await
773 .ok();
774 }
775 })
776 .detach();
777 }
778 });
779
780 div()
781 .id((container_element_id_base, ix))
782 .track_focus(&navigation.focus_handle)
783 .anchor_scroll(navigation.scroll_anchor.clone())
784 .on_action(cx.listener({
785 let callback = callback.clone();
786 move |this, _: &menu::Confirm, window, cx| {
787 callback(this, window, cx);
788 }
789 }))
790 .child(
791 ListItem::new((element_id_base, ix))
792 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
793 .inset(true)
794 .spacing(ui::ListItemSpacing::Sparse)
795 .start_slot(
796 Icon::new(IconName::Folder)
797 .color(Color::Muted)
798 .size(IconSize::Small),
799 )
800 .child(Label::new(project.paths.join(", ")))
801 .on_click(cx.listener(move |this, _, window, cx| callback(this, window, cx)))
802 .end_hover_slot::<AnyElement>(Some(
803 div()
804 .mr_2()
805 .child({
806 let project = project.clone();
807 // Right-margin to offset it from the Scrollbar
808 IconButton::new("remove-remote-project", IconName::TrashAlt)
809 .icon_size(IconSize::Small)
810 .shape(IconButtonShape::Square)
811 .size(ButtonSize::Large)
812 .tooltip(Tooltip::text("Delete Remote Project"))
813 .on_click(cx.listener(move |this, _, _, cx| {
814 this.delete_ssh_project(server_ix, &project, cx)
815 }))
816 })
817 .into_any_element(),
818 )),
819 )
820 }
821
822 fn update_settings_file(
823 &mut self,
824 cx: &mut Context<Self>,
825 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
826 ) {
827 let Some(fs) = self
828 .workspace
829 .update(cx, |workspace, _| workspace.app_state().fs.clone())
830 .log_err()
831 else {
832 return;
833 };
834 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
835 }
836
837 fn delete_ssh_server(&mut self, server: usize, cx: &mut Context<Self>) {
838 self.update_settings_file(cx, move |setting, _| {
839 if let Some(connections) = setting.ssh_connections.as_mut() {
840 connections.remove(server);
841 }
842 });
843 }
844
845 fn delete_ssh_project(&mut self, server: usize, project: &SshProject, cx: &mut Context<Self>) {
846 let project = project.clone();
847 self.update_settings_file(cx, move |setting, _| {
848 if let Some(server) = setting
849 .ssh_connections
850 .as_mut()
851 .and_then(|connections| connections.get_mut(server))
852 {
853 server.projects.remove(&project);
854 }
855 });
856 }
857
858 fn add_ssh_server(
859 &mut self,
860 connection_options: remote::SshConnectionOptions,
861 cx: &mut Context<Self>,
862 ) {
863 self.update_settings_file(cx, move |setting, _| {
864 setting
865 .ssh_connections
866 .get_or_insert(Default::default())
867 .push(SshConnection {
868 host: SharedString::from(connection_options.host),
869 username: connection_options.username,
870 port: connection_options.port,
871 projects: BTreeSet::<SshProject>::new(),
872 nickname: None,
873 args: connection_options.args.unwrap_or_default(),
874 upload_binary_over_ssh: None,
875 port_forwards: connection_options.port_forwards,
876 })
877 });
878 }
879
880 fn render_create_remote_server(
881 &self,
882 state: &CreateRemoteServer,
883 cx: &mut Context<Self>,
884 ) -> impl IntoElement {
885 let ssh_prompt = state.ssh_prompt.clone();
886
887 state.address_editor.update(cx, |editor, cx| {
888 if editor.text(cx).is_empty() {
889 editor.set_placeholder_text("ssh user@example -p 2222", cx);
890 }
891 });
892
893 let theme = cx.theme();
894
895 v_flex()
896 .track_focus(&self.focus_handle(cx))
897 .id("create-remote-server")
898 .overflow_hidden()
899 .size_full()
900 .flex_1()
901 .child(
902 div()
903 .p_2()
904 .border_b_1()
905 .border_color(theme.colors().border_variant)
906 .child(state.address_editor.clone()),
907 )
908 .child(
909 h_flex()
910 .bg(theme.colors().editor_background)
911 .rounded_b_sm()
912 .w_full()
913 .map(|this| {
914 if let Some(ssh_prompt) = ssh_prompt {
915 this.child(h_flex().w_full().child(ssh_prompt))
916 } else if let Some(address_error) = &state.address_error {
917 this.child(
918 h_flex().p_2().w_full().gap_2().child(
919 Label::new(address_error.clone())
920 .size(LabelSize::Small)
921 .color(Color::Error),
922 ),
923 )
924 } else {
925 this.child(
926 h_flex()
927 .p_2()
928 .w_full()
929 .gap_1()
930 .child(
931 Label::new(
932 "Enter the command you use to SSH into this server.",
933 )
934 .color(Color::Muted)
935 .size(LabelSize::Small),
936 )
937 .child(
938 Button::new("learn-more", "Learn more…")
939 .label_size(LabelSize::Small)
940 .size(ButtonSize::None)
941 .color(Color::Accent)
942 .style(ButtonStyle::Transparent)
943 .on_click(|_, _, cx| {
944 cx.open_url(
945 "https://zed.dev/docs/remote-development",
946 );
947 }),
948 ),
949 )
950 }
951 }),
952 )
953 }
954
955 fn render_view_options(
956 &mut self,
957 ViewServerOptionsState {
958 server_index,
959 connection,
960 entries,
961 }: ViewServerOptionsState,
962 window: &mut Window,
963 cx: &mut Context<Self>,
964 ) -> impl IntoElement {
965 let connection_string = connection.host.clone();
966
967 let mut view = Navigable::new(
968 div()
969 .track_focus(&self.focus_handle(cx))
970 .size_full()
971 .child(
972 SshConnectionHeader {
973 connection_string: connection_string.clone(),
974 paths: Default::default(),
975 nickname: connection.nickname.clone().map(|s| s.into()),
976 }
977 .render(window, cx),
978 )
979 .child(
980 v_flex()
981 .pb_1()
982 .child(ListSeparator)
983 .child({
984 let label = if connection.nickname.is_some() {
985 "Edit Nickname"
986 } else {
987 "Add Nickname to Server"
988 };
989 div()
990 .id("ssh-options-add-nickname")
991 .track_focus(&entries[0].focus_handle)
992 .on_action(cx.listener(
993 move |this, _: &menu::Confirm, window, cx| {
994 this.mode = Mode::EditNickname(EditNicknameState::new(
995 server_index,
996 window,
997 cx,
998 ));
999 cx.notify();
1000 },
1001 ))
1002 .child(
1003 ListItem::new("add-nickname")
1004 .toggle_state(
1005 entries[0].focus_handle.contains_focused(window, cx),
1006 )
1007 .inset(true)
1008 .spacing(ui::ListItemSpacing::Sparse)
1009 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1010 .child(Label::new(label))
1011 .on_click(cx.listener(move |this, _, window, cx| {
1012 this.mode = Mode::EditNickname(EditNicknameState::new(
1013 server_index,
1014 window,
1015 cx,
1016 ));
1017 cx.notify();
1018 })),
1019 )
1020 })
1021 .child({
1022 let workspace = self.workspace.clone();
1023 fn callback(
1024 workspace: WeakEntity<Workspace>,
1025 connection_string: SharedString,
1026 cx: &mut App,
1027 ) {
1028 cx.write_to_clipboard(ClipboardItem::new_string(
1029 connection_string.to_string(),
1030 ));
1031 workspace
1032 .update(cx, |this, cx| {
1033 struct SshServerAddressCopiedToClipboard;
1034 let notification = format!(
1035 "Copied server address ({}) to clipboard",
1036 connection_string
1037 );
1038
1039 this.show_toast(
1040 Toast::new(
1041 NotificationId::composite::<
1042 SshServerAddressCopiedToClipboard,
1043 >(
1044 connection_string.clone()
1045 ),
1046 notification,
1047 )
1048 .autohide(),
1049 cx,
1050 );
1051 })
1052 .ok();
1053 }
1054 div()
1055 .id("ssh-options-copy-server-address")
1056 .track_focus(&entries[1].focus_handle)
1057 .on_action({
1058 let connection_string = connection_string.clone();
1059 let workspace = self.workspace.clone();
1060 move |_: &menu::Confirm, _, cx| {
1061 callback(workspace.clone(), connection_string.clone(), cx);
1062 }
1063 })
1064 .child(
1065 ListItem::new("copy-server-address")
1066 .toggle_state(
1067 entries[1].focus_handle.contains_focused(window, cx),
1068 )
1069 .inset(true)
1070 .spacing(ui::ListItemSpacing::Sparse)
1071 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1072 .child(Label::new("Copy Server Address"))
1073 .end_hover_slot(
1074 Label::new(connection_string.clone())
1075 .color(Color::Muted),
1076 )
1077 .on_click({
1078 let connection_string = connection_string.clone();
1079 move |_, _, cx| {
1080 callback(
1081 workspace.clone(),
1082 connection_string.clone(),
1083 cx,
1084 );
1085 }
1086 }),
1087 )
1088 })
1089 .child({
1090 fn remove_ssh_server(
1091 remote_servers: Entity<RemoteServerProjects>,
1092 index: usize,
1093 connection_string: SharedString,
1094 window: &mut Window,
1095 cx: &mut App,
1096 ) {
1097 let prompt_message =
1098 format!("Remove server `{}`?", connection_string);
1099
1100 let confirmation = window.prompt(
1101 PromptLevel::Warning,
1102 &prompt_message,
1103 None,
1104 &["Yes, remove it", "No, keep it"],
1105 cx,
1106 );
1107
1108 cx.spawn(async move |cx| {
1109 if confirmation.await.ok() == Some(0) {
1110 remote_servers
1111 .update(cx, |this, cx| {
1112 this.delete_ssh_server(index, cx);
1113 })
1114 .ok();
1115 remote_servers
1116 .update(cx, |this, cx| {
1117 this.mode = Mode::default_mode(cx);
1118 cx.notify();
1119 })
1120 .ok();
1121 }
1122 anyhow::Ok(())
1123 })
1124 .detach_and_log_err(cx);
1125 }
1126 div()
1127 .id("ssh-options-copy-server-address")
1128 .track_focus(&entries[2].focus_handle)
1129 .on_action(cx.listener({
1130 let connection_string = connection_string.clone();
1131 move |_, _: &menu::Confirm, window, cx| {
1132 remove_ssh_server(
1133 cx.entity().clone(),
1134 server_index,
1135 connection_string.clone(),
1136 window,
1137 cx,
1138 );
1139 cx.focus_self(window);
1140 }
1141 }))
1142 .child(
1143 ListItem::new("remove-server")
1144 .toggle_state(
1145 entries[2].focus_handle.contains_focused(window, cx),
1146 )
1147 .inset(true)
1148 .spacing(ui::ListItemSpacing::Sparse)
1149 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1150 .child(Label::new("Remove Server").color(Color::Error))
1151 .on_click(cx.listener(move |_, _, window, cx| {
1152 remove_ssh_server(
1153 cx.entity().clone(),
1154 server_index,
1155 connection_string.clone(),
1156 window,
1157 cx,
1158 );
1159 cx.focus_self(window);
1160 })),
1161 )
1162 })
1163 .child(ListSeparator)
1164 .child({
1165 div()
1166 .id("ssh-options-copy-server-address")
1167 .track_focus(&entries[3].focus_handle)
1168 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1169 this.mode = Mode::default_mode(cx);
1170 cx.focus_self(window);
1171 cx.notify();
1172 }))
1173 .child(
1174 ListItem::new("go-back")
1175 .toggle_state(
1176 entries[3].focus_handle.contains_focused(window, cx),
1177 )
1178 .inset(true)
1179 .spacing(ui::ListItemSpacing::Sparse)
1180 .start_slot(
1181 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1182 )
1183 .child(Label::new("Go Back"))
1184 .on_click(cx.listener(|this, _, window, cx| {
1185 this.mode = Mode::default_mode(cx);
1186 cx.focus_self(window);
1187 cx.notify()
1188 })),
1189 )
1190 }),
1191 )
1192 .into_any_element(),
1193 );
1194 for entry in entries {
1195 view = view.entry(entry);
1196 }
1197
1198 view.render(window, cx).into_any_element()
1199 }
1200
1201 fn render_edit_nickname(
1202 &self,
1203 state: &EditNicknameState,
1204 window: &mut Window,
1205 cx: &mut Context<Self>,
1206 ) -> impl IntoElement {
1207 let Some(connection) = SshSettings::get_global(cx)
1208 .ssh_connections()
1209 .nth(state.index)
1210 else {
1211 return v_flex()
1212 .id("ssh-edit-nickname")
1213 .track_focus(&self.focus_handle(cx));
1214 };
1215
1216 let connection_string = connection.host.clone();
1217 let nickname = connection.nickname.clone().map(|s| s.into());
1218
1219 v_flex()
1220 .id("ssh-edit-nickname")
1221 .track_focus(&self.focus_handle(cx))
1222 .child(
1223 SshConnectionHeader {
1224 connection_string,
1225 paths: Default::default(),
1226 nickname,
1227 }
1228 .render(window, cx),
1229 )
1230 .child(
1231 h_flex()
1232 .p_2()
1233 .border_t_1()
1234 .border_color(cx.theme().colors().border_variant)
1235 .child(state.editor.clone()),
1236 )
1237 }
1238
1239 fn render_default(
1240 &mut self,
1241 mut state: DefaultState,
1242 window: &mut Window,
1243 cx: &mut Context<Self>,
1244 ) -> impl IntoElement {
1245 if SshSettings::get_global(cx)
1246 .ssh_connections
1247 .as_ref()
1248 .map_or(false, |connections| {
1249 state
1250 .servers
1251 .iter()
1252 .map(|server| &server.connection)
1253 .ne(connections.iter())
1254 })
1255 {
1256 self.mode = Mode::default_mode(cx);
1257 if let Mode::Default(new_state) = &self.mode {
1258 state = new_state.clone();
1259 }
1260 }
1261 let scroll_state = state.scrollbar.parent_entity(&cx.entity());
1262 let connect_button = div()
1263 .id("ssh-connect-new-server-container")
1264 .track_focus(&state.add_new_server.focus_handle)
1265 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1266 .child(
1267 ListItem::new("register-remove-server-button")
1268 .toggle_state(
1269 state
1270 .add_new_server
1271 .focus_handle
1272 .contains_focused(window, cx),
1273 )
1274 .inset(true)
1275 .spacing(ui::ListItemSpacing::Sparse)
1276 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1277 .child(Label::new("Connect New Server"))
1278 .on_click(cx.listener(|this, _, window, cx| {
1279 let state = CreateRemoteServer::new(window, cx);
1280 this.mode = Mode::CreateRemoteServer(state);
1281
1282 cx.notify();
1283 })),
1284 )
1285 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1286 let state = CreateRemoteServer::new(window, cx);
1287 this.mode = Mode::CreateRemoteServer(state);
1288
1289 cx.notify();
1290 }));
1291
1292 let handle = &**scroll_state.scroll_handle() as &dyn Any;
1293 let Some(scroll_handle) = handle.downcast_ref::<ScrollHandle>() else {
1294 unreachable!()
1295 };
1296
1297 let mut modal_section = Navigable::new(
1298 v_flex()
1299 .track_focus(&self.focus_handle(cx))
1300 .id("ssh-server-list")
1301 .overflow_y_scroll()
1302 .track_scroll(&scroll_handle)
1303 .size_full()
1304 .child(connect_button)
1305 .child(
1306 List::new()
1307 .empty_message(
1308 v_flex()
1309 .child(
1310 div().px_3().child(
1311 Label::new("No remote servers registered yet.")
1312 .color(Color::Muted),
1313 ),
1314 )
1315 .into_any_element(),
1316 )
1317 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
1318 self.render_ssh_connection(ix, connection.clone(), window, cx)
1319 .into_any_element()
1320 })),
1321 )
1322 .into_any_element(),
1323 )
1324 .entry(state.add_new_server.clone());
1325
1326 for server in &state.servers {
1327 for (navigation_state, _) in &server.projects {
1328 modal_section = modal_section.entry(navigation_state.clone());
1329 }
1330 modal_section = modal_section
1331 .entry(server.open_folder.clone())
1332 .entry(server.configure.clone());
1333 }
1334 let mut modal_section = modal_section.render(window, cx).into_any_element();
1335
1336 Modal::new("remote-projects", None)
1337 .header(
1338 ModalHeader::new()
1339 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)),
1340 )
1341 .section(
1342 Section::new().padded(false).child(
1343 v_flex()
1344 .min_h(rems(20.))
1345 .size_full()
1346 .relative()
1347 .child(ListSeparator)
1348 .child(
1349 canvas(
1350 |bounds, window, cx| {
1351 modal_section.prepaint_as_root(
1352 bounds.origin,
1353 bounds.size.into(),
1354 window,
1355 cx,
1356 );
1357 modal_section
1358 },
1359 |_, mut modal_section, window, cx| {
1360 modal_section.paint(window, cx);
1361 },
1362 )
1363 .size_full(),
1364 )
1365 .child(
1366 div()
1367 .occlude()
1368 .h_full()
1369 .absolute()
1370 .top_1()
1371 .bottom_1()
1372 .right_1()
1373 .w(px(8.))
1374 .children(Scrollbar::vertical(scroll_state)),
1375 ),
1376 ),
1377 )
1378 .into_any_element()
1379 }
1380}
1381
1382fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
1383 element.read(cx).text(cx).trim().to_string()
1384}
1385
1386impl ModalView for RemoteServerProjects {}
1387
1388impl Focusable for RemoteServerProjects {
1389 fn focus_handle(&self, cx: &App) -> FocusHandle {
1390 match &self.mode {
1391 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1392 _ => self.focus_handle.clone(),
1393 }
1394 }
1395}
1396
1397impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1398
1399impl Render for RemoteServerProjects {
1400 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1401 div()
1402 .elevation_3(cx)
1403 .w(rems(34.))
1404 .key_context("RemoteServerModal")
1405 .on_action(cx.listener(Self::cancel))
1406 .on_action(cx.listener(Self::confirm))
1407 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
1408 this.focus_handle(cx).focus(window);
1409 }))
1410 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
1411 if matches!(this.mode, Mode::Default(_)) {
1412 cx.emit(DismissEvent)
1413 }
1414 }))
1415 .child(match &self.mode {
1416 Mode::Default(state) => self
1417 .render_default(state.clone(), window, cx)
1418 .into_any_element(),
1419 Mode::ViewServerOptions(state) => self
1420 .render_view_options(state.clone(), window, cx)
1421 .into_any_element(),
1422 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1423 Mode::CreateRemoteServer(state) => self
1424 .render_create_remote_server(state, cx)
1425 .into_any_element(),
1426 Mode::EditNickname(state) => self
1427 .render_edit_nickname(state, window, cx)
1428 .into_any_element(),
1429 })
1430 }
1431}