ZIm/crates/recent_projects/src/ssh_connections.rs
Mikayla Maki b2e844f2ec
Fix an issue with using non-reusable body types with redirects (#19134)
Closes #19131
Closes #19039

fixes the broken auto-updater.

I had the bright idea of using streams as the most common unit of data
transfer. Unfortunately, streams are not re-usable. So HTTP redirects
that have a stream body (like our remote server and auto update
downloads), don't redirect, as they can't reuse the stream. This PR
fixes the problem and simplifies the AsyncBody implementation now that
we're not using Isahc.

Release Notes:

- N/A
2024-10-12 13:32:08 -07:00

538 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::{path::PathBuf, sync::Arc, time::Duration};
use anyhow::Result;
use auto_update::AutoUpdater;
use editor::Editor;
use futures::channel::oneshot;
use gpui::{
percentage, px, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion, SharedString, Task,
Transformation, View,
};
use gpui::{AppContext, Model};
use release_channel::{AppVersion, ReleaseChannel};
use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
use ui::{
div, h_flex, prelude::*, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, Icon, IconButton,
IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, Tooltip,
ViewContext, VisualContext, WindowContext,
};
use workspace::{AppState, ModalView, Workspace};
#[derive(Deserialize)]
pub struct SshSettings {
pub ssh_connections: Option<Vec<SshConnection>>,
}
impl SshSettings {
pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
self.ssh_connections.clone().into_iter().flatten()
}
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct SshConnection {
pub host: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
pub projects: Vec<SshProject>,
}
impl From<SshConnection> for SshConnectionOptions {
fn from(val: SshConnection) -> Self {
SshConnectionOptions {
host: val.host,
username: val.username,
port: val.port,
password: None,
}
}
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct SshProject {
pub paths: Vec<String>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct RemoteSettingsContent {
pub ssh_connections: Option<Vec<SshConnection>>,
}
impl Settings for SshSettings {
const KEY: Option<&'static str> = None;
type FileContent = RemoteSettingsContent;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
sources.json_merge()
}
}
pub struct SshPrompt {
connection_string: SharedString,
status_message: Option<SharedString>,
error_message: Option<SharedString>,
prompt: Option<(SharedString, oneshot::Sender<Result<String>>)>,
editor: View<Editor>,
}
pub struct SshConnectionModal {
pub(crate) prompt: View<SshPrompt>,
is_separate_window: bool,
}
impl SshPrompt {
pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
let connection_string = connection_options.connection_string().into();
Self {
connection_string,
status_message: None,
error_message: None,
prompt: None,
editor: cx.new_view(Editor::single_line),
}
}
pub fn set_prompt(
&mut self,
prompt: String,
tx: oneshot::Sender<Result<String>>,
cx: &mut ViewContext<Self>,
) {
self.editor.update(cx, |editor, cx| {
if prompt.contains("yes/no") {
editor.set_masked(false, cx);
} else {
editor.set_masked(true, cx);
}
});
self.prompt = Some((prompt.into(), tx));
self.status_message.take();
cx.focus_view(&self.editor);
cx.notify();
}
pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
self.status_message = status.map(|s| s.into());
cx.notify();
}
pub fn set_error(&mut self, error_message: String, cx: &mut ViewContext<Self>) {
self.error_message = Some(error_message.into());
cx.notify();
}
pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
if let Some((_, tx)) = self.prompt.take() {
self.editor.update(cx, |editor, cx| {
tx.send(Ok(editor.text(cx))).ok();
editor.clear(cx);
});
}
}
}
impl Render for SshPrompt {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let cx = cx.window_context();
let theme = cx.theme();
v_flex()
.key_context("PasswordPrompt")
.size_full()
.justify_center()
.child(
h_flex()
.p_2()
.justify_center()
.flex_wrap()
.child(if self.error_message.is_some() {
Icon::new(IconName::XCircle)
.size(IconSize::Medium)
.color(Color::Error)
.into_any_element()
} else {
Icon::new(IconName::ArrowCircle)
.size(IconSize::Medium)
.with_animation(
"arrow-circle",
Animation::new(Duration::from_secs(2)).repeat(),
|icon, delta| {
icon.transform(Transformation::rotate(percentage(delta)))
},
)
.into_any_element()
})
.child(
div()
.ml_1()
.child(Label::new("SSH Connection").size(LabelSize::Small)),
)
.child(
div()
.text_ellipsis()
.overflow_x_hidden()
.when_some(self.error_message.as_ref(), |el, error| {
el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
})
.when(
self.error_message.is_none() && self.status_message.is_some(),
|el| {
el.child(
Label::new(format!(
"{}",
self.status_message.clone().unwrap()
))
.size(LabelSize::Small),
)
},
),
),
)
.child(div().when_some(self.prompt.as_ref(), |el, prompt| {
el.child(
h_flex()
.p_4()
.border_t_1()
.border_color(theme.colors().border_variant)
.font_buffer(cx)
.child(Label::new(prompt.0.clone()))
.child(self.editor.clone()),
)
}))
}
}
impl SshConnectionModal {
pub fn new(
connection_options: &SshConnectionOptions,
is_separate_window: bool,
cx: &mut ViewContext<Self>,
) -> Self {
Self {
prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
is_separate_window,
}
}
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
}
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent);
if self.is_separate_window {
cx.remove_window();
}
}
}
impl Render for SshConnectionModal {
fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
let connection_string = self.prompt.read(cx).connection_string.clone();
let theme = cx.theme();
let mut header_color = cx.theme().colors().text;
header_color.fade_out(0.96);
let body_color = theme.colors().editor_background;
v_flex()
.elevation_3(cx)
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::confirm))
.w(px(500.))
.border_1()
.border_color(theme.colors().border)
.child(
h_flex()
.relative()
.p_1()
.rounded_t_md()
.border_b_1()
.border_color(theme.colors().border)
.bg(header_color)
.justify_between()
.child(
div().absolute().left_0p5().top_0p5().child(
IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
.icon_size(IconSize::XSmall)
.on_click(cx.listener(move |this, _, cx| {
this.dismiss(&Default::default(), cx);
}))
.tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
),
)
.child(
h_flex()
.w_full()
.gap_2()
.justify_center()
.child(Icon::new(IconName::Server).size(IconSize::XSmall))
.child(
Label::new(connection_string)
.size(ui::LabelSize::Small)
.single_line(),
),
),
)
.child(
h_flex()
.rounded_b_md()
.bg(body_color)
.w_full()
.child(self.prompt.clone()),
)
}
}
impl FocusableView for SshConnectionModal {
fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
self.prompt.read(cx).editor.focus_handle(cx)
}
}
impl EventEmitter<DismissEvent> for SshConnectionModal {}
impl ModalView for SshConnectionModal {}
#[derive(Clone)]
pub struct SshClientDelegate {
window: AnyWindowHandle,
ui: View<SshPrompt>,
known_password: Option<String>,
}
impl remote::SshClientDelegate for SshClientDelegate {
fn ask_password(
&self,
prompt: String,
cx: &mut AsyncAppContext,
) -> oneshot::Receiver<Result<String>> {
let (tx, rx) = oneshot::channel();
let mut known_password = self.known_password.clone();
if let Some(password) = known_password.take() {
tx.send(Ok(password)).ok();
} else {
self.window
.update(cx, |_, cx| {
self.ui.update(cx, |modal, cx| {
modal.set_prompt(prompt, tx, cx);
})
})
.ok();
}
rx
}
fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
self.update_status(status, cx)
}
fn set_error(&self, error: String, cx: &mut AsyncAppContext) {
self.update_error(error, cx)
}
fn get_server_binary(
&self,
platform: SshPlatform,
cx: &mut AsyncAppContext,
) -> oneshot::Receiver<Result<(PathBuf, SemanticVersion)>> {
let (tx, rx) = oneshot::channel();
let this = self.clone();
cx.spawn(|mut cx| async move {
tx.send(this.get_server_binary_impl(platform, &mut cx).await)
.ok();
})
.detach();
rx
}
fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result<PathBuf> {
let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into())
}
}
impl SshClientDelegate {
fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
self.window
.update(cx, |_, cx| {
self.ui.update(cx, |modal, cx| {
modal.set_status(status.map(|s| s.to_string()), cx);
})
})
.ok();
}
fn update_error(&self, error: String, cx: &mut AsyncAppContext) {
self.window
.update(cx, |_, cx| {
self.ui.update(cx, |modal, cx| {
modal.set_error(error, cx);
})
})
.ok();
}
async fn get_server_binary_impl(
&self,
platform: SshPlatform,
cx: &mut AsyncAppContext,
) -> Result<(PathBuf, SemanticVersion)> {
let (version, release_channel) = cx.update(|cx| {
let global = AppVersion::global(cx);
(global, ReleaseChannel::global(cx))
})?;
// In dev mode, build the remote server binary from source
#[cfg(debug_assertions)]
if release_channel == ReleaseChannel::Dev
&& platform.arch == std::env::consts::ARCH
&& platform.os == std::env::consts::OS
{
use smol::process::{Command, Stdio};
self.update_status(Some("building remote server binary from source"), cx);
log::info!("building remote server binary from source");
run_cmd(Command::new("cargo").args([
"build",
"--package",
"remote_server",
"--target-dir",
"target/remote_server",
]))
.await?;
// run_cmd(Command::new("strip").args(["target/remote_server/debug/remote_server"]))
// .await?;
run_cmd(Command::new("gzip").args([
"-9",
"-f",
"target/remote_server/debug/remote_server",
]))
.await?;
let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
return Ok((path, version));
async fn run_cmd(command: &mut Command) -> Result<()> {
let output = command.stderr(Stdio::inherit()).output().await?;
if !output.status.success() {
Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
}
Ok(())
}
}
self.update_status(Some("checking for latest version of remote server"), cx);
let binary_path = AutoUpdater::get_latest_remote_server_release(
platform.os,
platform.arch,
release_channel,
cx,
)
.await
.map_err(|e| {
anyhow::anyhow!(
"failed to download remote server binary (os: {}, arch: {}): {}",
platform.os,
platform.arch,
e
)
})?;
Ok((binary_path, version))
}
}
pub fn connect_over_ssh(
unique_identifier: String,
connection_options: SshConnectionOptions,
ui: View<SshPrompt>,
cx: &mut WindowContext,
) -> Task<Result<Model<SshRemoteClient>>> {
let window = cx.window_handle();
let known_password = connection_options.password.clone();
remote::SshRemoteClient::new(
unique_identifier,
connection_options,
Arc::new(SshClientDelegate {
window,
ui,
known_password,
}),
cx,
)
}
pub async fn open_ssh_project(
connection_options: SshConnectionOptions,
paths: Vec<PathBuf>,
app_state: Arc<AppState>,
open_options: workspace::OpenOptions,
cx: &mut AsyncAppContext,
) -> Result<()> {
let window = if let Some(window) = open_options.replace_window {
window
} else {
let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
cx.open_window(options, |cx| {
let project = project::Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
None,
cx,
);
cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
})?
};
let delegate = window.update(cx, |workspace, cx| {
cx.activate_window();
workspace.toggle_modal(cx, |cx| {
SshConnectionModal::new(&connection_options, true, cx)
});
let ui = workspace
.active_modal::<SshConnectionModal>(cx)
.unwrap()
.read(cx)
.prompt
.clone();
Arc::new(SshClientDelegate {
window: cx.window_handle(),
ui,
known_password: connection_options.password.clone(),
})
})?;
let did_open_ssh_project = cx
.update(|cx| {
workspace::open_ssh_project(
window,
connection_options,
delegate.clone(),
app_state,
paths,
cx,
)
})?
.await;
let did_open_ssh_project = match did_open_ssh_project {
Ok(ok) => Ok(ok),
Err(e) => {
delegate.update_error(e.to_string(), cx);
Err(e)
}
};
did_open_ssh_project
}