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 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1155 .header(
1156 ModalHeader::new()
1157 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
1158 )
1159 .section(
1160 Section::new().padded(false).child(
1161 h_flex()
1162 .min_h(rems(20.))
1163 .size_full()
1164 .child(
1165 v_flex().size_full().child(ListSeparator).child(
1166 canvas(
1167 |bounds, cx| {
1168 modal_section.prepaint_as_root(
1169 bounds.origin,
1170 bounds.size.into(),
1171 cx,
1172 );
1173 modal_section
1174 },
1175 |_, mut modal_section, cx| {
1176 modal_section.paint(cx);
1177 },
1178 )
1179 .size_full(),
1180 ),
1181 )
1182 .child(
1183 div()
1184 .occlude()
1185 .h_full()
1186 .absolute()
1187 .right_1()
1188 .top_1()
1189 .bottom_1()
1190 .w(px(12.))
1191 .children(Scrollbar::vertical(scroll_state)),
1192 ),
1193 ),
1194 )
1195 }
1196}
1197
1198fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1199 element.read(cx).text(cx).trim().to_string()
1200}
1201
1202impl ModalView for DevServerProjects {}
1203
1204impl FocusableView for DevServerProjects {
1205 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1206 self.focus_handle.clone()
1207 }
1208}
1209
1210impl EventEmitter<DismissEvent> for DevServerProjects {}
1211
1212impl Render for DevServerProjects {
1213 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1214 self.selectable_items.reset();
1215 div()
1216 .track_focus(&self.focus_handle)
1217 .elevation_3(cx)
1218 .key_context("DevServerModal")
1219 .on_action(cx.listener(Self::cancel))
1220 .on_action(cx.listener(Self::confirm))
1221 .on_action(cx.listener(Self::prev_item))
1222 .on_action(cx.listener(Self::next_item))
1223 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1224 this.focus_handle(cx).focus(cx);
1225 }))
1226 .on_mouse_down_out(cx.listener(|this, _, cx| {
1227 if matches!(this.mode, Mode::Default(_)) {
1228 cx.emit(DismissEvent)
1229 }
1230 }))
1231 .w(rems(34.))
1232 .child(match &self.mode {
1233 Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
1234 Mode::ViewServerOptions(index, connection) => self
1235 .render_view_options(*index, connection.clone(), cx)
1236 .into_any_element(),
1237 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1238 Mode::CreateDevServer(state) => {
1239 self.render_create_dev_server(state, cx).into_any_element()
1240 }
1241 Mode::EditNickname(state) => {
1242 self.render_edit_nickname(state, cx).into_any_element()
1243 }
1244 })
1245 }
1246}
1247
1248pub fn reconnect_to_dev_server_project(
1249 workspace: View<Workspace>,
1250 dev_server: DevServer,
1251 dev_server_project_id: DevServerProjectId,
1252 replace_current_window: bool,
1253 cx: &mut WindowContext,
1254) -> Task<Result<()>> {
1255 let store = dev_server_projects::Store::global(cx);
1256 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1257 cx.spawn(|mut cx| async move {
1258 reconnect.await?;
1259
1260 cx.background_executor()
1261 .timer(Duration::from_millis(1000))
1262 .await;
1263
1264 if let Some(project_id) = store.update(&mut cx, |store, _| {
1265 store
1266 .dev_server_project(dev_server_project_id)
1267 .and_then(|p| p.project_id)
1268 })? {
1269 workspace
1270 .update(&mut cx, move |_, cx| {
1271 open_dev_server_project(
1272 replace_current_window,
1273 dev_server_project_id,
1274 project_id,
1275 cx,
1276 )
1277 })?
1278 .await?;
1279 }
1280
1281 Ok(())
1282 })
1283}
1284
1285pub fn reconnect_to_dev_server(
1286 workspace: View<Workspace>,
1287 dev_server: DevServer,
1288 cx: &mut WindowContext,
1289) -> Task<Result<()>> {
1290 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1291 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1292 };
1293 let dev_server_store = dev_server_projects::Store::global(cx);
1294 let get_access_token = dev_server_store.update(cx, |store, cx| {
1295 store.regenerate_dev_server_token(dev_server.id, cx)
1296 });
1297
1298 cx.spawn(|mut cx| async move {
1299 let access_token = get_access_token.await?.access_token;
1300
1301 spawn_ssh_task(
1302 workspace,
1303 dev_server_store,
1304 dev_server.id,
1305 ssh_connection_string.to_string(),
1306 access_token,
1307 &mut cx,
1308 )
1309 .await
1310 })
1311}
1312
1313pub async fn spawn_ssh_task(
1314 workspace: View<Workspace>,
1315 dev_server_store: Model<dev_server_projects::Store>,
1316 dev_server_id: DevServerId,
1317 ssh_connection_string: String,
1318 access_token: String,
1319 cx: &mut AsyncWindowContext,
1320) -> Result<()> {
1321 let terminal_panel = workspace
1322 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1323 .ok()
1324 .flatten()
1325 .with_context(|| anyhow!("No terminal panel"))?;
1326
1327 let command = "sh".to_string();
1328 let args = vec![
1329 "-x".to_string(),
1330 "-c".to_string(),
1331 format!(
1332 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 {}"#,
1333 access_token
1334 ),
1335 ];
1336
1337 let ssh_connection_string = ssh_connection_string.to_string();
1338 let (command, args) = wrap_for_ssh(
1339 &SshCommand::DevServer(ssh_connection_string.clone()),
1340 Some((&command, &args)),
1341 None,
1342 HashMap::default(),
1343 None,
1344 );
1345
1346 let terminal = terminal_panel
1347 .update(cx, |terminal_panel, cx| {
1348 terminal_panel.spawn_in_new_terminal(
1349 SpawnInTerminal {
1350 id: task::TaskId("ssh-remote".into()),
1351 full_label: "Install zed over ssh".into(),
1352 label: "Install zed over ssh".into(),
1353 command,
1354 args,
1355 command_label: ssh_connection_string.clone(),
1356 cwd: None,
1357 use_new_terminal: true,
1358 allow_concurrent_runs: false,
1359 reveal: RevealStrategy::Always,
1360 hide: HideStrategy::Never,
1361 env: Default::default(),
1362 shell: Default::default(),
1363 },
1364 cx,
1365 )
1366 })?
1367 .await?;
1368
1369 terminal
1370 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1371 .await;
1372
1373 // 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.
1374 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1375 == DevServerStatus::Offline
1376 {
1377 cx.background_executor()
1378 .timer(Duration::from_millis(200))
1379 .await
1380 }
1381
1382 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1383 == DevServerStatus::Offline
1384 {
1385 return Err(anyhow!("couldn't reconnect"))?;
1386 }
1387
1388 Ok(())
1389}