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