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