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