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