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