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