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