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