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