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