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