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