SSH remote ui (#15129)

Still TODO:
* [x] hide this UI unless you have some ssh projects in settings
* [x] add the "open folder" flow with the new open picker
* [ ] integrate with recent projects / workspace restoration

Release Notes:

- N/A
This commit is contained in:
Conrad Irwin 2024-07-26 16:45:44 -06:00 committed by GitHub
parent be86852f95
commit 3e31955b7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1162 additions and 436 deletions

View file

@ -27,6 +27,7 @@ use log::LevelFilter;
use assets::Assets;
use node_runtime::RealNodeRuntime;
use parking_lot::Mutex;
use recent_projects::open_ssh_project;
use release_channel::{AppCommitSha, AppVersion};
use session::Session;
use settings::{handle_settings_file_changes, watch_config_file, Settings, SettingsStore};
@ -47,7 +48,7 @@ use welcome::{show_welcome_view, BaseKeymap, FIRST_OPEN};
use workspace::{AppState, WorkspaceSettings, WorkspaceStore};
use zed::{
app_menus, build_window_options, handle_cli_connection, handle_keymap_file_changes,
initialize_workspace, open_paths_with_positions, open_ssh_paths, OpenListener, OpenRequest,
initialize_workspace, open_paths_with_positions, OpenListener, OpenRequest,
};
use crate::zed::inline_completion_registry;
@ -537,7 +538,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
if let Some(connection_info) = request.ssh_connection {
cx.spawn(|mut cx| async move {
open_ssh_paths(
open_ssh_project(
connection_info,
request.open_paths,
app_state,

View file

@ -6,7 +6,6 @@ pub(crate) mod linux_prompts;
pub(crate) mod only_instance;
mod open_listener;
pub(crate) mod session;
mod ssh_connection_modal;
pub use app_menus::*;
use breadcrumbs::Breadcrumbs;

View file

@ -1,9 +1,6 @@
use crate::restorable_workspace_locations;
use crate::{
handle_open_request, init_headless, init_ui, zed::ssh_connection_modal::SshConnectionModal,
};
use crate::{handle_open_request, init_headless, init_ui};
use anyhow::{anyhow, Context, Result};
use auto_update::AutoUpdater;
use cli::{ipc, IpcHandshake};
use cli::{ipc::IpcSender, CliRequest, CliResponse};
use client::parse_zed_link;
@ -14,12 +11,9 @@ use editor::Editor;
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use futures::channel::{mpsc, oneshot};
use futures::{FutureExt, SinkExt, StreamExt};
use gpui::{
AppContext, AsyncAppContext, Global, SemanticVersion, View, VisualContext as _, WindowHandle,
};
use gpui::{AppContext, AsyncAppContext, Global, WindowHandle};
use language::{Bias, Point};
use release_channel::{AppVersion, ReleaseChannel};
use remote::SshPlatform;
use remote::SshConnectionOptions;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@ -37,15 +31,7 @@ pub struct OpenRequest {
pub open_paths: Vec<PathLikeWithPosition<PathBuf>>,
pub open_channel_notes: Vec<(u64, Option<String>)>,
pub join_channel: Option<u64>,
pub ssh_connection: Option<SshConnectionInfo>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct SshConnectionInfo {
pub username: String,
pub password: Option<String>,
pub host: String,
pub port: u16,
pub ssh_connection: Option<SshConnectionOptions>,
}
impl OpenRequest {
@ -86,16 +72,13 @@ impl OpenRequest {
.host()
.ok_or_else(|| anyhow!("missing host in ssh url: {}", file))?
.to_string();
let username = url.username().to_string();
if username.is_empty() {
return Err(anyhow!("missing username in ssh url: {}", file));
}
let username = Some(url.username().to_string()).filter(|s| !s.is_empty());
let password = url.password().map(|s| s.to_string());
let port = url.port().unwrap_or(22);
let port = url.port();
if !self.open_paths.is_empty() {
return Err(anyhow!("cannot open both local and ssh paths"));
}
let connection = SshConnectionInfo {
let connection = SshConnectionOptions {
username,
password,
host,
@ -158,119 +141,6 @@ impl OpenListener {
}
}
#[derive(Clone)]
struct SshClientDelegate {
window: WindowHandle<Workspace>,
modal: View<SshConnectionModal>,
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.modal.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 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.modal.update(cx, |modal, cx| {
modal.set_status(status.map(|s| s.to_string()), cx);
});
})
.ok();
}
async fn get_server_binary_impl(
&self,
platform: SshPlatform,
cx: &mut AsyncAppContext,
) -> Result<(PathBuf, SemanticVersion)> {
let (version, release_channel) =
cx.update(|cx| (AppVersion::global(cx), ReleaseChannel::global(cx)))?;
// In dev mode, build the remote server binary from source
#[cfg(debug_assertions)]
if crate::stdout_is_a_pty()
&& 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"])).await?;
run_cmd(Command::new("strip").args(["target/debug/remote_server"])).await?;
run_cmd(Command::new("gzip").args(["-9", "-f", "target/debug/remote_server"])).await?;
let path = std::env::current_dir()?.join("target/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!("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?;
Ok((binary_path, version))
}
}
#[cfg(target_os = "linux")]
pub fn listen_for_cli_connections(opener: OpenListener) -> Result<()> {
use release_channel::RELEASE_CHANNEL_NAME;
@ -322,81 +192,6 @@ fn connect_to_cli(
Ok((async_request_rx, response_tx))
}
pub async fn open_ssh_paths(
connection_info: SshConnectionInfo,
paths: Vec<PathLikeWithPosition<PathBuf>>,
app_state: Arc<AppState>,
_open_options: workspace::OpenOptions,
cx: &mut AsyncAppContext,
) -> Result<()> {
let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
let window = 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(),
cx,
);
cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
})?;
let modal = window.update(cx, |workspace, cx| {
cx.activate_window();
workspace.toggle_modal(cx, |cx| {
SshConnectionModal::new(connection_info.host.clone(), cx)
});
workspace.active_modal::<SshConnectionModal>(cx).unwrap()
})?;
let session = remote::SshSession::client(
connection_info.username,
connection_info.host,
connection_info.port,
Arc::new(SshClientDelegate {
window,
modal,
known_password: connection_info.password,
}),
cx,
)
.await;
if session.is_err() {
window.update(cx, |_, cx| cx.remove_window()).ok();
}
let session = session?;
let project = cx.update(|cx| {
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,
)
})?;
for path in paths {
project
.update(cx, |project, cx| {
project.find_or_create_worktree(&path.path_like, true, cx)
})?
.await?;
}
window.update(cx, |_, cx| {
cx.replace_root_view(|cx| Workspace::new(None, project, app_state, cx))
})?;
window.update(cx, |_, cx| cx.activate_window())?;
Ok(())
}
pub async fn open_paths_with_positions(
path_likes: &Vec<PathLikeWithPosition<PathBuf>>,
app_state: Arc<AppState>,

View file

@ -1,97 +0,0 @@
use anyhow::Result;
use editor::Editor;
use futures::channel::oneshot;
use gpui::{
px, DismissEvent, EventEmitter, FocusableView, ParentElement as _, Render, SharedString, View,
};
use ui::{
v_flex, FluentBuilder as _, InteractiveElement, Label, LabelCommon, Styled, StyledExt as _,
ViewContext, VisualContext,
};
use workspace::ModalView;
pub struct SshConnectionModal {
host: SharedString,
status_message: Option<SharedString>,
prompt: Option<(SharedString, oneshot::Sender<Result<String>>)>,
editor: View<Editor>,
}
impl SshConnectionModal {
pub fn new(host: String, cx: &mut ViewContext<Self>) -> Self {
Self {
host: host.into(),
prompt: None,
status_message: None,
editor: cx.new_view(|cx| Editor::single_line(cx)),
}
}
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_redact_all(false, cx);
} else {
editor.set_redact_all(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();
}
fn confirm(&mut self, _: &menu::Confirm, 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);
});
}
}
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.remove_window();
}
}
impl Render for SshConnectionModal {
fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
v_flex()
.key_context("PasswordPrompt")
.elevation_3(cx)
.p_4()
.gap_2()
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::confirm))
.w(px(400.))
.child(Label::new(format!("SSH: {}", self.host)).size(ui::LabelSize::Large))
.when_some(self.status_message.as_ref(), |el, status| {
el.child(Label::new(status.clone()))
})
.when_some(self.prompt.as_ref(), |el, prompt| {
el.child(Label::new(prompt.0.clone()))
.child(self.editor.clone())
})
}
}
impl FocusableView for SshConnectionModal {
fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
self.editor.focus_handle(cx)
}
}
impl EventEmitter<DismissEvent> for SshConnectionModal {}
impl ModalView for SshConnectionModal {}