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