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 cx.new_view(|cx| {
219 let _path_task = cx
220 .spawn({
221 let workspace = workspace.clone();
222 move |_, mut cx| async move {
223 let Ok(Some(paths)) = rx.await else {
224 workspace
225 .update(&mut cx, |workspace, cx| {
226 let weak = cx.view().downgrade();
227 workspace
228 .toggle_modal(cx, |cx| RemoteServerProjects::new(cx, weak));
229 })
230 .log_err()?;
231 return None;
232 };
233
234 let app_state = workspace
235 .update(&mut cx, |workspace, _| workspace.app_state().clone())
236 .ok()?;
237 let options = cx
238 .update(|cx| (app_state.build_window_options)(None, cx))
239 .log_err()?;
240
241 cx.open_window(options, |cx| {
242 cx.activate_window();
243
244 let fs = app_state.fs.clone();
245 update_settings_file::<SshSettings>(fs, cx, {
246 let paths = paths
247 .iter()
248 .map(|path| path.to_string_lossy().to_string())
249 .collect();
250 move |setting, _| {
251 if let Some(server) = setting
252 .ssh_connections
253 .as_mut()
254 .and_then(|connections| connections.get_mut(ix))
255 {
256 server.projects.push(SshProject { paths })
257 }
258 }
259 });
260
261 let tasks = paths
262 .into_iter()
263 .map(|path| {
264 project.update(cx, |project, cx| {
265 project.find_or_create_worktree(&path, true, cx)
266 })
267 })
268 .collect::<Vec<_>>();
269 cx.spawn(|_| async move {
270 for task in tasks {
271 task.await?;
272 }
273 Ok(())
274 })
275 .detach_and_prompt_err(
276 "Failed to open path",
277 cx,
278 |_, _| None,
279 );
280
281 cx.new_view(|cx| {
282 let workspace =
283 Workspace::new(None, project.clone(), app_state.clone(), cx);
284
285 workspace
286 .client()
287 .telemetry()
288 .report_app_event("create ssh project".to_string());
289
290 workspace
291 })
292 })
293 .log_err();
294 Some(())
295 }
296 })
297 .shared();
298
299 Self {
300 _path_task,
301 picker,
302 connection_string,
303 nickname,
304 }
305 })
306 }
307}
308
309impl gpui::Render for ProjectPicker {
310 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
311 v_flex()
312 .child(
313 SshConnectionHeader {
314 connection_string: self.connection_string.clone(),
315 nickname: self.nickname.clone(),
316 }
317 .render(cx),
318 )
319 .child(
320 div()
321 .border_t_1()
322 .border_color(cx.theme().colors().border_variant)
323 .child(self.picker.clone()),
324 )
325 }
326}
327enum Mode {
328 Default(ScrollbarState),
329 ViewServerOptions(usize, SshConnection),
330 EditNickname(EditNicknameState),
331 ProjectPicker(View<ProjectPicker>),
332 CreateRemoteServer(CreateRemoteServer),
333}
334
335impl Mode {
336 fn default_mode() -> Self {
337 let handle = ScrollHandle::new();
338 Self::Default(ScrollbarState::new(handle))
339 }
340}
341impl RemoteServerProjects {
342 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
343 workspace.register_action(|workspace, _: &OpenRemote, cx| {
344 let handle = cx.view().downgrade();
345 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
346 });
347 }
348
349 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
350 workspace.update(cx, |workspace, cx| {
351 let handle = cx.view().downgrade();
352 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
353 })
354 }
355
356 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
357 let focus_handle = cx.focus_handle();
358
359 let mut base_style = cx.text_style();
360 base_style.refine(&gpui::TextStyleRefinement {
361 color: Some(cx.theme().colors().editor_foreground),
362 ..Default::default()
363 });
364
365 Self {
366 mode: Mode::default_mode(),
367 focus_handle,
368 scroll_handle: ScrollHandle::new(),
369 workspace,
370 selectable_items: Default::default(),
371 }
372 }
373
374 fn scroll_to_selected(&self, _: &mut ViewContext<Self>) {
375 if let Mode::Default(scroll_state) = &self.mode {
376 if let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() {
377 if let Some(active_item) = self.selectable_items.active_item {
378 scroll_handle.scroll_to_item(active_item);
379 }
380 }
381 }
382 }
383
384 fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
385 if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
386 return;
387 }
388 self.selectable_items.next(cx);
389 cx.notify();
390 self.scroll_to_selected(cx);
391 }
392
393 fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
394 if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
395 return;
396 }
397 self.selectable_items.prev(cx);
398 cx.notify();
399 self.scroll_to_selected(cx);
400 }
401
402 pub fn project_picker(
403 ix: usize,
404 connection_options: remote::SshConnectionOptions,
405 project: Model<Project>,
406 cx: &mut ViewContext<Self>,
407 workspace: WeakView<Workspace>,
408 ) -> Self {
409 let mut this = Self::new(cx, workspace.clone());
410 this.mode = Mode::ProjectPicker(ProjectPicker::new(
411 ix,
412 connection_options,
413 project,
414 workspace,
415 cx,
416 ));
417 cx.notify();
418
419 this
420 }
421
422 fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
423 let input = get_text(&editor, cx);
424 if input.is_empty() {
425 return;
426 }
427
428 let connection_options = match SshConnectionOptions::parse_command_line(&input) {
429 Ok(c) => c,
430 Err(e) => {
431 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
432 address_editor: editor,
433 address_error: Some(format!("could not parse: {:?}", e).into()),
434 ssh_prompt: None,
435 _creating: None,
436 });
437 return;
438 }
439 };
440 let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, None, cx));
441
442 let connection = connect_over_ssh(
443 connection_options.remote_server_identifier(),
444 connection_options.clone(),
445 ssh_prompt.clone(),
446 cx,
447 )
448 .prompt_err("Failed to connect", cx, |_, _| None);
449
450 let address_editor = editor.clone();
451 let creating = cx.spawn(move |this, mut cx| async move {
452 match connection.await {
453 Some(_) => this
454 .update(&mut cx, |this, cx| {
455 let _ = this.workspace.update(cx, |workspace, _| {
456 workspace
457 .client()
458 .telemetry()
459 .report_app_event("create ssh server".to_string())
460 });
461
462 this.add_ssh_server(connection_options, cx);
463 this.mode = Mode::default_mode();
464 this.selectable_items.reset_selection();
465 cx.notify()
466 })
467 .log_err(),
468 None => this
469 .update(&mut cx, |this, cx| {
470 address_editor.update(cx, |this, _| {
471 this.set_read_only(false);
472 });
473 this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
474 address_editor,
475 address_error: None,
476 ssh_prompt: None,
477 _creating: None,
478 });
479 cx.notify()
480 })
481 .log_err(),
482 };
483 None
484 });
485
486 editor.update(cx, |this, _| {
487 this.set_read_only(true);
488 });
489 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
490 address_editor: editor,
491 address_error: None,
492 ssh_prompt: Some(ssh_prompt.clone()),
493 _creating: Some(creating),
494 });
495 }
496
497 fn view_server_options(
498 &mut self,
499 (index, connection): (usize, SshConnection),
500 cx: &mut ViewContext<Self>,
501 ) {
502 self.selectable_items.reset_selection();
503 self.mode = Mode::ViewServerOptions(index, connection);
504 cx.notify();
505 }
506
507 fn create_ssh_project(
508 &mut self,
509 ix: usize,
510 ssh_connection: SshConnection,
511 cx: &mut ViewContext<Self>,
512 ) {
513 let Some(workspace) = self.workspace.upgrade() else {
514 return;
515 };
516
517 let nickname = ssh_connection.nickname.clone();
518 let connection_options = ssh_connection.into();
519 workspace.update(cx, |_, cx| {
520 cx.defer(move |workspace, cx| {
521 workspace.toggle_modal(cx, |cx| {
522 SshConnectionModal::new(&connection_options, nickname, cx)
523 });
524 let prompt = workspace
525 .active_modal::<SshConnectionModal>(cx)
526 .unwrap()
527 .read(cx)
528 .prompt
529 .clone();
530
531 let connect = connect_over_ssh(
532 connection_options.remote_server_identifier(),
533 connection_options.clone(),
534 prompt,
535 cx,
536 )
537 .prompt_err("Failed to connect", cx, |_, _| None);
538
539 cx.spawn(move |workspace, mut cx| async move {
540 let session = connect.await;
541
542 workspace
543 .update(&mut cx, |workspace, cx| {
544 if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
545 prompt.update(cx, |prompt, cx| prompt.finished(cx))
546 }
547 })
548 .ok();
549
550 let Some(Some(session)) = session else {
551 workspace
552 .update(&mut cx, |workspace, cx| {
553 let weak = cx.view().downgrade();
554 workspace
555 .toggle_modal(cx, |cx| RemoteServerProjects::new(cx, weak));
556 })
557 .log_err();
558 return;
559 };
560
561 workspace
562 .update(&mut cx, |workspace, cx| {
563 let app_state = workspace.app_state().clone();
564 let weak = cx.view().downgrade();
565 let project = project::Project::ssh(
566 session,
567 app_state.client.clone(),
568 app_state.node_runtime.clone(),
569 app_state.user_store.clone(),
570 app_state.languages.clone(),
571 app_state.fs.clone(),
572 cx,
573 );
574 workspace.toggle_modal(cx, |cx| {
575 RemoteServerProjects::project_picker(
576 ix,
577 connection_options,
578 project,
579 cx,
580 weak,
581 )
582 });
583 })
584 .ok();
585 })
586 .detach()
587 })
588 })
589 }
590
591 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
592 match &self.mode {
593 Mode::Default(_) | Mode::ViewServerOptions(_, _) => {
594 let items = std::mem::take(&mut self.selectable_items);
595 items.confirm(self, cx);
596 self.selectable_items = items;
597 }
598 Mode::ProjectPicker(_) => {}
599 Mode::CreateRemoteServer(state) => {
600 if let Some(prompt) = state.ssh_prompt.as_ref() {
601 prompt.update(cx, |prompt, cx| {
602 prompt.confirm(cx);
603 });
604 return;
605 }
606
607 self.create_ssh_server(state.address_editor.clone(), cx);
608 }
609 Mode::EditNickname(state) => {
610 let text = Some(state.editor.read(cx).text(cx))
611 .filter(|text| !text.is_empty())
612 .map(SharedString::from);
613 let index = state.index;
614 self.update_settings_file(cx, move |setting, _| {
615 if let Some(connections) = setting.ssh_connections.as_mut() {
616 if let Some(connection) = connections.get_mut(index) {
617 connection.nickname = text;
618 }
619 }
620 });
621 self.mode = Mode::default_mode();
622 self.selectable_items.reset_selection();
623 self.focus_handle.focus(cx);
624 }
625 }
626 }
627
628 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
629 match &self.mode {
630 Mode::Default(_) => cx.emit(DismissEvent),
631 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
632 let new_state = CreateRemoteServer::new(cx);
633 let old_prompt = state.address_editor.read(cx).text(cx);
634 new_state.address_editor.update(cx, |this, cx| {
635 this.set_text(old_prompt, cx);
636 });
637
638 self.mode = Mode::CreateRemoteServer(new_state);
639 self.selectable_items.reset_selection();
640 cx.notify();
641 }
642 _ => {
643 self.mode = Mode::default_mode();
644 self.selectable_items.reset_selection();
645 self.focus_handle(cx).focus(cx);
646 cx.notify();
647 }
648 }
649 }
650
651 fn render_ssh_connection(
652 &mut self,
653 ix: usize,
654 ssh_connection: SshConnection,
655 cx: &mut ViewContext<Self>,
656 ) -> impl IntoElement {
657 let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() {
658 let aux_label = SharedString::from(format!("({})", ssh_connection.host));
659 (nickname, Some(aux_label))
660 } else {
661 (ssh_connection.host.clone(), None)
662 };
663 v_flex()
664 .w_full()
665 .child(ListSeparator)
666 .child(
667 h_flex()
668 .group("ssh-server")
669 .w_full()
670 .pt_0p5()
671 .px_3()
672 .gap_1()
673 .overflow_hidden()
674 .whitespace_nowrap()
675 .child(
676 Label::new(main_label)
677 .size(LabelSize::Small)
678 .color(Color::Muted),
679 )
680 .children(
681 aux_label.map(|label| {
682 Label::new(label).size(LabelSize::Small).color(Color::Muted)
683 }),
684 ),
685 )
686 .child(
687 List::new()
688 .empty_message("No projects.")
689 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
690 v_flex().gap_0p5().child(self.render_ssh_project(
691 ix,
692 &ssh_connection,
693 pix,
694 p,
695 cx,
696 ))
697 }))
698 .child(h_flex().map(|this| {
699 self.selectable_items.add_item(Box::new({
700 let ssh_connection = ssh_connection.clone();
701 move |this, cx| {
702 this.create_ssh_project(ix, ssh_connection.clone(), cx);
703 }
704 }));
705 let is_selected = self.selectable_items.is_selected();
706 this.child(
707 ListItem::new(("new-remote-project", ix))
708 .selected(is_selected)
709 .inset(true)
710 .spacing(ui::ListItemSpacing::Sparse)
711 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
712 .child(Label::new("Open Folder"))
713 .on_click(cx.listener({
714 let ssh_connection = ssh_connection.clone();
715 move |this, _, cx| {
716 this.create_ssh_project(ix, ssh_connection.clone(), cx);
717 }
718 })),
719 )
720 }))
721 .child(h_flex().map(|this| {
722 self.selectable_items.add_item(Box::new({
723 let ssh_connection = ssh_connection.clone();
724 move |this, cx| {
725 this.view_server_options((ix, ssh_connection.clone()), cx);
726 }
727 }));
728 let is_selected = self.selectable_items.is_selected();
729 this.child(
730 ListItem::new(("server-options", ix))
731 .selected(is_selected)
732 .inset(true)
733 .spacing(ui::ListItemSpacing::Sparse)
734 .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
735 .child(Label::new("View Server Options"))
736 .on_click(cx.listener({
737 let ssh_connection = ssh_connection.clone();
738 move |this, _, cx| {
739 this.view_server_options((ix, ssh_connection.clone()), cx);
740 }
741 })),
742 )
743 })),
744 )
745 }
746
747 fn render_ssh_project(
748 &mut self,
749 server_ix: usize,
750 server: &SshConnection,
751 ix: usize,
752 project: &SshProject,
753 cx: &ViewContext<Self>,
754 ) -> impl IntoElement {
755 let server = server.clone();
756
757 let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
758 let callback = Arc::new({
759 let project = project.clone();
760 move |this: &mut Self, cx: &mut ViewContext<Self>| {
761 let Some(app_state) = this
762 .workspace
763 .update(cx, |workspace, _| workspace.app_state().clone())
764 .log_err()
765 else {
766 return;
767 };
768 let project = project.clone();
769 let server = server.clone();
770 cx.spawn(|_, mut cx| async move {
771 let nickname = server.nickname.clone();
772 let result = open_ssh_project(
773 server.into(),
774 project.paths.into_iter().map(PathBuf::from).collect(),
775 app_state,
776 OpenOptions::default(),
777 nickname,
778 &mut cx,
779 )
780 .await;
781 if let Err(e) = result {
782 log::error!("Failed to connect: {:?}", e);
783 cx.prompt(
784 gpui::PromptLevel::Critical,
785 "Failed to connect",
786 Some(&e.to_string()),
787 &["Ok"],
788 )
789 .await
790 .ok();
791 }
792 })
793 .detach();
794 }
795 });
796 self.selectable_items.add_item(Box::new({
797 let callback = callback.clone();
798 move |this, cx| callback(this, cx)
799 }));
800 let is_selected = self.selectable_items.is_selected();
801
802 ListItem::new((element_id_base, ix))
803 .inset(true)
804 .selected(is_selected)
805 .spacing(ui::ListItemSpacing::Sparse)
806 .start_slot(
807 Icon::new(IconName::Folder)
808 .color(Color::Muted)
809 .size(IconSize::Small),
810 )
811 .child(Label::new(project.paths.join(", ")))
812 .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
813 .end_hover_slot::<AnyElement>(Some(
814 IconButton::new("remove-remote-project", IconName::TrashAlt)
815 .icon_size(IconSize::Small)
816 .shape(IconButtonShape::Square)
817 .on_click(
818 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
819 )
820 .size(ButtonSize::Large)
821 .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
822 .into_any_element(),
823 ))
824 }
825
826 fn update_settings_file(
827 &mut self,
828 cx: &mut ViewContext<Self>,
829 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
830 ) {
831 let Some(fs) = self
832 .workspace
833 .update(cx, |workspace, _| workspace.app_state().fs.clone())
834 .log_err()
835 else {
836 return;
837 };
838 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
839 }
840
841 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
842 self.update_settings_file(cx, move |setting, _| {
843 if let Some(connections) = setting.ssh_connections.as_mut() {
844 connections.remove(server);
845 }
846 });
847 }
848
849 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
850 self.update_settings_file(cx, move |setting, _| {
851 if let Some(server) = setting
852 .ssh_connections
853 .as_mut()
854 .and_then(|connections| connections.get_mut(server))
855 {
856 server.projects.remove(project);
857 }
858 });
859 }
860
861 fn add_ssh_server(
862 &mut self,
863 connection_options: remote::SshConnectionOptions,
864 cx: &mut ViewContext<Self>,
865 ) {
866 self.update_settings_file(cx, move |setting, _| {
867 setting
868 .ssh_connections
869 .get_or_insert(Default::default())
870 .push(SshConnection {
871 host: SharedString::from(connection_options.host),
872 username: connection_options.username,
873 port: connection_options.port,
874 projects: vec![],
875 nickname: None,
876 args: connection_options.args.unwrap_or_default(),
877 })
878 });
879 }
880
881 fn render_create_remote_server(
882 &self,
883 state: &CreateRemoteServer,
884 cx: &mut ViewContext<Self>,
885 ) -> impl IntoElement {
886 let ssh_prompt = state.ssh_prompt.clone();
887
888 state.address_editor.update(cx, |editor, cx| {
889 if editor.text(cx).is_empty() {
890 editor.set_placeholder_text("ssh user@example -p 2222", cx);
891 }
892 });
893
894 let theme = cx.theme();
895
896 v_flex()
897 .id("create-remote-server")
898 .overflow_hidden()
899 .size_full()
900 .flex_1()
901 .child(
902 div()
903 .p_2()
904 .border_b_1()
905 .border_color(theme.colors().border_variant)
906 .child(state.address_editor.clone()),
907 )
908 .child(
909 h_flex()
910 .bg(theme.colors().editor_background)
911 .rounded_b_md()
912 .w_full()
913 .map(|this| {
914 if let Some(ssh_prompt) = ssh_prompt {
915 this.child(h_flex().w_full().child(ssh_prompt))
916 } else if let Some(address_error) = &state.address_error {
917 this.child(
918 h_flex().p_2().w_full().gap_2().child(
919 Label::new(address_error.clone())
920 .size(LabelSize::Small)
921 .color(Color::Error),
922 ),
923 )
924 } else {
925 this.child(
926 h_flex()
927 .p_2()
928 .w_full()
929 .gap_1()
930 .child(
931 Label::new(
932 "Enter the command you use to SSH into this server.",
933 )
934 .color(Color::Muted)
935 .size(LabelSize::Small),
936 )
937 .child(
938 Button::new("learn-more", "Learn more…")
939 .label_size(LabelSize::Small)
940 .size(ButtonSize::None)
941 .color(Color::Accent)
942 .style(ButtonStyle::Transparent)
943 .on_click(|_, cx| {
944 cx.open_url(
945 "https://zed.dev/docs/remote-development",
946 );
947 }),
948 ),
949 )
950 }
951 }),
952 )
953 }
954
955 fn render_view_options(
956 &mut self,
957 index: usize,
958 connection: SshConnection,
959 cx: &mut ViewContext<Self>,
960 ) -> impl IntoElement {
961 let connection_string = connection.host.clone();
962
963 div()
964 .size_full()
965 .child(
966 SshConnectionHeader {
967 connection_string: connection_string.clone(),
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 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(ListSeparator)
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 .group("remote-projects-section")
1236 .size_full()
1237 .relative()
1238 .child(ListSeparator)
1239 .child(
1240 canvas(
1241 |bounds, cx| {
1242 modal_section.prepaint_as_root(
1243 bounds.origin,
1244 bounds.size.into(),
1245 cx,
1246 );
1247 modal_section
1248 },
1249 |_, mut modal_section, cx| {
1250 modal_section.paint(cx);
1251 },
1252 )
1253 .size_full(),
1254 )
1255 .child(
1256 div()
1257 .visible_on_hover("remote-projects-section")
1258 .occlude()
1259 .h_full()
1260 .absolute()
1261 .top_1()
1262 .bottom_1()
1263 .right_1()
1264 .w(px(8.))
1265 .children(Scrollbar::vertical(scroll_state)),
1266 ),
1267 ),
1268 )
1269 }
1270}
1271
1272fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1273 element.read(cx).text(cx).trim().to_string()
1274}
1275
1276impl ModalView for RemoteServerProjects {}
1277
1278impl FocusableView for RemoteServerProjects {
1279 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
1280 match &self.mode {
1281 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1282 _ => self.focus_handle.clone(),
1283 }
1284 }
1285}
1286
1287impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1288
1289impl Render for RemoteServerProjects {
1290 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1291 self.selectable_items.reset();
1292 div()
1293 .track_focus(&self.focus_handle)
1294 .elevation_3(cx)
1295 .w(rems(34.))
1296 .key_context("RemoteServerModal")
1297 .on_action(cx.listener(Self::cancel))
1298 .on_action(cx.listener(Self::confirm))
1299 .on_action(cx.listener(Self::prev_item))
1300 .on_action(cx.listener(Self::next_item))
1301 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1302 this.focus_handle(cx).focus(cx);
1303 }))
1304 .on_mouse_down_out(cx.listener(|this, _, cx| {
1305 if matches!(this.mode, Mode::Default(_)) {
1306 cx.emit(DismissEvent)
1307 }
1308 }))
1309 .child(match &self.mode {
1310 Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
1311 Mode::ViewServerOptions(index, connection) => self
1312 .render_view_options(*index, connection.clone(), cx)
1313 .into_any_element(),
1314 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1315 Mode::CreateRemoteServer(state) => self
1316 .render_create_remote_server(state, cx)
1317 .into_any_element(),
1318 Mode::EditNickname(state) => {
1319 self.render_edit_nickname(state, cx).into_any_element()
1320 }
1321 })
1322 }
1323}
1324
1325pub fn reconnect_to_dev_server_project(
1326 workspace: View<Workspace>,
1327 dev_server: DevServer,
1328 dev_server_project_id: DevServerProjectId,
1329 replace_current_window: bool,
1330 cx: &mut WindowContext,
1331) -> Task<Result<()>> {
1332 let store = dev_server_projects::Store::global(cx);
1333 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1334 cx.spawn(|mut cx| async move {
1335 reconnect.await?;
1336
1337 cx.background_executor()
1338 .timer(Duration::from_millis(1000))
1339 .await;
1340
1341 if let Some(project_id) = store.update(&mut cx, |store, _| {
1342 store
1343 .dev_server_project(dev_server_project_id)
1344 .and_then(|p| p.project_id)
1345 })? {
1346 workspace
1347 .update(&mut cx, move |_, cx| {
1348 open_dev_server_project(
1349 replace_current_window,
1350 dev_server_project_id,
1351 project_id,
1352 cx,
1353 )
1354 })?
1355 .await?;
1356 }
1357
1358 Ok(())
1359 })
1360}
1361
1362pub fn reconnect_to_dev_server(
1363 workspace: View<Workspace>,
1364 dev_server: DevServer,
1365 cx: &mut WindowContext,
1366) -> Task<Result<()>> {
1367 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1368 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1369 };
1370 let dev_server_store = dev_server_projects::Store::global(cx);
1371 let get_access_token = dev_server_store.update(cx, |store, cx| {
1372 store.regenerate_dev_server_token(dev_server.id, cx)
1373 });
1374
1375 cx.spawn(|mut cx| async move {
1376 let access_token = get_access_token.await?.access_token;
1377
1378 spawn_ssh_task(
1379 workspace,
1380 dev_server_store,
1381 dev_server.id,
1382 ssh_connection_string.to_string(),
1383 access_token,
1384 &mut cx,
1385 )
1386 .await
1387 })
1388}
1389
1390pub async fn spawn_ssh_task(
1391 workspace: View<Workspace>,
1392 dev_server_store: Model<dev_server_projects::Store>,
1393 dev_server_id: DevServerId,
1394 ssh_connection_string: String,
1395 access_token: String,
1396 cx: &mut AsyncWindowContext,
1397) -> Result<()> {
1398 let terminal_panel = workspace
1399 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1400 .ok()
1401 .flatten()
1402 .with_context(|| anyhow!("No terminal panel"))?;
1403
1404 let command = "sh".to_string();
1405 let args = vec![
1406 "-x".to_string(),
1407 "-c".to_string(),
1408 format!(
1409 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 {}"#,
1410 access_token
1411 ),
1412 ];
1413
1414 let ssh_connection_string = ssh_connection_string.to_string();
1415 let (command, args) = wrap_for_ssh(
1416 &SshCommand::DevServer(ssh_connection_string.clone()),
1417 Some((&command, &args)),
1418 None,
1419 HashMap::default(),
1420 None,
1421 );
1422
1423 let terminal = terminal_panel
1424 .update(cx, |terminal_panel, cx| {
1425 terminal_panel.spawn_in_new_terminal(
1426 SpawnInTerminal {
1427 id: task::TaskId("ssh-remote".into()),
1428 full_label: "Install zed over ssh".into(),
1429 label: "Install zed over ssh".into(),
1430 command,
1431 args,
1432 command_label: ssh_connection_string.clone(),
1433 cwd: None,
1434 use_new_terminal: true,
1435 allow_concurrent_runs: false,
1436 reveal: RevealStrategy::Always,
1437 hide: HideStrategy::Never,
1438 env: Default::default(),
1439 shell: Default::default(),
1440 },
1441 cx,
1442 )
1443 })?
1444 .await?;
1445
1446 terminal
1447 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1448 .await;
1449
1450 // 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.
1451 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1452 == DevServerStatus::Offline
1453 {
1454 cx.background_executor()
1455 .timer(Duration::from_millis(200))
1456 .await
1457 }
1458
1459 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1460 == DevServerStatus::Offline
1461 {
1462 return Err(anyhow!("couldn't reconnect"))?;
1463 }
1464
1465 Ok(())
1466}