use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use anyhow::anyhow; use anyhow::Context; use anyhow::Result; use dev_server_projects::{DevServer, DevServerId, DevServerProjectId}; use editor::Editor; use gpui::pulsating_between; use gpui::AsyncWindowContext; use gpui::ClipboardItem; use gpui::PathPromptOptions; use gpui::Subscription; use gpui::Task; use gpui::WeakView; use gpui::{ Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext, }; use project::terminals::wrap_for_ssh; use project::terminals::SshCommand; use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt}; use settings::update_settings_file; use settings::Settings; use task::HideStrategy; use task::RevealStrategy; use task::SpawnInTerminal; use terminal_view::terminal_panel::TerminalPanel; use ui::ElevationIndex; use ui::Section; use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip}; use ui_input::{FieldLabelLayout, TextField}; use util::ResultExt; use workspace::OpenOptions; use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace}; use crate::open_dev_server_project; use crate::ssh_connections::connect_over_ssh; use crate::ssh_connections::open_ssh_project; use crate::ssh_connections::RemoteSettingsContent; use crate::ssh_connections::SshConnection; use crate::ssh_connections::SshConnectionModal; use crate::ssh_connections::SshProject; use crate::ssh_connections::SshPrompt; use crate::ssh_connections::SshSettings; use crate::OpenRemote; pub struct DevServerProjects { mode: Mode, focus_handle: FocusHandle, scroll_handle: ScrollHandle, dev_server_store: Model, workspace: WeakView, project_path_input: View, dev_server_name_input: View, _dev_server_subscription: Subscription, } #[derive(Default)] struct CreateDevServer { creating: Option>>, ssh_prompt: Option>, } struct CreateDevServerProject { dev_server_id: DevServerId, _opening: Option, } enum Mode { Default(Option), CreateDevServer(CreateDevServer), } impl DevServerProjects { pub fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(|workspace, _: &OpenRemote, cx| { let handle = cx.view().downgrade(); workspace.toggle_modal(cx, |cx| Self::new(cx, handle)) }); } pub fn open(workspace: View, cx: &mut WindowContext) { workspace.update(cx, |workspace, cx| { let handle = cx.view().downgrade(); workspace.toggle_modal(cx, |cx| Self::new(cx, handle)) }) } pub fn new(cx: &mut ViewContext, workspace: WeakView) -> Self { let project_path_input = cx.new_view(|cx| { let mut editor = Editor::single_line(cx); editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx); editor }); let dev_server_name_input = cx.new_view(|cx| { TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden) }); let focus_handle = cx.focus_handle(); let dev_server_store = dev_server_projects::Store::global(cx); let subscription = cx.observe(&dev_server_store, |_, _, cx| { cx.notify(); }); let mut base_style = cx.text_style(); base_style.refine(&gpui::TextStyleRefinement { color: Some(cx.theme().colors().editor_foreground), ..Default::default() }); Self { mode: Mode::Default(None), focus_handle, scroll_handle: ScrollHandle::new(), dev_server_store, project_path_input, dev_server_name_input, workspace, _dev_server_subscription: subscription, } } pub fn create_dev_server_project( &mut self, dev_server_id: DevServerId, cx: &mut ViewContext, ) { let mut path = self.project_path_input.read(cx).text(cx).trim().to_string(); if path.is_empty() { return; } if !path.starts_with('/') && !path.starts_with('~') { path = format!("~/{}", path); } if self .dev_server_store .read(cx) .projects_for_server(dev_server_id) .iter() .any(|p| p.paths.iter().any(|p| p == &path)) { cx.spawn(|_, mut cx| async move { cx.prompt( gpui::PromptLevel::Critical, "Failed to create project", Some(&format!("{} is already open on this dev server.", path)), &["Ok"], ) .await }) .detach_and_log_err(cx); return; } let create = { let path = path.clone(); self.dev_server_store.update(cx, |store, cx| { store.create_dev_server_project(dev_server_id, path, cx) }) }; cx.spawn(|this, mut cx| async move { let result = create.await; this.update(&mut cx, |this, cx| { if let Ok(result) = &result { if let Some(dev_server_project_id) = result.dev_server_project.as_ref().map(|p| p.id) { let subscription = cx.observe(&this.dev_server_store, move |this, store, cx| { if let Some(project_id) = store .read(cx) .dev_server_project(DevServerProjectId(dev_server_project_id)) .and_then(|p| p.project_id) { this.project_path_input.update(cx, |editor, cx| { editor.set_text("", cx); }); this.mode = Mode::Default(None); if let Some(app_state) = AppState::global(cx).upgrade() { workspace::join_dev_server_project( DevServerProjectId(dev_server_project_id), project_id, app_state, None, cx, ) .detach_and_prompt_err( "Could not join project", cx, |_, _| None, ) } } }); this.mode = Mode::Default(Some(CreateDevServerProject { dev_server_id, _opening: Some(subscription), })); } } else { this.mode = Mode::Default(Some(CreateDevServerProject { dev_server_id, _opening: None, })); } }) .log_err(); result }) .detach_and_prompt_err("Failed to create project", cx, move |e, _| { match e.error_code() { ErrorCode::DevServerOffline => Some( "The dev server is offline. Please log in and check it is connected." .to_string(), ), ErrorCode::DevServerProjectPathDoesNotExist => { Some(format!("The path `{}` does not exist on the server.", path)) } _ => None, } }); self.mode = Mode::Default(Some(CreateDevServerProject { dev_server_id, _opening: None, })); } fn create_ssh_server(&mut self, cx: &mut ViewContext) { let host = get_text(&self.dev_server_name_input, cx); if host.is_empty() { return; } let mut host = host.trim_start_matches("ssh "); let mut username: Option = None; let mut port: Option = None; if let Some((u, rest)) = host.split_once('@') { host = rest; username = Some(u.to_string()); } if let Some((rest, p)) = host.split_once(':') { host = rest; port = p.parse().ok() } if let Some((rest, p)) = host.split_once(" -p") { host = rest; port = p.trim().parse().ok() } let connection_options = remote::SshConnectionOptions { host: host.to_string(), username: username.clone(), port, password: None, }; let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx)); let connection = connect_over_ssh( connection_options.dev_server_identifier(), connection_options.clone(), ssh_prompt.clone(), cx, ) .prompt_err("Failed to connect", cx, |_, _| None); let creating = cx.spawn(move |this, mut cx| async move { match connection.await { Some(_) => this .update(&mut cx, |this, cx| { let _ = this.workspace.update(cx, |workspace, _| { workspace .client() .telemetry() .report_app_event("create ssh server".to_string()) }); this.add_ssh_server(connection_options, cx); this.mode = Mode::Default(None); cx.notify() }) .log_err(), None => this .update(&mut cx, |this, cx| { this.mode = Mode::CreateDevServer(CreateDevServer::default()); cx.notify() }) .log_err(), }; None }); self.mode = Mode::CreateDevServer(CreateDevServer { ssh_prompt: Some(ssh_prompt.clone()), creating: Some(creating), }); } fn create_ssh_project( &mut self, ix: usize, ssh_connection: SshConnection, cx: &mut ViewContext, ) { let Some(workspace) = self.workspace.upgrade() else { return; }; let connection_options = ssh_connection.into(); workspace.update(cx, |_, cx| { cx.defer(move |workspace, cx| { workspace.toggle_modal(cx, |cx| { SshConnectionModal::new(&connection_options, false, cx) }); let prompt = workspace .active_modal::(cx) .unwrap() .read(cx) .prompt .clone(); let connect = connect_over_ssh( connection_options.dev_server_identifier(), connection_options, prompt, cx, ) .prompt_err("Failed to connect", cx, |_, _| None); cx.spawn(|workspace, mut cx| async move { let Some(session) = connect.await else { workspace .update(&mut cx, |workspace, cx| { let weak = cx.view().downgrade(); workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak)); }) .log_err(); return; }; let Ok((app_state, project, paths)) = workspace.update(&mut cx, |workspace, cx| { let app_state = workspace.app_state().clone(); let project = project::Project::ssh( session, app_state.client.clone(), app_state.node_runtime.clone(), app_state.user_store.clone(), app_state.languages.clone(), app_state.fs.clone(), cx, ); let paths = workspace.prompt_for_open_path( PathPromptOptions { files: true, directories: true, multiple: true, }, project::DirectoryLister::Project(project.clone()), cx, ); (app_state, project, paths) }) else { return; }; let Ok(Some(paths)) = paths.await else { workspace .update(&mut cx, |workspace, cx| { let weak = cx.view().downgrade(); workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak)); }) .log_err(); return; }; let Some(options) = cx .update(|cx| (app_state.build_window_options)(None, cx)) .log_err() else { return; }; cx.open_window(options, |cx| { cx.activate_window(); let fs = app_state.fs.clone(); update_settings_file::(fs, cx, { let paths = paths .iter() .map(|path| path.to_string_lossy().to_string()) .collect(); move |setting, _| { if let Some(server) = setting .ssh_connections .as_mut() .and_then(|connections| connections.get_mut(ix)) { server.projects.push(SshProject { paths }) } } }); let tasks = paths .into_iter() .map(|path| { project.update(cx, |project, cx| { project.find_or_create_worktree(&path, true, cx) }) }) .collect::>(); cx.spawn(|_| async move { for task in tasks { task.await?; } Ok(()) }) .detach_and_prompt_err( "Failed to open path", cx, |_, _| None, ); cx.new_view(|cx| { let workspace = Workspace::new(None, project.clone(), app_state.clone(), cx); workspace .client() .telemetry() .report_app_event("create ssh project".to_string()); workspace }) }) .log_err(); }) .detach() }) }) } fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { match &self.mode { Mode::Default(None) => {} Mode::Default(Some(create_project)) => { self.create_dev_server_project(create_project.dev_server_id, cx); } Mode::CreateDevServer(state) => { if let Some(prompt) = state.ssh_prompt.as_ref() { prompt.update(cx, |prompt, cx| { prompt.confirm(cx); }); return; } self.create_ssh_server(cx); } } } fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { match &self.mode { Mode::Default(None) => cx.emit(DismissEvent), Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => { self.mode = Mode::CreateDevServer(CreateDevServer { ..Default::default() }); cx.notify(); } _ => { self.mode = Mode::Default(None); self.focus_handle(cx).focus(cx); cx.notify(); } } } fn render_ssh_connection( &mut self, ix: usize, ssh_connection: SshConnection, cx: &mut ViewContext, ) -> impl IntoElement { v_flex() .w_full() .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx)) .child( h_flex() .w_full() .group("ssh-server") .justify_between() .child( h_flex() .gap_2() .w_full() .child( div() .id(("status", ix)) .relative() .child(Icon::new(IconName::Server).size(IconSize::Small)), ) .child( h_flex() .max_w(rems(26.)) .overflow_hidden() .whitespace_nowrap() .child(Label::new(ssh_connection.host.clone())), ), ) .child( h_flex() .visible_on_hover("ssh-server") .gap_1() .child({ IconButton::new("copy-dev-server-address", IconName::Copy) .icon_size(IconSize::Small) .on_click(cx.listener(move |this, _, cx| { this.update_settings_file(cx, move |servers, cx| { if let Some(content) = servers .ssh_connections .as_ref() .and_then(|connections| { connections .get(ix) .map(|connection| connection.host.clone()) }) { cx.write_to_clipboard(ClipboardItem::new_string( content, )); } }); })) .tooltip(|cx| Tooltip::text("Copy Server Address", cx)) }) .child({ IconButton::new("remove-dev-server", IconName::TrashAlt) .icon_size(IconSize::Small) .on_click(cx.listener(move |this, _, cx| { this.delete_ssh_server(ix, cx) })) .tooltip(|cx| Tooltip::text("Remove Dev Server", cx)) }), ), ) .child( v_flex() .w_full() .border_l_1() .border_color(cx.theme().colors().border_variant) .mb_1() .mx_1p5() .pl_2() .child( List::new() .empty_message("No projects.") .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| { v_flex().gap_0p5().child(self.render_ssh_project( ix, &ssh_connection, pix, p, cx, )) })) .child( h_flex().mt_1().pl_1().child( Button::new(("new-remote_project", ix), "Open Folder…") .size(ButtonSize::Default) .layer(ElevationIndex::ModalSurface) .icon(IconName::Plus) .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(cx.listener(move |this, _, cx| { this.create_ssh_project(ix, ssh_connection.clone(), cx); })), ), ), ), ) } fn render_ssh_project( &self, server_ix: usize, server: &SshConnection, ix: usize, project: &SshProject, cx: &ViewContext, ) -> impl IntoElement { let project = project.clone(); let server = server.clone(); ListItem::new(("remote-project", ix)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::Folder) .color(Color::Muted) .size(IconSize::Small), ) .child(Label::new(project.paths.join(", "))) .on_click(cx.listener(move |this, _, cx| { let Some(app_state) = this .workspace .update(cx, |workspace, _| workspace.app_state().clone()) .log_err() else { return; }; let project = project.clone(); let server = server.clone(); cx.spawn(|_, mut cx| async move { let result = open_ssh_project( server.into(), project.paths.into_iter().map(PathBuf::from).collect(), app_state, OpenOptions::default(), &mut cx, ) .await; if let Err(e) = result { log::error!("Failed to connect: {:?}", e); cx.prompt( gpui::PromptLevel::Critical, "Failed to connect", Some(&e.to_string()), &["Ok"], ) .await .ok(); } }) .detach(); })) .end_hover_slot::(Some( IconButton::new("remove-remote-project", IconName::TrashAlt) .on_click( cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)), ) .tooltip(|cx| Tooltip::text("Delete Remote Project", cx)) .into_any_element(), )) } fn update_settings_file( &mut self, cx: &mut ViewContext, f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static, ) { let Some(fs) = self .workspace .update(cx, |workspace, _| workspace.app_state().fs.clone()) .log_err() else { return; }; update_settings_file::(fs, cx, move |setting, cx| f(setting, cx)); } fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext) { self.update_settings_file(cx, move |setting, _| { if let Some(connections) = setting.ssh_connections.as_mut() { connections.remove(server); } }); } fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext) { self.update_settings_file(cx, move |setting, _| { if let Some(server) = setting .ssh_connections .as_mut() .and_then(|connections| connections.get_mut(server)) { server.projects.remove(project); } }); } fn add_ssh_server( &mut self, connection_options: remote::SshConnectionOptions, cx: &mut ViewContext, ) { self.update_settings_file(cx, move |setting, _| { setting .ssh_connections .get_or_insert(Default::default()) .push(SshConnection { host: connection_options.host, username: connection_options.username, port: connection_options.port, projects: vec![], }) }); } fn render_create_dev_server( &self, state: &CreateDevServer, cx: &mut ViewContext, ) -> impl IntoElement { let creating = state.creating.is_some(); let ssh_prompt = state.ssh_prompt.clone(); self.dev_server_name_input.update(cx, |input, cx| { input.editor().update(cx, |editor, cx| { if editor.text(cx).is_empty() { editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx); } }) }); let theme = cx.theme(); v_flex() .id("create-dev-server") .overflow_hidden() .size_full() .flex_1() .child( h_flex() .p_2() .gap_2() .items_center() .border_b_1() .border_color(theme.colors().border_variant) .child( IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft) .shape(IconButtonShape::Square) .on_click(|_, cx| { cx.dispatch_action(menu::Cancel.boxed_clone()); }), ) .child(Label::new("Connect New Dev Server")), ) .child( v_flex() .p_3() .border_b_1() .border_color(theme.colors().border_variant) .child(Label::new("SSH Arguments")) .child( Label::new("Enter the command you use to SSH into this server.") .size(LabelSize::Small) .color(Color::Muted), ) .child( h_flex() .mt_2() .w_full() .gap_2() .child(self.dev_server_name_input.clone()) .child( Button::new("create-dev-server", "Connect Server") .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) .disabled(creating) .on_click(cx.listener({ move |this, _, cx| { this.create_ssh_server(cx); } })), ), ), ) .child( h_flex() .bg(theme.colors().editor_background) .rounded_b_md() .w_full() .map(|this| { if let Some(ssh_prompt) = ssh_prompt { this.child(h_flex().w_full().child(ssh_prompt)) } else { let color = Color::Muted.color(cx); this.child( h_flex() .p_2() .w_full() .justify_center() .gap_1p5() .child( div().p_1().rounded_lg().bg(color).with_animation( "pulse-ssh-waiting-for-connection", Animation::new(Duration::from_secs(2)) .repeat() .with_easing(pulsating_between(0.2, 0.5)), move |this, progress| this.bg(color.opacity(progress)), ), ) .child( Label::new("Waiting for connection…") .size(LabelSize::Small), ), ) } }), ) } fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { let dev_servers = self.dev_server_store.read(cx).dev_servers(); let ssh_connections = SshSettings::get_global(cx) .ssh_connections() .collect::>(); let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len()); Modal::new("remote-projects", Some(self.scroll_handle.clone())) .header( ModalHeader::new().child( h_flex() .justify_between() .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)) .child( Button::new("register-dev-server-button", "Connect New Server") .style(ButtonStyle::Filled) .layer(ElevationIndex::ModalSurface) .icon(IconName::Plus) .icon_position(IconPosition::Start) .icon_color(Color::Muted) .on_click(cx.listener(|this, _, cx| { this.mode = Mode::CreateDevServer(CreateDevServer { ..Default::default() }); this.dev_server_name_input.update(cx, |text_field, cx| { text_field.editor().update(cx, |editor, cx| { editor.set_text("", cx); }); }); cx.notify(); })), ), ), ) .section( Section::new().padded(false).child( div() .border_y_1() .border_color(cx.theme().colors().border_variant) .w_full() .child( div().p_2().child( List::new() .empty_message("No dev servers registered yet.") .children(ssh_connections.iter().cloned().enumerate().map( |(ix, connection)| { self.render_ssh_connection(ix, connection, cx) .into_any_element() }, )), ), ), ), ) .footer( ModalFooter::new() .start_slot(div().child(Label::new(footer).size(LabelSize::Small))), ) } } fn get_text(element: &View, cx: &mut WindowContext) -> String { element .read(cx) .editor() .read(cx) .text(cx) .trim() .to_string() } impl ModalView for DevServerProjects {} impl FocusableView for DevServerProjects { fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { self.focus_handle.clone() } } impl EventEmitter for DevServerProjects {} impl Render for DevServerProjects { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div() .track_focus(&self.focus_handle) .elevation_3(cx) .key_context("DevServerModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .capture_any_mouse_down(cx.listener(|this, _, cx| { this.focus_handle(cx).focus(cx); })) .on_mouse_down_out(cx.listener(|this, _, cx| { if matches!(this.mode, Mode::Default(None)) { cx.emit(DismissEvent) } })) .w(rems(34.)) .max_h(rems(40.)) .child(match &self.mode { Mode::Default(_) => self.render_default(cx).into_any_element(), Mode::CreateDevServer(state) => { self.render_create_dev_server(state, cx).into_any_element() } }) } } pub fn reconnect_to_dev_server_project( workspace: View, dev_server: DevServer, dev_server_project_id: DevServerProjectId, replace_current_window: bool, cx: &mut WindowContext, ) -> Task> { let store = dev_server_projects::Store::global(cx); let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx); cx.spawn(|mut cx| async move { reconnect.await?; cx.background_executor() .timer(Duration::from_millis(1000)) .await; if let Some(project_id) = store.update(&mut cx, |store, _| { store .dev_server_project(dev_server_project_id) .and_then(|p| p.project_id) })? { workspace .update(&mut cx, move |_, cx| { open_dev_server_project( replace_current_window, dev_server_project_id, project_id, cx, ) })? .await?; } Ok(()) }) } pub fn reconnect_to_dev_server( workspace: View, dev_server: DevServer, cx: &mut WindowContext, ) -> Task> { let Some(ssh_connection_string) = dev_server.ssh_connection_string else { return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string"))); }; let dev_server_store = dev_server_projects::Store::global(cx); let get_access_token = dev_server_store.update(cx, |store, cx| { store.regenerate_dev_server_token(dev_server.id, cx) }); cx.spawn(|mut cx| async move { let access_token = get_access_token.await?.access_token; spawn_ssh_task( workspace, dev_server_store, dev_server.id, ssh_connection_string.to_string(), access_token, &mut cx, ) .await }) } pub async fn spawn_ssh_task( workspace: View, dev_server_store: Model, dev_server_id: DevServerId, ssh_connection_string: String, access_token: String, cx: &mut AsyncWindowContext, ) -> Result<()> { let terminal_panel = workspace .update(cx, |workspace, cx| workspace.panel::(cx)) .ok() .flatten() .with_context(|| anyhow!("No terminal panel"))?; let command = "sh".to_string(); let args = vec![ "-x".to_string(), "-c".to_string(), format!( 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 {}"#, access_token ), ]; let ssh_connection_string = ssh_connection_string.to_string(); let (command, args) = wrap_for_ssh( &SshCommand::DevServer(ssh_connection_string.clone()), Some((&command, &args)), None, HashMap::default(), None, ); let terminal = terminal_panel .update(cx, |terminal_panel, cx| { terminal_panel.spawn_in_new_terminal( SpawnInTerminal { id: task::TaskId("ssh-remote".into()), full_label: "Install zed over ssh".into(), label: "Install zed over ssh".into(), command, args, command_label: ssh_connection_string.clone(), cwd: None, use_new_terminal: true, allow_concurrent_runs: false, reveal: RevealStrategy::Always, hide: HideStrategy::Never, env: Default::default(), shell: Default::default(), }, cx, ) })? .await?; terminal .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))? .await; // 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. if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))? == DevServerStatus::Offline { cx.background_executor() .timer(Duration::from_millis(200)) .await } if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))? == DevServerStatus::Offline { return Err(anyhow!("couldn't reconnect"))?; } Ok(()) }