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