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