1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use anyhow::anyhow;
6use anyhow::Context;
7use anyhow::Result;
8use client::Client;
9use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
10use editor::Editor;
11use gpui::pulsating_between;
12use gpui::AsyncWindowContext;
13use gpui::ClipboardItem;
14use gpui::PathPromptOptions;
15use gpui::Subscription;
16use gpui::Task;
17use gpui::WeakView;
18use gpui::{
19 percentage, Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent,
20 EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View,
21 ViewContext,
22};
23use project::terminals::wrap_for_ssh;
24use project::terminals::SshCommand;
25use rpc::proto::RegenerateDevServerTokenResponse;
26use rpc::{
27 proto::{CreateDevServerResponse, DevServerStatus},
28 ErrorCode, ErrorExt,
29};
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::ElevationIndex;
37use ui::Section;
38use ui::{
39 prelude::*, IconButtonShape, Indicator, List, ListItem, Modal, ModalFooter, ModalHeader,
40 Tooltip,
41};
42use ui_input::{FieldLabelLayout, TextField};
43use util::ResultExt;
44use workspace::OpenOptions;
45use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
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::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 dev_server_store: Model<dev_server_projects::Store>,
63 workspace: WeakView<Workspace>,
64 project_path_input: View<Editor>,
65 dev_server_name_input: View<TextField>,
66 _dev_server_subscription: Subscription,
67}
68
69#[derive(Default)]
70struct CreateDevServer {
71 creating: Option<Task<Option<()>>>,
72 dev_server_id: Option<DevServerId>,
73 access_token: Option<String>,
74 ssh_prompt: Option<View<SshPrompt>>,
75 kind: NewServerKind,
76}
77
78struct CreateDevServerProject {
79 dev_server_id: DevServerId,
80 creating: bool,
81 _opening: Option<Subscription>,
82}
83
84enum Mode {
85 Default(Option<CreateDevServerProject>),
86 CreateDevServer(CreateDevServer),
87}
88
89#[derive(Default, PartialEq, Eq, Clone, Copy)]
90enum NewServerKind {
91 DirectSSH,
92 #[default]
93 LegacySSH,
94 Manual,
95}
96
97impl DevServerProjects {
98 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
99 workspace.register_action(|workspace, _: &OpenRemote, cx| {
100 let handle = cx.view().downgrade();
101 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
102 });
103 }
104
105 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
106 workspace.update(cx, |workspace, cx| {
107 let handle = cx.view().downgrade();
108 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
109 })
110 }
111
112 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
113 let project_path_input = cx.new_view(|cx| {
114 let mut editor = Editor::single_line(cx);
115 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
116 editor
117 });
118 let dev_server_name_input = cx.new_view(|cx| {
119 TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
120 });
121
122 let focus_handle = cx.focus_handle();
123 let dev_server_store = dev_server_projects::Store::global(cx);
124
125 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
126 cx.notify();
127 });
128
129 let mut base_style = cx.text_style();
130 base_style.refine(&gpui::TextStyleRefinement {
131 color: Some(cx.theme().colors().editor_foreground),
132 ..Default::default()
133 });
134
135 Self {
136 mode: Mode::Default(None),
137 focus_handle,
138 scroll_handle: ScrollHandle::new(),
139 dev_server_store,
140 project_path_input,
141 dev_server_name_input,
142 workspace,
143 _dev_server_subscription: subscription,
144 }
145 }
146
147 pub fn create_dev_server_project(
148 &mut self,
149 dev_server_id: DevServerId,
150 cx: &mut ViewContext<Self>,
151 ) {
152 let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
153
154 if path.is_empty() {
155 return;
156 }
157
158 if !path.starts_with('/') && !path.starts_with('~') {
159 path = format!("~/{}", path);
160 }
161
162 if self
163 .dev_server_store
164 .read(cx)
165 .projects_for_server(dev_server_id)
166 .iter()
167 .any(|p| p.paths.iter().any(|p| p == &path))
168 {
169 cx.spawn(|_, mut cx| async move {
170 cx.prompt(
171 gpui::PromptLevel::Critical,
172 "Failed to create project",
173 Some(&format!("{} is already open on this dev server.", path)),
174 &["Ok"],
175 )
176 .await
177 })
178 .detach_and_log_err(cx);
179 return;
180 }
181
182 let create = {
183 let path = path.clone();
184 self.dev_server_store.update(cx, |store, cx| {
185 store.create_dev_server_project(dev_server_id, path, cx)
186 })
187 };
188
189 cx.spawn(|this, mut cx| async move {
190 let result = create.await;
191 this.update(&mut cx, |this, cx| {
192 if let Ok(result) = &result {
193 if let Some(dev_server_project_id) =
194 result.dev_server_project.as_ref().map(|p| p.id)
195 {
196 let subscription =
197 cx.observe(&this.dev_server_store, move |this, store, cx| {
198 if let Some(project_id) = store
199 .read(cx)
200 .dev_server_project(DevServerProjectId(dev_server_project_id))
201 .and_then(|p| p.project_id)
202 {
203 this.project_path_input.update(cx, |editor, cx| {
204 editor.set_text("", cx);
205 });
206 this.mode = Mode::Default(None);
207 if let Some(app_state) = AppState::global(cx).upgrade() {
208 workspace::join_dev_server_project(
209 DevServerProjectId(dev_server_project_id),
210 project_id,
211 app_state,
212 None,
213 cx,
214 )
215 .detach_and_prompt_err(
216 "Could not join project",
217 cx,
218 |_, _| None,
219 )
220 }
221 }
222 });
223
224 this.mode = Mode::Default(Some(CreateDevServerProject {
225 dev_server_id,
226 creating: true,
227 _opening: Some(subscription),
228 }));
229 }
230 } else {
231 this.mode = Mode::Default(Some(CreateDevServerProject {
232 dev_server_id,
233 creating: false,
234 _opening: None,
235 }));
236 }
237 })
238 .log_err();
239 result
240 })
241 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
242 match e.error_code() {
243 ErrorCode::DevServerOffline => Some(
244 "The dev server is offline. Please log in and check it is connected."
245 .to_string(),
246 ),
247 ErrorCode::DevServerProjectPathDoesNotExist => {
248 Some(format!("The path `{}` does not exist on the server.", path))
249 }
250 _ => None,
251 }
252 });
253
254 self.mode = Mode::Default(Some(CreateDevServerProject {
255 dev_server_id,
256 creating: true,
257 _opening: None,
258 }));
259 }
260
261 fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
262 let host = get_text(&self.dev_server_name_input, cx);
263 if host.is_empty() {
264 return;
265 }
266
267 let mut host = host.trim_start_matches("ssh ");
268 let mut username: Option<String> = None;
269 let mut port: Option<u16> = None;
270
271 if let Some((u, rest)) = host.split_once('@') {
272 host = rest;
273 username = Some(u.to_string());
274 }
275 if let Some((rest, p)) = host.split_once(':') {
276 host = rest;
277 port = p.parse().ok()
278 }
279
280 if let Some((rest, p)) = host.split_once(" -p") {
281 host = rest;
282 port = p.trim().parse().ok()
283 }
284
285 let connection_options = remote::SshConnectionOptions {
286 host: host.to_string(),
287 username: username.clone(),
288 port,
289 password: None,
290 };
291 let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
292
293 let connection = connect_over_ssh(
294 connection_options.dev_server_identifier(),
295 connection_options.clone(),
296 ssh_prompt.clone(),
297 cx,
298 )
299 .prompt_err("Failed to connect", cx, |_, _| None);
300
301 let creating = cx.spawn(move |this, mut cx| async move {
302 match connection.await {
303 Some(_) => this
304 .update(&mut cx, |this, cx| {
305 this.add_ssh_server(connection_options, cx);
306 this.mode = Mode::Default(None);
307 cx.notify()
308 })
309 .log_err(),
310 None => this
311 .update(&mut cx, |this, cx| {
312 this.mode = Mode::CreateDevServer(CreateDevServer {
313 kind: NewServerKind::DirectSSH,
314 ..Default::default()
315 });
316 cx.notify()
317 })
318 .log_err(),
319 };
320 None
321 });
322 self.mode = Mode::CreateDevServer(CreateDevServer {
323 kind: NewServerKind::DirectSSH,
324 ssh_prompt: Some(ssh_prompt.clone()),
325 creating: Some(creating),
326 ..Default::default()
327 });
328 }
329
330 fn create_ssh_project(
331 &mut self,
332 ix: usize,
333 ssh_connection: SshConnection,
334 cx: &mut ViewContext<Self>,
335 ) {
336 let Some(workspace) = self.workspace.upgrade() else {
337 return;
338 };
339
340 let connection_options = ssh_connection.into();
341 workspace.update(cx, |_, cx| {
342 cx.defer(move |workspace, cx| {
343 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
344 let prompt = workspace
345 .active_modal::<SshConnectionModal>(cx)
346 .unwrap()
347 .read(cx)
348 .prompt
349 .clone();
350
351 let connect = connect_over_ssh(
352 connection_options.dev_server_identifier(),
353 connection_options,
354 prompt,
355 cx,
356 )
357 .prompt_err("Failed to connect", cx, |_, _| None);
358 cx.spawn(|workspace, mut cx| async move {
359 let Some(session) = connect.await else {
360 workspace
361 .update(&mut cx, |workspace, cx| {
362 let weak = cx.view().downgrade();
363 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
364 })
365 .log_err();
366 return;
367 };
368 let Ok((app_state, project, paths)) =
369 workspace.update(&mut cx, |workspace, cx| {
370 let app_state = workspace.app_state().clone();
371 let project = project::Project::ssh(
372 session,
373 app_state.client.clone(),
374 app_state.node_runtime.clone(),
375 app_state.user_store.clone(),
376 app_state.languages.clone(),
377 app_state.fs.clone(),
378 cx,
379 );
380 let paths = workspace.prompt_for_open_path(
381 PathPromptOptions {
382 files: true,
383 directories: true,
384 multiple: true,
385 },
386 project::DirectoryLister::Project(project.clone()),
387 cx,
388 );
389 (app_state, project, paths)
390 })
391 else {
392 return;
393 };
394
395 let Ok(Some(paths)) = paths.await else {
396 workspace
397 .update(&mut cx, |workspace, cx| {
398 let weak = cx.view().downgrade();
399 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
400 })
401 .log_err();
402 return;
403 };
404
405 let Some(options) = cx
406 .update(|cx| (app_state.build_window_options)(None, cx))
407 .log_err()
408 else {
409 return;
410 };
411
412 cx.open_window(options, |cx| {
413 cx.activate_window();
414
415 let fs = app_state.fs.clone();
416 update_settings_file::<SshSettings>(fs, cx, {
417 let paths = paths
418 .iter()
419 .map(|path| path.to_string_lossy().to_string())
420 .collect();
421 move |setting, _| {
422 if let Some(server) = setting
423 .ssh_connections
424 .as_mut()
425 .and_then(|connections| connections.get_mut(ix))
426 {
427 server.projects.push(SshProject { paths })
428 }
429 }
430 });
431
432 let tasks = paths
433 .into_iter()
434 .map(|path| {
435 project.update(cx, |project, cx| {
436 project.find_or_create_worktree(&path, true, cx)
437 })
438 })
439 .collect::<Vec<_>>();
440 cx.spawn(|_| async move {
441 for task in tasks {
442 task.await?;
443 }
444 Ok(())
445 })
446 .detach_and_prompt_err(
447 "Failed to open path",
448 cx,
449 |_, _| None,
450 );
451
452 cx.new_view(|cx| {
453 Workspace::new(None, project.clone(), app_state.clone(), cx)
454 })
455 })
456 .log_err();
457 })
458 .detach()
459 })
460 })
461 }
462
463 fn create_or_update_dev_server(
464 &mut self,
465 kind: NewServerKind,
466 existing_id: Option<DevServerId>,
467 access_token: Option<String>,
468 cx: &mut ViewContext<Self>,
469 ) {
470 let name = get_text(&self.dev_server_name_input, cx);
471 if name.is_empty() {
472 return;
473 }
474
475 let manual_setup = match kind {
476 NewServerKind::DirectSSH => unreachable!(),
477 NewServerKind::LegacySSH => false,
478 NewServerKind::Manual => true,
479 };
480
481 let ssh_connection_string = if manual_setup {
482 None
483 } else if name.contains(' ') {
484 Some(name.clone())
485 } else {
486 Some(format!("ssh {}", name))
487 };
488
489 let dev_server = self.dev_server_store.update(cx, {
490 let access_token = access_token.clone();
491 |store, cx| {
492 let ssh_connection_string = ssh_connection_string.clone();
493 if let Some(dev_server_id) = existing_id {
494 let rename = store.rename_dev_server(
495 dev_server_id,
496 name.clone(),
497 ssh_connection_string,
498 cx,
499 );
500 let token = if let Some(access_token) = access_token {
501 Task::ready(Ok(RegenerateDevServerTokenResponse {
502 dev_server_id: dev_server_id.0,
503 access_token,
504 }))
505 } else {
506 store.regenerate_dev_server_token(dev_server_id, cx)
507 };
508 cx.spawn(|_, _| async move {
509 rename.await?;
510 let response = token.await?;
511 Ok(CreateDevServerResponse {
512 dev_server_id: dev_server_id.0,
513 name,
514 access_token: response.access_token,
515 })
516 })
517 } else {
518 store.create_dev_server(name, ssh_connection_string.clone(), cx)
519 }
520 }
521 });
522
523 let workspace = self.workspace.clone();
524 let store = dev_server_projects::Store::global(cx);
525
526 let task = cx
527 .spawn({
528 |this, mut cx| async move {
529 let result = dev_server.await;
530
531 match result {
532 Ok(dev_server) => {
533 if let Some(ssh_connection_string) = ssh_connection_string {
534 this.update(&mut cx, |this, cx| {
535 if let Mode::CreateDevServer(CreateDevServer {
536 access_token,
537 dev_server_id,
538 ..
539 }) = &mut this.mode
540 {
541 access_token.replace(dev_server.access_token.clone());
542 dev_server_id
543 .replace(DevServerId(dev_server.dev_server_id));
544 }
545 cx.notify();
546 })?;
547
548 spawn_ssh_task(
549 workspace
550 .upgrade()
551 .ok_or_else(|| anyhow!("workspace dropped"))?,
552 store,
553 DevServerId(dev_server.dev_server_id),
554 ssh_connection_string,
555 dev_server.access_token.clone(),
556 &mut cx,
557 )
558 .await
559 .log_err();
560 }
561
562 this.update(&mut cx, |this, cx| {
563 this.focus_handle.focus(cx);
564 this.mode = Mode::CreateDevServer(CreateDevServer {
565 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
566 access_token: Some(dev_server.access_token),
567 kind,
568 ..Default::default()
569 });
570 cx.notify();
571 })?;
572 Ok(())
573 }
574 Err(e) => {
575 this.update(&mut cx, |this, cx| {
576 this.mode = Mode::CreateDevServer(CreateDevServer {
577 dev_server_id: existing_id,
578 access_token: None,
579 kind,
580 ..Default::default()
581 });
582 cx.notify()
583 })
584 .log_err();
585
586 Err(e)
587 }
588 }
589 }
590 })
591 .prompt_err("Failed to create server", cx, |_, _| None);
592
593 self.mode = Mode::CreateDevServer(CreateDevServer {
594 creating: Some(task),
595 dev_server_id: existing_id,
596 access_token,
597 kind,
598 ..Default::default()
599 });
600 cx.notify()
601 }
602
603 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
604 let store = self.dev_server_store.read(cx);
605 let prompt = if store.projects_for_server(id).is_empty()
606 && store
607 .dev_server(id)
608 .is_some_and(|server| server.status == DevServerStatus::Offline)
609 {
610 None
611 } else {
612 Some(cx.prompt(
613 gpui::PromptLevel::Warning,
614 "Are you sure?",
615 Some("This will delete the dev server and all of its remote projects."),
616 &["Delete", "Cancel"],
617 ))
618 };
619
620 cx.spawn(|this, mut cx| async move {
621 if let Some(prompt) = prompt {
622 if prompt.await? != 0 {
623 return Ok(());
624 }
625 }
626
627 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
628 this.dev_server_store.update(cx, |store, _| {
629 store
630 .projects_for_server(id)
631 .into_iter()
632 .map(|project| project.id)
633 .collect()
634 })
635 })?;
636
637 this.update(&mut cx, |this, cx| {
638 this.dev_server_store
639 .update(cx, |store, cx| store.delete_dev_server(id, cx))
640 })?
641 .await?;
642
643 for id in project_ids {
644 WORKSPACE_DB
645 .delete_workspace_by_dev_server_project_id(id)
646 .await
647 .log_err();
648 }
649 Ok(())
650 })
651 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
652 }
653
654 fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext<Self>) {
655 let answer = cx.prompt(
656 gpui::PromptLevel::Warning,
657 "Delete this project?",
658 Some("This will delete the remote project. You can always re-add it later."),
659 &["Delete", "Cancel"],
660 );
661
662 cx.spawn(|this, mut cx| async move {
663 let answer = answer.await?;
664
665 if answer != 0 {
666 return Ok(());
667 }
668
669 this.update(&mut cx, |this, cx| {
670 this.dev_server_store
671 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
672 })?
673 .await?;
674
675 WORKSPACE_DB
676 .delete_workspace_by_dev_server_project_id(id)
677 .await
678 .log_err();
679
680 Ok(())
681 })
682 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
683 }
684
685 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
686 match &self.mode {
687 Mode::Default(None) => {}
688 Mode::Default(Some(create_project)) => {
689 self.create_dev_server_project(create_project.dev_server_id, cx);
690 }
691 Mode::CreateDevServer(state) => {
692 if let Some(prompt) = state.ssh_prompt.as_ref() {
693 prompt.update(cx, |prompt, cx| {
694 prompt.confirm(cx);
695 });
696 return;
697 }
698 if state.kind == NewServerKind::DirectSSH {
699 self.create_ssh_server(cx);
700 return;
701 }
702 if state.creating.is_none() || state.dev_server_id.is_some() {
703 self.create_or_update_dev_server(
704 state.kind,
705 state.dev_server_id,
706 state.access_token.clone(),
707 cx,
708 );
709 }
710 }
711 }
712 }
713
714 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
715 match &self.mode {
716 Mode::Default(None) => cx.emit(DismissEvent),
717 Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
718 self.mode = Mode::CreateDevServer(CreateDevServer {
719 kind: NewServerKind::DirectSSH,
720 ..Default::default()
721 });
722 cx.notify();
723 }
724 _ => {
725 self.mode = Mode::Default(None);
726 self.focus_handle(cx).focus(cx);
727 cx.notify();
728 }
729 }
730 }
731
732 fn render_dev_server(
733 &mut self,
734 dev_server: &DevServer,
735 create_project: Option<bool>,
736 cx: &mut ViewContext<Self>,
737 ) -> impl IntoElement {
738 let dev_server_id = dev_server.id;
739 let status = dev_server.status;
740 let dev_server_name = dev_server.name.clone();
741 let kind = if dev_server.ssh_connection_string.is_some() {
742 NewServerKind::LegacySSH
743 } else {
744 NewServerKind::Manual
745 };
746
747 v_flex()
748 .w_full()
749 .child(
750 h_flex().group("dev-server").justify_between().child(
751 h_flex()
752 .gap_2()
753 .child(
754 div()
755 .id(("status", dev_server.id.0))
756 .relative()
757 .child(Icon::new(IconName::Server).size(IconSize::Small))
758 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
759 Indicator::dot().color(match status {
760 DevServerStatus::Online => Color::Created,
761 DevServerStatus::Offline => Color::Hidden,
762 }),
763 ))
764 .tooltip(move |cx| {
765 Tooltip::text(
766 match status {
767 DevServerStatus::Online => "Online",
768 DevServerStatus::Offline => "Offline",
769 },
770 cx,
771 )
772 }),
773 )
774 .child(
775 div()
776 .max_w(rems(26.))
777 .overflow_hidden()
778 .whitespace_nowrap()
779 .child(Label::new(dev_server_name.clone())),
780 )
781 .child(
782 h_flex()
783 .visible_on_hover("dev-server")
784 .gap_1()
785 .child(if dev_server.ssh_connection_string.is_some() {
786 let dev_server = dev_server.clone();
787 IconButton::new("reconnect-dev-server", IconName::ArrowCircle)
788 .on_click(cx.listener(move |this, _, cx| {
789 let Some(workspace) = this.workspace.upgrade() else {
790 return;
791 };
792
793 reconnect_to_dev_server(
794 workspace,
795 dev_server.clone(),
796 cx,
797 )
798 .detach_and_prompt_err(
799 "Failed to reconnect",
800 cx,
801 |_, _| None,
802 );
803 }))
804 .tooltip(|cx| Tooltip::text("Reconnect", cx))
805 } else {
806 IconButton::new("edit-dev-server", IconName::Pencil)
807 .on_click(cx.listener(move |this, _, cx| {
808 this.mode = Mode::CreateDevServer(CreateDevServer {
809 dev_server_id: Some(dev_server_id),
810 kind,
811 ..Default::default()
812 });
813 let dev_server_name = dev_server_name.clone();
814 this.dev_server_name_input.update(
815 cx,
816 move |input, cx| {
817 input.editor().update(cx, move |editor, cx| {
818 editor.set_text(dev_server_name, cx)
819 })
820 },
821 )
822 }))
823 .tooltip(|cx| Tooltip::text("Edit dev server", cx))
824 })
825 .child({
826 let dev_server_id = dev_server.id;
827 IconButton::new("remove-dev-server", IconName::TrashAlt)
828 .on_click(cx.listener(move |this, _, cx| {
829 this.delete_dev_server(dev_server_id, cx)
830 }))
831 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
832 }),
833 ),
834 ),
835 )
836 .child(
837 v_flex()
838 .w_full()
839 .bg(cx.theme().colors().background)
840 .border_1()
841 .border_color(cx.theme().colors().border_variant)
842 .rounded_md()
843 .my_1()
844 .py_0p5()
845 .px_3()
846 .child(
847 List::new()
848 .empty_message("No projects.")
849 .children(
850 self.dev_server_store
851 .read(cx)
852 .projects_for_server(dev_server.id)
853 .iter()
854 .map(|p| self.render_dev_server_project(p, cx)),
855 )
856 .when(
857 create_project.is_none()
858 && dev_server.status == DevServerStatus::Online,
859 |el| {
860 el.child(
861 ListItem::new("new-remote_project")
862 .start_slot(Icon::new(IconName::Plus))
863 .child(Label::new("Open folder…"))
864 .on_click(cx.listener(move |this, _, cx| {
865 this.mode =
866 Mode::Default(Some(CreateDevServerProject {
867 dev_server_id,
868 creating: false,
869 _opening: None,
870 }));
871 this.project_path_input
872 .read(cx)
873 .focus_handle(cx)
874 .focus(cx);
875 cx.notify();
876 })),
877 )
878 },
879 )
880 .when_some(create_project, |el, creating| {
881 el.child(self.render_create_new_project(creating, cx))
882 }),
883 ),
884 )
885 }
886
887 fn render_ssh_connection(
888 &mut self,
889 ix: usize,
890 ssh_connection: SshConnection,
891 cx: &mut ViewContext<Self>,
892 ) -> impl IntoElement {
893 v_flex()
894 .w_full()
895 .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
896 .child(
897 h_flex()
898 .w_full()
899 .group("ssh-server")
900 .justify_between()
901 .child(
902 h_flex()
903 .gap_2()
904 .w_full()
905 .child(
906 div()
907 .id(("status", ix))
908 .relative()
909 .child(Icon::new(IconName::Server).size(IconSize::Small)),
910 )
911 .child(
912 h_flex()
913 .max_w(rems(26.))
914 .overflow_hidden()
915 .whitespace_nowrap()
916 .child(Label::new(ssh_connection.host.clone())),
917 ),
918 )
919 .child(
920 h_flex()
921 .visible_on_hover("ssh-server")
922 .gap_1()
923 .child({
924 IconButton::new("copy-dev-server-address", IconName::Copy)
925 .icon_size(IconSize::Small)
926 .on_click(cx.listener(move |this, _, cx| {
927 this.update_settings_file(cx, move |servers, cx| {
928 if let Some(content) = servers
929 .ssh_connections
930 .as_ref()
931 .and_then(|connections| {
932 connections
933 .get(ix)
934 .map(|connection| connection.host.clone())
935 })
936 {
937 cx.write_to_clipboard(ClipboardItem::new_string(
938 content,
939 ));
940 }
941 });
942 }))
943 .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
944 })
945 .child({
946 IconButton::new("remove-dev-server", IconName::TrashAlt)
947 .icon_size(IconSize::Small)
948 .on_click(cx.listener(move |this, _, cx| {
949 this.delete_ssh_server(ix, cx)
950 }))
951 .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
952 }),
953 ),
954 )
955 .child(
956 v_flex()
957 .w_full()
958 .border_l_1()
959 .border_color(cx.theme().colors().border_variant)
960 .my_1()
961 .mx_1p5()
962 .py_0p5()
963 .px_3()
964 .child(
965 List::new()
966 .empty_message("No projects.")
967 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
968 self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
969 }))
970 .child(
971 h_flex().child(
972 Button::new("new-remote_project", "Open Folder…")
973 .icon(IconName::Plus)
974 .size(ButtonSize::Default)
975 .style(ButtonStyle::Filled)
976 .layer(ElevationIndex::ModalSurface)
977 .icon_position(IconPosition::Start)
978 .on_click(cx.listener(move |this, _, cx| {
979 this.create_ssh_project(ix, ssh_connection.clone(), cx);
980 })),
981 ),
982 ),
983 ),
984 )
985 }
986
987 fn render_ssh_project(
988 &self,
989 server_ix: usize,
990 server: &SshConnection,
991 ix: usize,
992 project: &SshProject,
993 cx: &ViewContext<Self>,
994 ) -> impl IntoElement {
995 let project = project.clone();
996 let server = server.clone();
997 ListItem::new(("remote-project", ix))
998 .spacing(ui::ListItemSpacing::Sparse)
999 .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
1000 .child(Label::new(project.paths.join(", ")))
1001 .on_click(cx.listener(move |this, _, cx| {
1002 let Some(app_state) = this
1003 .workspace
1004 .update(cx, |workspace, _| workspace.app_state().clone())
1005 .log_err()
1006 else {
1007 return;
1008 };
1009 let project = project.clone();
1010 let server = server.clone();
1011 cx.spawn(|_, mut cx| async move {
1012 let result = open_ssh_project(
1013 server.into(),
1014 project.paths.into_iter().map(PathBuf::from).collect(),
1015 app_state,
1016 OpenOptions::default(),
1017 &mut cx,
1018 )
1019 .await;
1020 if let Err(e) = result {
1021 log::error!("Failed to connect: {:?}", e);
1022 cx.prompt(
1023 gpui::PromptLevel::Critical,
1024 "Failed to connect",
1025 Some(&e.to_string()),
1026 &["Ok"],
1027 )
1028 .await
1029 .ok();
1030 }
1031 })
1032 .detach();
1033 }))
1034 .end_hover_slot::<AnyElement>(Some(
1035 IconButton::new("remove-remote-project", IconName::TrashAlt)
1036 .on_click(
1037 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
1038 )
1039 .tooltip(|cx| Tooltip::text("Delete remote project", cx))
1040 .into_any_element(),
1041 ))
1042 }
1043
1044 fn update_settings_file(
1045 &mut self,
1046 cx: &mut ViewContext<Self>,
1047 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
1048 ) {
1049 let Some(fs) = self
1050 .workspace
1051 .update(cx, |workspace, _| workspace.app_state().fs.clone())
1052 .log_err()
1053 else {
1054 return;
1055 };
1056 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
1057 }
1058
1059 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
1060 self.update_settings_file(cx, move |setting, _| {
1061 if let Some(connections) = setting.ssh_connections.as_mut() {
1062 connections.remove(server);
1063 }
1064 });
1065 }
1066
1067 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
1068 self.update_settings_file(cx, move |setting, _| {
1069 if let Some(server) = setting
1070 .ssh_connections
1071 .as_mut()
1072 .and_then(|connections| connections.get_mut(server))
1073 {
1074 server.projects.remove(project);
1075 }
1076 });
1077 }
1078
1079 fn add_ssh_server(
1080 &mut self,
1081 connection_options: remote::SshConnectionOptions,
1082 cx: &mut ViewContext<Self>,
1083 ) {
1084 self.update_settings_file(cx, move |setting, _| {
1085 setting
1086 .ssh_connections
1087 .get_or_insert(Default::default())
1088 .push(SshConnection {
1089 host: connection_options.host,
1090 username: connection_options.username,
1091 port: connection_options.port,
1092 projects: vec![],
1093 })
1094 });
1095 }
1096
1097 fn render_create_new_project(
1098 &mut self,
1099 creating: bool,
1100 _: &mut ViewContext<Self>,
1101 ) -> impl IntoElement {
1102 ListItem::new("create-remote-project")
1103 .disabled(true)
1104 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
1105 .child(self.project_path_input.clone())
1106 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
1107 el.child(
1108 Icon::new(IconName::ArrowCircle)
1109 .size(IconSize::Medium)
1110 .with_animation(
1111 "arrow-circle",
1112 Animation::new(Duration::from_secs(2)).repeat(),
1113 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1114 ),
1115 )
1116 }))
1117 }
1118
1119 fn render_dev_server_project(
1120 &mut self,
1121 project: &DevServerProject,
1122 cx: &mut ViewContext<Self>,
1123 ) -> impl IntoElement {
1124 let dev_server_project_id = project.id;
1125 let project_id = project.project_id;
1126 let is_online = project_id.is_some();
1127
1128 ListItem::new(("remote-project", dev_server_project_id.0))
1129 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
1130 .child(
1131 Label::new(project.paths.join(", "))
1132 )
1133 .on_click(cx.listener(move |_, _, cx| {
1134 if let Some(project_id) = project_id {
1135 if let Some(app_state) = AppState::global(cx).upgrade() {
1136 workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
1137 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
1138 }
1139 } else {
1140 cx.spawn(|_, mut cx| async move {
1141 cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err();
1142 }).detach();
1143 }
1144 }))
1145 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::TrashAlt)
1146 .on_click(cx.listener(move |this, _, cx| {
1147 this.delete_dev_server_project(dev_server_project_id, cx)
1148 }))
1149 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
1150 }
1151
1152 fn render_create_dev_server(
1153 &self,
1154 state: &CreateDevServer,
1155 cx: &mut ViewContext<Self>,
1156 ) -> impl IntoElement {
1157 let creating = state.creating.is_some();
1158 let dev_server_id = state.dev_server_id;
1159 let access_token = state.access_token.clone();
1160 let ssh_prompt = state.ssh_prompt.clone();
1161 let use_direct_ssh = SshSettings::get_global(cx).use_direct_ssh()
1162 || Client::global(cx).status().borrow().is_signed_out();
1163
1164 let mut kind = state.kind;
1165 if use_direct_ssh && kind == NewServerKind::LegacySSH {
1166 kind = NewServerKind::DirectSSH;
1167 }
1168
1169 self.dev_server_name_input.update(cx, |input, cx| {
1170 input.editor().update(cx, |editor, cx| {
1171 if editor.text(cx).is_empty() {
1172 editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
1173 }
1174 })
1175 });
1176 let theme = cx.theme();
1177 v_flex()
1178 .id("create-dev-server")
1179 .overflow_hidden()
1180 .size_full()
1181 .flex_1()
1182 .child(
1183 h_flex()
1184 .p_2()
1185 .gap_2()
1186 .items_center()
1187 .border_b_1()
1188 .border_color(theme.colors().border_variant)
1189 .child(
1190 IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
1191 .shape(IconButtonShape::Square)
1192 .on_click(|_, cx| {
1193 cx.dispatch_action(menu::Cancel.boxed_clone());
1194 }),
1195 )
1196 .child(Label::new("Connect New Dev Server")),
1197 )
1198 .child(
1199 v_flex()
1200 .p_3()
1201 .border_b_1()
1202 .border_color(theme.colors().border_variant)
1203 .child(Label::new("SSH Arguments"))
1204 .child(
1205 Label::new("Enter the command you use to SSH into this server.")
1206 .size(LabelSize::Small)
1207 .color(Color::Muted),
1208 )
1209 .child(
1210 h_flex()
1211 .mt_2()
1212 .w_full()
1213 .gap_2()
1214 .child(self.dev_server_name_input.clone())
1215 .child(
1216 Button::new("create-dev-server", "Connect Server")
1217 .style(ButtonStyle::Filled)
1218 .layer(ElevationIndex::ModalSurface)
1219 .disabled(creating && dev_server_id.is_none())
1220 .on_click(cx.listener({
1221 let access_token = access_token.clone();
1222 move |this, _, cx| {
1223 if kind == NewServerKind::DirectSSH {
1224 this.create_ssh_server(cx);
1225 return;
1226 }
1227 this.create_or_update_dev_server(
1228 kind,
1229 dev_server_id,
1230 access_token.clone(),
1231 cx,
1232 );
1233 }
1234 })),
1235 ),
1236 ),
1237 )
1238 .child(
1239 h_flex()
1240 .bg(theme.colors().editor_background)
1241 .w_full()
1242 .map(|this| {
1243 if let Some(ssh_prompt) = ssh_prompt {
1244 this.child(h_flex().w_full().child(ssh_prompt))
1245 } else {
1246 let color = Color::Muted.color(cx);
1247 this.child(
1248 h_flex()
1249 .p_2()
1250 .w_full()
1251 .content_center()
1252 .gap_2()
1253 .child(h_flex().w_full())
1254 .child(
1255 div().p_1().rounded_lg().bg(color).with_animation(
1256 "pulse-ssh-waiting-for-connection",
1257 Animation::new(Duration::from_secs(2))
1258 .repeat()
1259 .with_easing(pulsating_between(0.2, 0.5)),
1260 move |this, progress| this.bg(color.opacity(progress)),
1261 ),
1262 )
1263 .child(
1264 Label::new("Waiting for connection…")
1265 .size(LabelSize::Small),
1266 )
1267 .child(h_flex().w_full()),
1268 )
1269 }
1270 }),
1271 )
1272 }
1273
1274 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1275 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1276 let ssh_connections = SshSettings::get_global(cx)
1277 .ssh_connections()
1278 .collect::<Vec<_>>();
1279
1280 let Mode::Default(create_dev_server_project) = &self.mode else {
1281 unreachable!()
1282 };
1283
1284 let mut is_creating = None;
1285 let mut creating_dev_server = None;
1286 if let Some(CreateDevServerProject {
1287 creating,
1288 dev_server_id,
1289 ..
1290 }) = create_dev_server_project
1291 {
1292 is_creating = Some(*creating);
1293 creating_dev_server = Some(*dev_server_id);
1294 };
1295
1296 let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
1297 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1298 .header(
1299 ModalHeader::new().child(
1300 h_flex()
1301 .justify_between()
1302 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
1303 .child(
1304 Button::new("register-dev-server-button", "Connect New Server")
1305 .style(ButtonStyle::Filled)
1306 .layer(ElevationIndex::ModalSurface)
1307 .icon(IconName::Plus)
1308 .icon_position(IconPosition::Start)
1309 .icon_color(Color::Muted)
1310 .on_click(cx.listener(|this, _, cx| {
1311 this.mode = Mode::CreateDevServer(CreateDevServer {
1312 kind: if SshSettings::get_global(cx).use_direct_ssh() {
1313 NewServerKind::DirectSSH
1314 } else {
1315 NewServerKind::LegacySSH
1316 },
1317 ..Default::default()
1318 });
1319 this.dev_server_name_input.update(cx, |text_field, cx| {
1320 text_field.editor().update(cx, |editor, cx| {
1321 editor.set_text("", cx);
1322 });
1323 });
1324 cx.notify();
1325 })),
1326 ),
1327 ),
1328 )
1329 .section(
1330 Section::new().padded(false).child(
1331 div()
1332 .border_y_1()
1333 .border_color(cx.theme().colors().border_variant)
1334 .w_full()
1335 .child(
1336 div().p_2().child(
1337 List::new()
1338 .empty_message("No dev servers registered yet.")
1339 .children(ssh_connections.iter().cloned().enumerate().map(
1340 |(ix, connection)| {
1341 self.render_ssh_connection(ix, connection, cx)
1342 .into_any_element()
1343 },
1344 ))
1345 .children(dev_servers.iter().map(|dev_server| {
1346 let creating = if creating_dev_server == Some(dev_server.id)
1347 {
1348 is_creating
1349 } else {
1350 None
1351 };
1352 self.render_dev_server(dev_server, creating, cx)
1353 .into_any_element()
1354 })),
1355 ),
1356 ),
1357 ),
1358 )
1359 .footer(
1360 ModalFooter::new()
1361 .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
1362 )
1363 }
1364}
1365
1366fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1367 element
1368 .read(cx)
1369 .editor()
1370 .read(cx)
1371 .text(cx)
1372 .trim()
1373 .to_string()
1374}
1375
1376impl ModalView for DevServerProjects {}
1377
1378impl FocusableView for DevServerProjects {
1379 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1380 self.focus_handle.clone()
1381 }
1382}
1383
1384impl EventEmitter<DismissEvent> for DevServerProjects {}
1385
1386impl Render for DevServerProjects {
1387 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1388 div()
1389 .track_focus(&self.focus_handle)
1390 .elevation_3(cx)
1391 .key_context("DevServerModal")
1392 .on_action(cx.listener(Self::cancel))
1393 .on_action(cx.listener(Self::confirm))
1394 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1395 this.focus_handle(cx).focus(cx);
1396 }))
1397 .on_mouse_down_out(cx.listener(|this, _, cx| {
1398 if matches!(this.mode, Mode::Default(None)) {
1399 cx.emit(DismissEvent)
1400 }
1401 }))
1402 .w(rems(34.))
1403 .max_h(rems(40.))
1404 .child(match &self.mode {
1405 Mode::Default(_) => self.render_default(cx).into_any_element(),
1406 Mode::CreateDevServer(state) => {
1407 self.render_create_dev_server(state, cx).into_any_element()
1408 }
1409 })
1410 }
1411}
1412
1413pub fn reconnect_to_dev_server_project(
1414 workspace: View<Workspace>,
1415 dev_server: DevServer,
1416 dev_server_project_id: DevServerProjectId,
1417 replace_current_window: bool,
1418 cx: &mut WindowContext,
1419) -> Task<Result<()>> {
1420 let store = dev_server_projects::Store::global(cx);
1421 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1422 cx.spawn(|mut cx| async move {
1423 reconnect.await?;
1424
1425 cx.background_executor()
1426 .timer(Duration::from_millis(1000))
1427 .await;
1428
1429 if let Some(project_id) = store.update(&mut cx, |store, _| {
1430 store
1431 .dev_server_project(dev_server_project_id)
1432 .and_then(|p| p.project_id)
1433 })? {
1434 workspace
1435 .update(&mut cx, move |_, cx| {
1436 open_dev_server_project(
1437 replace_current_window,
1438 dev_server_project_id,
1439 project_id,
1440 cx,
1441 )
1442 })?
1443 .await?;
1444 }
1445
1446 Ok(())
1447 })
1448}
1449
1450pub fn reconnect_to_dev_server(
1451 workspace: View<Workspace>,
1452 dev_server: DevServer,
1453 cx: &mut WindowContext,
1454) -> Task<Result<()>> {
1455 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1456 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1457 };
1458 let dev_server_store = dev_server_projects::Store::global(cx);
1459 let get_access_token = dev_server_store.update(cx, |store, cx| {
1460 store.regenerate_dev_server_token(dev_server.id, cx)
1461 });
1462
1463 cx.spawn(|mut cx| async move {
1464 let access_token = get_access_token.await?.access_token;
1465
1466 spawn_ssh_task(
1467 workspace,
1468 dev_server_store,
1469 dev_server.id,
1470 ssh_connection_string.to_string(),
1471 access_token,
1472 &mut cx,
1473 )
1474 .await
1475 })
1476}
1477
1478pub async fn spawn_ssh_task(
1479 workspace: View<Workspace>,
1480 dev_server_store: Model<dev_server_projects::Store>,
1481 dev_server_id: DevServerId,
1482 ssh_connection_string: String,
1483 access_token: String,
1484 cx: &mut AsyncWindowContext,
1485) -> Result<()> {
1486 let terminal_panel = workspace
1487 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1488 .ok()
1489 .flatten()
1490 .with_context(|| anyhow!("No terminal panel"))?;
1491
1492 let command = "sh".to_string();
1493 let args = vec![
1494 "-x".to_string(),
1495 "-c".to_string(),
1496 format!(
1497 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 {}"#,
1498 access_token
1499 ),
1500 ];
1501
1502 let ssh_connection_string = ssh_connection_string.to_string();
1503 let (command, args) = wrap_for_ssh(
1504 &SshCommand::DevServer(ssh_connection_string.clone()),
1505 Some((&command, &args)),
1506 None,
1507 HashMap::default(),
1508 None,
1509 );
1510
1511 let terminal = terminal_panel
1512 .update(cx, |terminal_panel, cx| {
1513 terminal_panel.spawn_in_new_terminal(
1514 SpawnInTerminal {
1515 id: task::TaskId("ssh-remote".into()),
1516 full_label: "Install zed over ssh".into(),
1517 label: "Install zed over ssh".into(),
1518 command,
1519 args,
1520 command_label: ssh_connection_string.clone(),
1521 cwd: None,
1522 use_new_terminal: true,
1523 allow_concurrent_runs: false,
1524 reveal: RevealStrategy::Always,
1525 hide: HideStrategy::Never,
1526 env: Default::default(),
1527 shell: Default::default(),
1528 },
1529 cx,
1530 )
1531 })?
1532 .await?;
1533
1534 terminal
1535 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1536 .await;
1537
1538 // 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.
1539 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1540 == DevServerStatus::Offline
1541 {
1542 cx.background_executor()
1543 .timer(Duration::from_millis(200))
1544 .await
1545 }
1546
1547 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1548 == DevServerStatus::Offline
1549 {
1550 return Err(anyhow!("couldn't reconnect"))?;
1551 }
1552
1553 Ok(())
1554}