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