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