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 self.mode = Mode::CreateRemoteServer(CreateRemoteServer::new(cx));
601 self.selectable_items.reset_selection();
602 cx.notify();
603 }
604 _ => {
605 self.mode = Mode::default_mode();
606 self.selectable_items.reset_selection();
607 self.focus_handle(cx).focus(cx);
608 cx.notify();
609 }
610 }
611 }
612
613 fn render_ssh_connection(
614 &mut self,
615 ix: usize,
616 ssh_connection: SshConnection,
617 cx: &mut ViewContext<Self>,
618 ) -> impl IntoElement {
619 let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() {
620 let aux_label = SharedString::from(format!("({})", ssh_connection.host));
621 (nickname, Some(aux_label))
622 } else {
623 (ssh_connection.host.clone(), None)
624 };
625 v_flex()
626 .w_full()
627 .child(ListSeparator)
628 .child(
629 h_flex()
630 .group("ssh-server")
631 .w_full()
632 .pt_0p5()
633 .px_3()
634 .gap_1()
635 .overflow_hidden()
636 .whitespace_nowrap()
637 .child(
638 Label::new(main_label)
639 .size(LabelSize::Small)
640 .weight(FontWeight::SEMIBOLD)
641 .color(Color::Muted),
642 )
643 .children(
644 aux_label.map(|label| {
645 Label::new(label).size(LabelSize::Small).color(Color::Muted)
646 }),
647 ),
648 )
649 .child(
650 List::new()
651 .empty_message("No projects.")
652 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
653 v_flex().gap_0p5().child(self.render_ssh_project(
654 ix,
655 &ssh_connection,
656 pix,
657 p,
658 cx,
659 ))
660 }))
661 .child(h_flex().map(|this| {
662 self.selectable_items.add_item(Box::new({
663 let ssh_connection = ssh_connection.clone();
664 move |this, cx| {
665 this.create_ssh_project(ix, ssh_connection.clone(), cx);
666 }
667 }));
668 let is_selected = self.selectable_items.is_selected();
669 this.child(
670 ListItem::new(("new-remote-project", ix))
671 .selected(is_selected)
672 .inset(true)
673 .spacing(ui::ListItemSpacing::Sparse)
674 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
675 .child(Label::new("Open Folder"))
676 .on_click(cx.listener({
677 let ssh_connection = ssh_connection.clone();
678 move |this, _, cx| {
679 this.create_ssh_project(ix, ssh_connection.clone(), cx);
680 }
681 })),
682 )
683 }))
684 .child(h_flex().map(|this| {
685 self.selectable_items.add_item(Box::new({
686 let ssh_connection = ssh_connection.clone();
687 move |this, cx| {
688 this.view_server_options((ix, ssh_connection.clone()), cx);
689 }
690 }));
691 let is_selected = self.selectable_items.is_selected();
692 this.child(
693 ListItem::new(("server-options", ix))
694 .selected(is_selected)
695 .inset(true)
696 .spacing(ui::ListItemSpacing::Sparse)
697 .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
698 .child(Label::new("View Server Options"))
699 .on_click(cx.listener({
700 let ssh_connection = ssh_connection.clone();
701 move |this, _, cx| {
702 this.view_server_options((ix, ssh_connection.clone()), cx);
703 }
704 })),
705 )
706 })),
707 )
708 }
709
710 fn render_ssh_project(
711 &mut self,
712 server_ix: usize,
713 server: &SshConnection,
714 ix: usize,
715 project: &SshProject,
716 cx: &ViewContext<Self>,
717 ) -> impl IntoElement {
718 let server = server.clone();
719
720 let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
721 let callback = Arc::new({
722 let project = project.clone();
723 move |this: &mut Self, cx: &mut ViewContext<Self>| {
724 let Some(app_state) = this
725 .workspace
726 .update(cx, |workspace, _| workspace.app_state().clone())
727 .log_err()
728 else {
729 return;
730 };
731 let project = project.clone();
732 let server = server.clone();
733 cx.spawn(|_, mut cx| async move {
734 let result = open_ssh_project(
735 server.into(),
736 project.paths.into_iter().map(PathBuf::from).collect(),
737 app_state,
738 OpenOptions::default(),
739 &mut cx,
740 )
741 .await;
742 if let Err(e) = result {
743 log::error!("Failed to connect: {:?}", e);
744 cx.prompt(
745 gpui::PromptLevel::Critical,
746 "Failed to connect",
747 Some(&e.to_string()),
748 &["Ok"],
749 )
750 .await
751 .ok();
752 }
753 })
754 .detach();
755 }
756 });
757 self.selectable_items.add_item(Box::new({
758 let callback = callback.clone();
759 move |this, cx| callback(this, cx)
760 }));
761 let is_selected = self.selectable_items.is_selected();
762
763 ListItem::new((element_id_base, ix))
764 .inset(true)
765 .selected(is_selected)
766 .spacing(ui::ListItemSpacing::Sparse)
767 .start_slot(
768 Icon::new(IconName::Folder)
769 .color(Color::Muted)
770 .size(IconSize::Small),
771 )
772 .child(Label::new(project.paths.join(", ")))
773 .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
774 .end_hover_slot::<AnyElement>(Some(
775 IconButton::new("remove-remote-project", IconName::TrashAlt)
776 .icon_size(IconSize::Small)
777 .shape(IconButtonShape::Square)
778 .on_click(
779 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
780 )
781 .size(ButtonSize::Large)
782 .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
783 .into_any_element(),
784 ))
785 }
786
787 fn update_settings_file(
788 &mut self,
789 cx: &mut ViewContext<Self>,
790 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
791 ) {
792 let Some(fs) = self
793 .workspace
794 .update(cx, |workspace, _| workspace.app_state().fs.clone())
795 .log_err()
796 else {
797 return;
798 };
799 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
800 }
801
802 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
803 self.update_settings_file(cx, move |setting, _| {
804 if let Some(connections) = setting.ssh_connections.as_mut() {
805 connections.remove(server);
806 }
807 });
808 }
809
810 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
811 self.update_settings_file(cx, move |setting, _| {
812 if let Some(server) = setting
813 .ssh_connections
814 .as_mut()
815 .and_then(|connections| connections.get_mut(server))
816 {
817 server.projects.remove(project);
818 }
819 });
820 }
821
822 fn add_ssh_server(
823 &mut self,
824 connection_options: remote::SshConnectionOptions,
825 cx: &mut ViewContext<Self>,
826 ) {
827 self.update_settings_file(cx, move |setting, _| {
828 setting
829 .ssh_connections
830 .get_or_insert(Default::default())
831 .push(SshConnection {
832 host: SharedString::from(connection_options.host),
833 username: connection_options.username,
834 port: connection_options.port,
835 projects: vec![],
836 nickname: None,
837 args: connection_options.args.unwrap_or_default(),
838 })
839 });
840 }
841
842 fn render_create_remote_server(
843 &self,
844 state: &CreateRemoteServer,
845 cx: &mut ViewContext<Self>,
846 ) -> impl IntoElement {
847 let ssh_prompt = state.ssh_prompt.clone();
848
849 state.address_editor.update(cx, |editor, cx| {
850 if editor.text(cx).is_empty() {
851 editor.set_placeholder_text("ssh user@example -p 2222", cx);
852 }
853 });
854
855 let theme = cx.theme();
856
857 v_flex()
858 .id("create-remote-server")
859 .overflow_hidden()
860 .size_full()
861 .flex_1()
862 .child(
863 div()
864 .p_2()
865 .border_b_1()
866 .border_color(theme.colors().border_variant)
867 .child(state.address_editor.clone()),
868 )
869 .child(
870 h_flex()
871 .bg(theme.colors().editor_background)
872 .rounded_b_md()
873 .w_full()
874 .map(|this| {
875 if let Some(ssh_prompt) = ssh_prompt {
876 this.child(h_flex().w_full().child(ssh_prompt))
877 } else if let Some(address_error) = &state.address_error {
878 this.child(
879 h_flex().p_2().w_full().gap_2().child(
880 Label::new(address_error.clone())
881 .size(LabelSize::Small)
882 .color(Color::Error),
883 ),
884 )
885 } else {
886 this.child(
887 h_flex()
888 .p_2()
889 .w_full()
890 .gap_1()
891 .child(
892 Label::new(
893 "Enter the command you use to SSH into this server.",
894 )
895 .color(Color::Muted)
896 .size(LabelSize::Small),
897 )
898 .child(
899 Button::new("learn-more", "Learn more…")
900 .label_size(LabelSize::Small)
901 .size(ButtonSize::None)
902 .color(Color::Accent)
903 .style(ButtonStyle::Transparent)
904 .on_click(|_, cx| {
905 cx.open_url(
906 "https://zed.dev/docs/remote-development",
907 );
908 }),
909 ),
910 )
911 }
912 }),
913 )
914 }
915
916 fn render_view_options(
917 &mut self,
918 index: usize,
919 connection: SshConnection,
920 cx: &mut ViewContext<Self>,
921 ) -> impl IntoElement {
922 let connection_string = connection.host.clone();
923
924 div()
925 .size_full()
926 .child(
927 SshConnectionHeader {
928 connection_string: connection_string.clone(),
929 nickname: connection.nickname.clone(),
930 }
931 .render(cx),
932 )
933 .child(
934 v_flex()
935 .py_1()
936 .child({
937 self.selectable_items.add_item(Box::new({
938 move |this, cx| {
939 this.mode = Mode::EditNickname(EditNicknameState::new(index, cx));
940 cx.notify();
941 }
942 }));
943 let is_selected = self.selectable_items.is_selected();
944 let label = if connection.nickname.is_some() {
945 "Edit Nickname"
946 } else {
947 "Add Nickname to Server"
948 };
949 ListItem::new("add-nickname")
950 .selected(is_selected)
951 .inset(true)
952 .spacing(ui::ListItemSpacing::Sparse)
953 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
954 .child(Label::new(label))
955 .on_click(cx.listener(move |this, _, cx| {
956 this.mode = Mode::EditNickname(EditNicknameState::new(index, cx));
957 cx.notify();
958 }))
959 })
960 .child({
961 let workspace = self.workspace.clone();
962 fn callback(
963 workspace: WeakView<Workspace>,
964 connection_string: SharedString,
965 cx: &mut WindowContext<'_>,
966 ) {
967 cx.write_to_clipboard(ClipboardItem::new_string(
968 connection_string.to_string(),
969 ));
970 workspace
971 .update(cx, |this, cx| {
972 struct SshServerAddressCopiedToClipboard;
973 let notification = format!(
974 "Copied server address ({}) to clipboard",
975 connection_string
976 );
977
978 this.show_toast(
979 Toast::new(
980 NotificationId::composite::<
981 SshServerAddressCopiedToClipboard,
982 >(
983 connection_string.clone()
984 ),
985 notification,
986 )
987 .autohide(),
988 cx,
989 );
990 })
991 .ok();
992 }
993 self.selectable_items.add_item(Box::new({
994 let workspace = workspace.clone();
995 let connection_string = connection_string.clone();
996 move |_, cx| {
997 callback(workspace.clone(), connection_string.clone(), cx);
998 }
999 }));
1000 let is_selected = self.selectable_items.is_selected();
1001 ListItem::new("copy-server-address")
1002 .selected(is_selected)
1003 .inset(true)
1004 .spacing(ui::ListItemSpacing::Sparse)
1005 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1006 .child(Label::new("Copy Server Address"))
1007 .end_hover_slot(
1008 Label::new(connection_string.clone()).color(Color::Muted),
1009 )
1010 .on_click({
1011 let connection_string = connection_string.clone();
1012 move |_, cx| {
1013 callback(workspace.clone(), connection_string.clone(), cx);
1014 }
1015 })
1016 })
1017 .child({
1018 fn remove_ssh_server(
1019 remote_servers: View<RemoteServerProjects>,
1020 index: usize,
1021 connection_string: SharedString,
1022 cx: &mut WindowContext<'_>,
1023 ) {
1024 let prompt_message = format!("Remove server `{}`?", connection_string);
1025
1026 let confirmation = cx.prompt(
1027 PromptLevel::Warning,
1028 &prompt_message,
1029 None,
1030 &["Yes, remove it", "No, keep it"],
1031 );
1032
1033 cx.spawn(|mut cx| async move {
1034 if confirmation.await.ok() == Some(0) {
1035 remote_servers
1036 .update(&mut cx, |this, cx| {
1037 this.delete_ssh_server(index, cx);
1038 this.mode = Mode::default_mode();
1039 cx.notify();
1040 })
1041 .ok();
1042 }
1043 anyhow::Ok(())
1044 })
1045 .detach_and_log_err(cx);
1046 }
1047 self.selectable_items.add_item(Box::new({
1048 let connection_string = connection_string.clone();
1049 move |_, cx| {
1050 remove_ssh_server(
1051 cx.view().clone(),
1052 index,
1053 connection_string.clone(),
1054 cx,
1055 );
1056 }
1057 }));
1058 let is_selected = self.selectable_items.is_selected();
1059 ListItem::new("remove-server")
1060 .selected(is_selected)
1061 .inset(true)
1062 .spacing(ui::ListItemSpacing::Sparse)
1063 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1064 .child(Label::new("Remove Server").color(Color::Error))
1065 .on_click(cx.listener(move |_, _, cx| {
1066 remove_ssh_server(
1067 cx.view().clone(),
1068 index,
1069 connection_string.clone(),
1070 cx,
1071 );
1072 }))
1073 })
1074 .child(ListSeparator)
1075 .child({
1076 self.selectable_items.add_item(Box::new({
1077 move |this, cx| {
1078 this.mode = Mode::default_mode();
1079 cx.notify();
1080 }
1081 }));
1082 let is_selected = self.selectable_items.is_selected();
1083 ListItem::new("go-back")
1084 .selected(is_selected)
1085 .inset(true)
1086 .spacing(ui::ListItemSpacing::Sparse)
1087 .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
1088 .child(Label::new("Go Back"))
1089 .on_click(cx.listener(|this, _, cx| {
1090 this.mode = Mode::default_mode();
1091 cx.notify()
1092 }))
1093 }),
1094 )
1095 }
1096
1097 fn render_edit_nickname(
1098 &self,
1099 state: &EditNicknameState,
1100 cx: &mut ViewContext<Self>,
1101 ) -> impl IntoElement {
1102 let Some(connection) = SshSettings::get_global(cx)
1103 .ssh_connections()
1104 .nth(state.index)
1105 else {
1106 return v_flex();
1107 };
1108
1109 let connection_string = connection.host.clone();
1110
1111 v_flex()
1112 .child(
1113 SshConnectionHeader {
1114 connection_string,
1115 nickname: connection.nickname.clone(),
1116 }
1117 .render(cx),
1118 )
1119 .child(h_flex().p_2().child(state.editor.clone()))
1120 }
1121
1122 fn render_default(
1123 &mut self,
1124 scroll_state: ScrollbarState,
1125 cx: &mut ViewContext<Self>,
1126 ) -> impl IntoElement {
1127 let scroll_state = scroll_state.parent_view(cx.view());
1128 let ssh_connections = SshSettings::get_global(cx)
1129 .ssh_connections()
1130 .collect::<Vec<_>>();
1131 self.selectable_items.add_item(Box::new(|this, cx| {
1132 this.mode = Mode::CreateRemoteServer(CreateRemoteServer::new(cx));
1133 cx.notify();
1134 }));
1135
1136 let is_selected = self.selectable_items.is_selected();
1137
1138 let connect_button = ListItem::new("register-remove-server-button")
1139 .selected(is_selected)
1140 .inset(true)
1141 .spacing(ui::ListItemSpacing::Sparse)
1142 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1143 .child(Label::new("Connect New Server"))
1144 .on_click(cx.listener(|this, _, cx| {
1145 let state = CreateRemoteServer::new(cx);
1146 this.mode = Mode::CreateRemoteServer(state);
1147
1148 cx.notify();
1149 }));
1150
1151 let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
1152 unreachable!()
1153 };
1154
1155 let mut modal_section = v_flex()
1156 .id("ssh-server-list")
1157 .overflow_y_scroll()
1158 .track_scroll(&scroll_handle)
1159 .size_full()
1160 .child(connect_button)
1161 .child(
1162 h_flex().child(
1163 List::new()
1164 .empty_message(
1165 v_flex()
1166 .child(ListSeparator)
1167 .child(
1168 div().px_3().child(
1169 Label::new("No remote servers registered yet.")
1170 .color(Color::Muted),
1171 ),
1172 )
1173 .into_any_element(),
1174 )
1175 .children(ssh_connections.iter().cloned().enumerate().map(
1176 |(ix, connection)| {
1177 self.render_ssh_connection(ix, connection, cx)
1178 .into_any_element()
1179 },
1180 )),
1181 ),
1182 )
1183 .into_any_element();
1184
1185 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1186 .header(
1187 ModalHeader::new()
1188 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
1189 )
1190 .section(
1191 Section::new().padded(false).child(
1192 h_flex()
1193 .min_h(rems(20.))
1194 .size_full()
1195 .child(
1196 v_flex().size_full().child(ListSeparator).child(
1197 canvas(
1198 |bounds, cx| {
1199 modal_section.prepaint_as_root(
1200 bounds.origin,
1201 bounds.size.into(),
1202 cx,
1203 );
1204 modal_section
1205 },
1206 |_, mut modal_section, cx| {
1207 modal_section.paint(cx);
1208 },
1209 )
1210 .size_full(),
1211 ),
1212 )
1213 .child(
1214 div()
1215 .occlude()
1216 .h_full()
1217 .absolute()
1218 .right_1()
1219 .top_1()
1220 .bottom_1()
1221 .w(px(12.))
1222 .children(Scrollbar::vertical(scroll_state)),
1223 ),
1224 ),
1225 )
1226 }
1227}
1228
1229fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1230 element.read(cx).text(cx).trim().to_string()
1231}
1232
1233impl ModalView for RemoteServerProjects {}
1234
1235impl FocusableView for RemoteServerProjects {
1236 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
1237 match &self.mode {
1238 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1239 _ => self.focus_handle.clone(),
1240 }
1241 }
1242}
1243
1244impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1245
1246impl Render for RemoteServerProjects {
1247 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1248 self.selectable_items.reset();
1249 div()
1250 .track_focus(&self.focus_handle)
1251 .elevation_3(cx)
1252 .key_context("RemoteServerModal")
1253 .on_action(cx.listener(Self::cancel))
1254 .on_action(cx.listener(Self::confirm))
1255 .on_action(cx.listener(Self::prev_item))
1256 .on_action(cx.listener(Self::next_item))
1257 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1258 this.focus_handle(cx).focus(cx);
1259 }))
1260 .on_mouse_down_out(cx.listener(|this, _, cx| {
1261 if matches!(this.mode, Mode::Default(_)) {
1262 cx.emit(DismissEvent)
1263 }
1264 }))
1265 .w(rems(34.))
1266 .child(match &self.mode {
1267 Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
1268 Mode::ViewServerOptions(index, connection) => self
1269 .render_view_options(*index, connection.clone(), cx)
1270 .into_any_element(),
1271 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1272 Mode::CreateRemoteServer(state) => self
1273 .render_create_remote_server(state, cx)
1274 .into_any_element(),
1275 Mode::EditNickname(state) => {
1276 self.render_edit_nickname(state, cx).into_any_element()
1277 }
1278 })
1279 }
1280}
1281
1282pub fn reconnect_to_dev_server_project(
1283 workspace: View<Workspace>,
1284 dev_server: DevServer,
1285 dev_server_project_id: DevServerProjectId,
1286 replace_current_window: bool,
1287 cx: &mut WindowContext,
1288) -> Task<Result<()>> {
1289 let store = dev_server_projects::Store::global(cx);
1290 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1291 cx.spawn(|mut cx| async move {
1292 reconnect.await?;
1293
1294 cx.background_executor()
1295 .timer(Duration::from_millis(1000))
1296 .await;
1297
1298 if let Some(project_id) = store.update(&mut cx, |store, _| {
1299 store
1300 .dev_server_project(dev_server_project_id)
1301 .and_then(|p| p.project_id)
1302 })? {
1303 workspace
1304 .update(&mut cx, move |_, cx| {
1305 open_dev_server_project(
1306 replace_current_window,
1307 dev_server_project_id,
1308 project_id,
1309 cx,
1310 )
1311 })?
1312 .await?;
1313 }
1314
1315 Ok(())
1316 })
1317}
1318
1319pub fn reconnect_to_dev_server(
1320 workspace: View<Workspace>,
1321 dev_server: DevServer,
1322 cx: &mut WindowContext,
1323) -> Task<Result<()>> {
1324 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1325 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1326 };
1327 let dev_server_store = dev_server_projects::Store::global(cx);
1328 let get_access_token = dev_server_store.update(cx, |store, cx| {
1329 store.regenerate_dev_server_token(dev_server.id, cx)
1330 });
1331
1332 cx.spawn(|mut cx| async move {
1333 let access_token = get_access_token.await?.access_token;
1334
1335 spawn_ssh_task(
1336 workspace,
1337 dev_server_store,
1338 dev_server.id,
1339 ssh_connection_string.to_string(),
1340 access_token,
1341 &mut cx,
1342 )
1343 .await
1344 })
1345}
1346
1347pub async fn spawn_ssh_task(
1348 workspace: View<Workspace>,
1349 dev_server_store: Model<dev_server_projects::Store>,
1350 dev_server_id: DevServerId,
1351 ssh_connection_string: String,
1352 access_token: String,
1353 cx: &mut AsyncWindowContext,
1354) -> Result<()> {
1355 let terminal_panel = workspace
1356 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1357 .ok()
1358 .flatten()
1359 .with_context(|| anyhow!("No terminal panel"))?;
1360
1361 let command = "sh".to_string();
1362 let args = vec![
1363 "-x".to_string(),
1364 "-c".to_string(),
1365 format!(
1366 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 {}"#,
1367 access_token
1368 ),
1369 ];
1370
1371 let ssh_connection_string = ssh_connection_string.to_string();
1372 let (command, args) = wrap_for_ssh(
1373 &SshCommand::DevServer(ssh_connection_string.clone()),
1374 Some((&command, &args)),
1375 None,
1376 HashMap::default(),
1377 None,
1378 );
1379
1380 let terminal = terminal_panel
1381 .update(cx, |terminal_panel, cx| {
1382 terminal_panel.spawn_in_new_terminal(
1383 SpawnInTerminal {
1384 id: task::TaskId("ssh-remote".into()),
1385 full_label: "Install zed over ssh".into(),
1386 label: "Install zed over ssh".into(),
1387 command,
1388 args,
1389 command_label: ssh_connection_string.clone(),
1390 cwd: None,
1391 use_new_terminal: true,
1392 allow_concurrent_runs: false,
1393 reveal: RevealStrategy::Always,
1394 hide: HideStrategy::Never,
1395 env: Default::default(),
1396 shell: Default::default(),
1397 },
1398 cx,
1399 )
1400 })?
1401 .await?;
1402
1403 terminal
1404 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1405 .await;
1406
1407 // 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.
1408 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1409 == DevServerStatus::Offline
1410 {
1411 cx.background_executor()
1412 .timer(Duration::from_millis(200))
1413 .await
1414 }
1415
1416 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1417 == DevServerStatus::Offline
1418 {
1419 return Err(anyhow!("couldn't reconnect"))?;
1420 }
1421
1422 Ok(())
1423}