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, PromptLevel, 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 index: usize,
990 connection_string: SharedString,
991 cx: &mut WindowContext<'_>,
992 ) {
993 let prompt_message = format!("Remove server `{}`?", connection_string);
994
995 let confirmation = cx.prompt(
996 PromptLevel::Warning,
997 &prompt_message,
998 None,
999 &["Yes, remove it", "No, keep it"],
1000 );
1001
1002 cx.spawn(|mut cx| async move {
1003 if confirmation.await.ok() == Some(0) {
1004 dev_servers
1005 .update(&mut cx, |this, cx| {
1006 this.delete_ssh_server(index, cx);
1007 this.mode = Mode::default_mode();
1008 cx.notify();
1009 })
1010 .ok();
1011 }
1012 anyhow::Ok(())
1013 })
1014 .detach_and_log_err(cx);
1015 }
1016 self.selectable_items.add_item(Box::new({
1017 let connection_string = connection_string.clone();
1018 move |_, cx| {
1019 remove_ssh_server(
1020 cx.view().clone(),
1021 index,
1022 connection_string.clone(),
1023 cx,
1024 );
1025 }
1026 }));
1027 let is_selected = self.selectable_items.is_selected();
1028 ListItem::new("remove-server")
1029 .selected(is_selected)
1030 .inset(true)
1031 .spacing(ui::ListItemSpacing::Sparse)
1032 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1033 .child(Label::new("Remove Server").color(Color::Error))
1034 .on_click(cx.listener(move |_, _, cx| {
1035 remove_ssh_server(
1036 cx.view().clone(),
1037 index,
1038 connection_string.clone(),
1039 cx,
1040 );
1041 }))
1042 })
1043 .child(ListSeparator)
1044 .child({
1045 self.selectable_items.add_item(Box::new({
1046 move |this, cx| {
1047 this.mode = Mode::default_mode();
1048 cx.notify();
1049 }
1050 }));
1051 let is_selected = self.selectable_items.is_selected();
1052 ListItem::new("go-back")
1053 .selected(is_selected)
1054 .inset(true)
1055 .spacing(ui::ListItemSpacing::Sparse)
1056 .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
1057 .child(Label::new("Go Back"))
1058 .on_click(cx.listener(|this, _, cx| {
1059 this.mode = Mode::default_mode();
1060 cx.notify()
1061 }))
1062 }),
1063 )
1064 }
1065
1066 fn render_edit_nickname(
1067 &self,
1068 state: &EditNicknameState,
1069 cx: &mut ViewContext<Self>,
1070 ) -> impl IntoElement {
1071 let Some(connection) = SshSettings::get_global(cx)
1072 .ssh_connections()
1073 .nth(state.index)
1074 else {
1075 return v_flex();
1076 };
1077
1078 let connection_string = connection.host.clone();
1079
1080 v_flex()
1081 .child(
1082 SshConnectionHeader {
1083 connection_string,
1084 nickname: connection.nickname.clone(),
1085 }
1086 .render(cx),
1087 )
1088 .child(h_flex().p_2().child(state.editor.clone()))
1089 }
1090
1091 fn render_default(
1092 &mut self,
1093 scroll_state: ScrollbarState,
1094 cx: &mut ViewContext<Self>,
1095 ) -> impl IntoElement {
1096 let scroll_state = scroll_state.parent_view(cx.view());
1097 let ssh_connections = SshSettings::get_global(cx)
1098 .ssh_connections()
1099 .collect::<Vec<_>>();
1100 self.selectable_items.add_item(Box::new(|this, cx| {
1101 this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
1102 cx.notify();
1103 }));
1104
1105 let is_selected = self.selectable_items.is_selected();
1106
1107 let connect_button = ListItem::new("register-dev-server-button")
1108 .selected(is_selected)
1109 .inset(true)
1110 .spacing(ui::ListItemSpacing::Sparse)
1111 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1112 .child(Label::new("Connect New Server"))
1113 .on_click(cx.listener(|this, _, cx| {
1114 let state = CreateDevServer::new(cx);
1115 this.mode = Mode::CreateDevServer(state);
1116
1117 cx.notify();
1118 }));
1119
1120 let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
1121 unreachable!()
1122 };
1123
1124 let mut modal_section = v_flex()
1125 .id("ssh-server-list")
1126 .overflow_y_scroll()
1127 .track_scroll(&scroll_handle)
1128 .size_full()
1129 .child(connect_button)
1130 .child(
1131 h_flex().child(
1132 List::new()
1133 .empty_message(
1134 v_flex()
1135 .child(ListSeparator)
1136 .child(
1137 div().px_3().child(
1138 Label::new("No dev servers registered yet.")
1139 .color(Color::Muted),
1140 ),
1141 )
1142 .into_any_element(),
1143 )
1144 .children(ssh_connections.iter().cloned().enumerate().map(
1145 |(ix, connection)| {
1146 self.render_ssh_connection(ix, connection, cx)
1147 .into_any_element()
1148 },
1149 )),
1150 ),
1151 )
1152 .into_any_element();
1153
1154 let server_count = format!("Servers: {}", ssh_connections.len());
1155
1156 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1157 .header(
1158 ModalHeader::new().child(
1159 h_flex()
1160 .items_center()
1161 .justify_between()
1162 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
1163 .child(Label::new(server_count).size(LabelSize::Small)),
1164 ),
1165 )
1166 .section(
1167 Section::new().padded(false).child(
1168 h_flex()
1169 .min_h(rems(20.))
1170 .size_full()
1171 .child(
1172 v_flex().size_full().child(ListSeparator).child(
1173 canvas(
1174 |bounds, cx| {
1175 modal_section.prepaint_as_root(
1176 bounds.origin,
1177 bounds.size.into(),
1178 cx,
1179 );
1180 modal_section
1181 },
1182 |_, mut modal_section, cx| {
1183 modal_section.paint(cx);
1184 },
1185 )
1186 .size_full(),
1187 ),
1188 )
1189 .child(
1190 div()
1191 .occlude()
1192 .h_full()
1193 .absolute()
1194 .right_1()
1195 .top_1()
1196 .bottom_1()
1197 .w(px(12.))
1198 .children(Scrollbar::vertical(scroll_state)),
1199 ),
1200 ),
1201 )
1202 }
1203}
1204
1205fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1206 element.read(cx).text(cx).trim().to_string()
1207}
1208
1209impl ModalView for DevServerProjects {}
1210
1211impl FocusableView for DevServerProjects {
1212 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1213 self.focus_handle.clone()
1214 }
1215}
1216
1217impl EventEmitter<DismissEvent> for DevServerProjects {}
1218
1219impl Render for DevServerProjects {
1220 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1221 self.selectable_items.reset();
1222 div()
1223 .track_focus(&self.focus_handle)
1224 .elevation_3(cx)
1225 .key_context("DevServerModal")
1226 .on_action(cx.listener(Self::cancel))
1227 .on_action(cx.listener(Self::confirm))
1228 .on_action(cx.listener(Self::prev_item))
1229 .on_action(cx.listener(Self::next_item))
1230 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1231 this.focus_handle(cx).focus(cx);
1232 }))
1233 .on_mouse_down_out(cx.listener(|this, _, cx| {
1234 if matches!(this.mode, Mode::Default(_)) {
1235 cx.emit(DismissEvent)
1236 }
1237 }))
1238 .w(rems(34.))
1239 .child(match &self.mode {
1240 Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
1241 Mode::ViewServerOptions(index, connection) => self
1242 .render_view_options(*index, connection.clone(), cx)
1243 .into_any_element(),
1244 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1245 Mode::CreateDevServer(state) => {
1246 self.render_create_dev_server(state, cx).into_any_element()
1247 }
1248 Mode::EditNickname(state) => {
1249 self.render_edit_nickname(state, cx).into_any_element()
1250 }
1251 })
1252 }
1253}
1254
1255pub fn reconnect_to_dev_server_project(
1256 workspace: View<Workspace>,
1257 dev_server: DevServer,
1258 dev_server_project_id: DevServerProjectId,
1259 replace_current_window: bool,
1260 cx: &mut WindowContext,
1261) -> Task<Result<()>> {
1262 let store = dev_server_projects::Store::global(cx);
1263 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1264 cx.spawn(|mut cx| async move {
1265 reconnect.await?;
1266
1267 cx.background_executor()
1268 .timer(Duration::from_millis(1000))
1269 .await;
1270
1271 if let Some(project_id) = store.update(&mut cx, |store, _| {
1272 store
1273 .dev_server_project(dev_server_project_id)
1274 .and_then(|p| p.project_id)
1275 })? {
1276 workspace
1277 .update(&mut cx, move |_, cx| {
1278 open_dev_server_project(
1279 replace_current_window,
1280 dev_server_project_id,
1281 project_id,
1282 cx,
1283 )
1284 })?
1285 .await?;
1286 }
1287
1288 Ok(())
1289 })
1290}
1291
1292pub fn reconnect_to_dev_server(
1293 workspace: View<Workspace>,
1294 dev_server: DevServer,
1295 cx: &mut WindowContext,
1296) -> Task<Result<()>> {
1297 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1298 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1299 };
1300 let dev_server_store = dev_server_projects::Store::global(cx);
1301 let get_access_token = dev_server_store.update(cx, |store, cx| {
1302 store.regenerate_dev_server_token(dev_server.id, cx)
1303 });
1304
1305 cx.spawn(|mut cx| async move {
1306 let access_token = get_access_token.await?.access_token;
1307
1308 spawn_ssh_task(
1309 workspace,
1310 dev_server_store,
1311 dev_server.id,
1312 ssh_connection_string.to_string(),
1313 access_token,
1314 &mut cx,
1315 )
1316 .await
1317 })
1318}
1319
1320pub async fn spawn_ssh_task(
1321 workspace: View<Workspace>,
1322 dev_server_store: Model<dev_server_projects::Store>,
1323 dev_server_id: DevServerId,
1324 ssh_connection_string: String,
1325 access_token: String,
1326 cx: &mut AsyncWindowContext,
1327) -> Result<()> {
1328 let terminal_panel = workspace
1329 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1330 .ok()
1331 .flatten()
1332 .with_context(|| anyhow!("No terminal panel"))?;
1333
1334 let command = "sh".to_string();
1335 let args = vec![
1336 "-x".to_string(),
1337 "-c".to_string(),
1338 format!(
1339 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 {}"#,
1340 access_token
1341 ),
1342 ];
1343
1344 let ssh_connection_string = ssh_connection_string.to_string();
1345 let (command, args) = wrap_for_ssh(
1346 &SshCommand::DevServer(ssh_connection_string.clone()),
1347 Some((&command, &args)),
1348 None,
1349 HashMap::default(),
1350 None,
1351 );
1352
1353 let terminal = terminal_panel
1354 .update(cx, |terminal_panel, cx| {
1355 terminal_panel.spawn_in_new_terminal(
1356 SpawnInTerminal {
1357 id: task::TaskId("ssh-remote".into()),
1358 full_label: "Install zed over ssh".into(),
1359 label: "Install zed over ssh".into(),
1360 command,
1361 args,
1362 command_label: ssh_connection_string.clone(),
1363 cwd: None,
1364 use_new_terminal: true,
1365 allow_concurrent_runs: false,
1366 reveal: RevealStrategy::Always,
1367 hide: HideStrategy::Never,
1368 env: Default::default(),
1369 shell: Default::default(),
1370 },
1371 cx,
1372 )
1373 })?
1374 .await?;
1375
1376 terminal
1377 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1378 .await;
1379
1380 // 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.
1381 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1382 == DevServerStatus::Offline
1383 {
1384 cx.background_executor()
1385 .timer(Duration::from_millis(200))
1386 .await
1387 }
1388
1389 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1390 == DevServerStatus::Offline
1391 {
1392 return Err(anyhow!("couldn't reconnect"))?;
1393 }
1394
1395 Ok(())
1396}