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