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