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