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