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

7
Cargo.lock generated
View File

@ -8504,18 +8504,24 @@ name = "recent_projects"
version = "0.1.0"
dependencies = [
"anyhow",
"auto_update",
"client",
"dev_server_projects",
"editor",
"futures 0.3.28",
"fuzzy",
"gpui",
"language",
"log",
"markdown",
"menu",
"ordered-float 2.10.0",
"picker",
"project",
"release_channel",
"remote",
"rpc",
"schemars",
"serde",
"serde_json",
"settings",
@ -8692,6 +8698,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
"shellexpand 2.1.2",
"smol",
"toml 0.8.16",
"util",

View File

@ -965,5 +965,21 @@
// {
// "W": "workspace::Save"
// }
"command_aliases": {}
"command_aliases": {},
// ssh_connections is an array of ssh connections.
// By default this setting is null, which disables the direct ssh connection support.
// You can configure these from `project: Open Remote` in the command palette.
// Zed's ssh support will pull configuration from your ~/.ssh too.
// Examples:
// [
// {
// "host": "example-box",
// "projects": [
// {
// "paths": ["/home/user/code/zed"]
// }
// ]
// }
// ]
"ssh_connections": null
}

View File

@ -109,6 +109,7 @@ pub struct DisplayMap {
crease_map: CreaseMap,
fold_placeholder: FoldPlaceholder,
pub clip_at_line_ends: bool,
pub(crate) masked: bool,
}
impl DisplayMap {
@ -156,6 +157,7 @@ impl DisplayMap {
text_highlights: Default::default(),
inlay_highlights: Default::default(),
clip_at_line_ends: false,
masked: false,
}
}
@ -182,6 +184,7 @@ impl DisplayMap {
text_highlights: self.text_highlights.clone(),
inlay_highlights: self.inlay_highlights.clone(),
clip_at_line_ends: self.clip_at_line_ends,
masked: self.masked,
fold_placeholder: self.fold_placeholder.clone(),
}
}
@ -499,6 +502,7 @@ pub struct DisplaySnapshot {
text_highlights: TextHighlights,
inlay_highlights: InlayHighlights,
clip_at_line_ends: bool,
masked: bool,
pub(crate) fold_placeholder: FoldPlaceholder,
}
@ -650,6 +654,7 @@ impl DisplaySnapshot {
.chunks(
display_row.0..self.max_point().row().next_row().0,
false,
self.masked,
Highlights::default(),
)
.map(|h| h.text)
@ -657,9 +662,9 @@ impl DisplaySnapshot {
/// Returns text chunks starting at the end of the given display row in reverse until the start of the file
pub fn reverse_text_chunks(&self, display_row: DisplayRow) -> impl Iterator<Item = &str> {
(0..=display_row.0).rev().flat_map(|row| {
(0..=display_row.0).rev().flat_map(move |row| {
self.block_snapshot
.chunks(row..row + 1, false, Highlights::default())
.chunks(row..row + 1, false, self.masked, Highlights::default())
.map(|h| h.text)
.collect::<Vec<_>>()
.into_iter()
@ -676,6 +681,7 @@ impl DisplaySnapshot {
self.block_snapshot.chunks(
display_rows.start.0..display_rows.end.0,
language_aware,
self.masked,
Highlights {
text_highlights: Some(&self.text_highlights),
inlay_highlights: Some(&self.inlay_highlights),

View File

@ -23,6 +23,7 @@ use text::Edit;
use ui::ElementId;
const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize];
const BULLETS: &str = "********************************************************************************************************************************";
/// Tracks custom blocks such as diagnostics that should be displayed within buffer.
///
@ -285,6 +286,7 @@ pub struct BlockChunks<'a> {
input_chunk: Chunk<'a>,
output_row: u32,
max_output_row: u32,
masked: bool,
}
#[derive(Clone)]
@ -893,6 +895,7 @@ impl BlockSnapshot {
self.chunks(
0..self.transforms.summary().output_rows,
false,
false,
Highlights::default(),
)
.map(|chunk| chunk.text)
@ -903,6 +906,7 @@ impl BlockSnapshot {
&'a self,
rows: Range<u32>,
language_aware: bool,
masked: bool,
highlights: Highlights<'a>,
) -> BlockChunks<'a> {
let max_output_row = cmp::min(rows.end, self.transforms.summary().output_rows);
@ -941,6 +945,7 @@ impl BlockSnapshot {
transforms: cursor,
output_row: rows.start,
max_output_row,
masked,
}
}
@ -1229,12 +1234,20 @@ impl<'a> Iterator for BlockChunks<'a> {
let (prefix_rows, prefix_bytes) =
offset_for_row(self.input_chunk.text, transform_end - self.output_row);
self.output_row += prefix_rows;
let (prefix, suffix) = self.input_chunk.text.split_at(prefix_bytes);
let (mut prefix, suffix) = self.input_chunk.text.split_at(prefix_bytes);
self.input_chunk.text = suffix;
if self.output_row == transform_end {
self.transforms.next(&());
}
if self.masked {
// Not great for multibyte text because to keep cursor math correct we
// need to have the same number of bytes in the input as output.
let chars = prefix.chars().count();
let bullet_len = chars;
prefix = &BULLETS[..bullet_len];
}
Some(Chunk {
text: prefix,
..self.input_chunk.clone()
@ -2048,6 +2061,7 @@ mod tests {
.chunks(
start_row as u32..blocks_snapshot.max_point().row + 1,
false,
false,
Highlights::default(),
)
.map(|chunk| chunk.text)

View File

@ -480,7 +480,6 @@ pub struct Editor {
mode: EditorMode,
show_breadcrumbs: bool,
show_gutter: bool,
redact_all: bool,
show_line_numbers: Option<bool>,
show_git_diff_gutter: Option<bool>,
show_code_actions: Option<bool>,
@ -1803,7 +1802,6 @@ impl Editor {
show_code_actions: None,
show_runnables: None,
show_wrap_guides: None,
redact_all: false,
show_indent_guides,
placeholder_text: None,
highlight_order: 0,
@ -10420,9 +10418,11 @@ impl Editor {
cx.notify();
}
pub fn set_redact_all(&mut self, redact_all: bool, cx: &mut ViewContext<Self>) {
self.redact_all = redact_all;
cx.notify();
pub fn set_masked(&mut self, masked: bool, cx: &mut ViewContext<Self>) {
if self.display_map.read(cx).masked != masked {
self.display_map.update(cx, |map, _| map.masked = masked);
}
cx.notify()
}
pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut ViewContext<Self>) {
@ -11108,10 +11108,6 @@ impl Editor {
display_snapshot: &DisplaySnapshot,
cx: &WindowContext,
) -> Vec<Range<DisplayPoint>> {
if self.redact_all {
return vec![DisplayPoint::zero()..display_snapshot.max_point()];
}
display_snapshot
.buffer_snapshot
.redacted_ranges(search_range, |file| {

View File

@ -940,6 +940,15 @@ where
pub fn half_perimeter(&self) -> T {
self.size.width.clone() + self.size.height.clone()
}
/// centered_at creates a new bounds centered at the given point.
pub fn centered_at(center: Point<T>, size: Size<T>) -> Self {
let origin = Point {
x: center.x - size.width.half(),
y: center.y - size.height.half(),
};
Self::new(origin, size)
}
}
impl<T: Clone + Default + Debug + PartialOrd + Add<T, Output = T> + Sub<Output = T>> Bounds<T> {

View File

@ -14,18 +14,25 @@ doctest = false
[dependencies]
anyhow.workspace = true
auto_update.workspace = true
release_channel.workspace = true
client.workspace = true
editor.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
log.workspace = true
markdown.workspace = true
menu.workspace = true
ordered-float.workspace = true
picker.workspace = true
project.workspace = true
dev_server_projects.workspace = true
remote.workspace = true
rpc.workspace = true
schemars.workspace = true
serde.workspace = true
settings.workspace = true
smol.workspace = true
task.workspace = true
terminal_view.workspace = true

View File

@ -1,11 +1,14 @@
use std::path::PathBuf;
use std::time::Duration;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use client::Client;
use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
use editor::Editor;
use gpui::AsyncWindowContext;
use gpui::PathPromptOptions;
use gpui::Subscription;
use gpui::Task;
use gpui::WeakView;
@ -20,6 +23,8 @@ use rpc::{
proto::{CreateDevServerResponse, DevServerStatus},
ErrorCode, ErrorExt,
};
use settings::update_settings_file;
use settings::Settings;
use task::HideStrategy;
use task::RevealStrategy;
use task::SpawnInTerminal;
@ -32,11 +37,21 @@ use ui::{
RadioWithLabel, Tooltip,
};
use ui_input::{FieldLabelLayout, TextField};
use util::paths::PathLikeWithPosition;
use util::ResultExt;
use workspace::notifications::NotifyResultExt;
use workspace::OpenOptions;
use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
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 {
@ -53,10 +68,11 @@ pub struct DevServerProjects {
#[derive(Default)]
struct CreateDevServer {
creating: Option<Task<()>>,
creating: Option<Task<Option<()>>>,
dev_server_id: Option<DevServerId>,
access_token: Option<String>,
manual_setup: bool,
ssh_prompt: Option<View<SshPrompt>>,
kind: NewServerKind,
}
struct CreateDevServerProject {
@ -70,6 +86,14 @@ enum Mode {
CreateDevServer(CreateDevServer),
}
#[derive(Default, PartialEq, Eq, Clone, Copy)]
enum NewServerKind {
DirectSSH,
#[default]
LegacySSH,
Manual,
}
impl DevServerProjects {
pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
workspace.register_action(|workspace, _: &OpenRemote, cx| {
@ -255,9 +279,203 @@ impl DevServerProjects {
}));
}
pub fn create_or_update_dev_server(
fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
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<String> = None;
let mut port: Option<u16> = 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,
port,
password: None,
};
let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
let connection = connect_over_ssh(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| {
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 {
kind: NewServerKind::DirectSSH,
..Default::default()
});
cx.notify()
})
.log_err(),
};
None
});
self.mode = Mode::CreateDevServer(CreateDevServer {
kind: NewServerKind::DirectSSH,
ssh_prompt: Some(ssh_prompt.clone()),
creating: Some(creating),
..Default::default()
});
}
fn create_ssh_project(
&mut self,
manual_setup: bool,
ix: usize,
ssh_connection: SshConnection,
cx: &mut ViewContext<Self>,
) {
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, cx));
let prompt = workspace
.active_modal::<SshConnectionModal>(cx)
.unwrap()
.read(cx)
.prompt
.clone();
let connect = connect_over_ssh(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::<SshSettings>(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::<Vec<_>>();
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| {
Workspace::new(None, project.clone(), app_state.clone(), cx)
})
})
.log_err();
})
.detach()
})
})
}
fn create_or_update_dev_server(
&mut self,
kind: NewServerKind,
existing_id: Option<DevServerId>,
access_token: Option<String>,
cx: &mut ViewContext<Self>,
@ -267,6 +485,12 @@ impl DevServerProjects {
return;
}
let manual_setup = match kind {
NewServerKind::DirectSSH => unreachable!(),
NewServerKind::LegacySSH => false,
NewServerKind::Manual => true,
};
let ssh_connection_string = if manual_setup {
None
} else if name.contains(' ') {
@ -351,10 +575,10 @@ impl DevServerProjects {
this.update(&mut cx, |this, cx| {
this.focus_handle.focus(cx);
this.mode = Mode::CreateDevServer(CreateDevServer {
creating: None,
dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
access_token: Some(dev_server.access_token),
manual_setup,
kind,
..Default::default()
});
cx.notify();
})?;
@ -363,10 +587,10 @@ impl DevServerProjects {
Err(e) => {
this.update(&mut cx, |this, cx| {
this.mode = Mode::CreateDevServer(CreateDevServer {
creating: None,
dev_server_id: existing_id,
access_token: None,
manual_setup,
kind,
..Default::default()
});
cx.notify()
})
@ -383,7 +607,8 @@ impl DevServerProjects {
creating: Some(task),
dev_server_id: existing_id,
access_token,
manual_setup,
kind,
..Default::default()
});
cx.notify()
}
@ -477,9 +702,19 @@ impl DevServerProjects {
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;
}
if state.kind == NewServerKind::DirectSSH {
self.create_ssh_server(cx);
return;
}
if state.creating.is_none() || state.dev_server_id.is_some() {
self.create_or_update_dev_server(
state.manual_setup,
state.kind,
state.dev_server_id,
state.access_token.clone(),
cx,
@ -490,8 +725,16 @@ impl DevServerProjects {
}
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
match self.mode {
match &self.mode {
Mode::Default(None) => cx.emit(DismissEvent),
Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
self.mode = Mode::CreateDevServer(CreateDevServer {
kind: NewServerKind::DirectSSH,
..Default::default()
});
cx.notify();
return;
}
_ => {
self.mode = Mode::Default(None);
self.focus_handle(cx).focus(cx);
@ -509,7 +752,11 @@ impl DevServerProjects {
let dev_server_id = dev_server.id;
let status = dev_server.status;
let dev_server_name = dev_server.name.clone();
let manual_setup = dev_server.ssh_connection_string.is_none();
let kind = if dev_server.ssh_connection_string.is_some() {
NewServerKind::LegacySSH
} else {
NewServerKind::Manual
};
v_flex()
.w_full()
@ -574,9 +821,8 @@ impl DevServerProjects {
.on_click(cx.listener(move |this, _, cx| {
this.mode = Mode::CreateDevServer(CreateDevServer {
dev_server_id: Some(dev_server_id),
creating: None,
access_token: None,
manual_setup,
kind,
..Default::default()
});
let dev_server_name = dev_server_name.clone();
this.dev_server_name_input.update(
@ -652,6 +898,181 @@ impl DevServerProjects {
)
}
fn render_ssh_connection(
&mut self,
ix: usize,
ssh_connection: SshConnection,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
v_flex()
.w_full()
.child(
h_flex().group("ssh-server").justify_between().child(
h_flex()
.gap_2()
.child(
div()
.id(("status", ix))
.relative()
.child(Icon::new(IconName::Server).size(IconSize::Small)),
)
.child(
div()
.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("remove-dev-server", IconName::Trash)
.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()
.bg(cx.theme().colors().background)
.border_1()
.border_color(cx.theme().colors().border_variant)
.rounded_md()
.my_1()
.py_0p5()
.px_3()
.child(
List::new()
.empty_message("No projects.")
.children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
}))
.child(
ListItem::new("new-remote_project")
.start_slot(Icon::new(IconName::Plus))
.child(Label::new("Open folder…"))
.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<Self>,
) -> impl IntoElement {
let project = project.clone();
let server = server.clone();
ListItem::new(("remote-project", ix))
.start_slot(Icon::new(IconName::FileTree))
.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(|path| PathLikeWithPosition::from_path(PathBuf::from(path)))
.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::<AnyElement>(Some(
IconButton::new("remove-remote-project", IconName::Trash)
.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<Self>,
f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static,
) {
let Some(fs) = self
.workspace
.update(cx, |workspace, _| workspace.app_state().fs.clone())
.log_err()
else {
return;
};
update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting));
}
fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
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>) {
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>,
) {
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_new_project(
&mut self,
creating: bool,
@ -715,7 +1136,13 @@ impl DevServerProjects {
let creating = state.creating.is_some();
let dev_server_id = state.dev_server_id;
let access_token = state.access_token.clone();
let manual_setup = state.manual_setup;
let ssh_prompt = state.ssh_prompt.clone();
let use_direct_ssh = SshSettings::get_global(cx).use_direct_ssh();
let mut kind = state.kind;
if use_direct_ssh && kind == NewServerKind::LegacySSH {
kind = NewServerKind::DirectSSH;
}
let status = dev_server_id
.map(|id| self.dev_server_store.read(cx).dev_server_status(id))
@ -724,10 +1151,10 @@ impl DevServerProjects {
let name = self.dev_server_name_input.update(cx, |input, cx| {
input.editor().update(cx, |editor, cx| {
if editor.text(cx).is_empty() {
if manual_setup {
editor.set_placeholder_text("example-server", cx)
} else {
editor.set_placeholder_text("ssh host", cx)
match kind {
NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
}
}
editor.text(cx)
@ -735,7 +1162,8 @@ impl DevServerProjects {
});
const MANUAL_SETUP_MESSAGE: &str = "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine.";
const SSH_SETUP_MESSAGE: &str = "Enter the command you use to ssh into this server.\nFor example: `ssh me@my.server` or `gh cs ssh -c example`.";
const SSH_SETUP_MESSAGE: &str =
"Enter the command you use to ssh into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
.header(
@ -745,7 +1173,7 @@ impl DevServerProjects {
)
.section(
Section::new()
.header(if manual_setup {
.header(if kind == NewServerKind::Manual {
"Server Name".into()
} else {
"SSH arguments".into()
@ -763,46 +1191,66 @@ impl DevServerProjects {
v_flex()
.w_full()
.gap_y(Spacing::Large.rems(cx))
.child(
v_flex()
.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Connect via SSH (default)"),
!manual_setup,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(CreateDevServer {
manual_setup,
..
}) = &mut this.mode
{
*manual_setup = false;
}
cx.notify()
}
}),
))
.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Manual Setup"),
manual_setup,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(CreateDevServer {
manual_setup,
..
}) = &mut this.mode
{
*manual_setup = true;
}
cx.notify()
}
}),
)),
)
.when(dev_server_id.is_none(), |el| {
.when(ssh_prompt.is_none(), |el| {
el.child(
if manual_setup {
v_flex()
.when(use_direct_ssh, |el| {
el.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Connect via SSH (default)"),
NewServerKind::DirectSSH == kind,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(
CreateDevServer { kind, .. },
) = &mut this.mode
{
*kind = NewServerKind::DirectSSH;
}
cx.notify()
}
}),
))
})
.when(!use_direct_ssh, |el| {
el.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Configure over SSH (default)"),
kind == NewServerKind::LegacySSH,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(
CreateDevServer { kind, .. },
) = &mut this.mode
{
*kind = NewServerKind::LegacySSH;
}
cx.notify()
}
}),
))
})
.child(RadioWithLabel::new(
"use-server-name-in-ssh",
Label::new("Configure manually"),
kind == NewServerKind::Manual,
cx.listener({
move |this, _, cx| {
if let Mode::CreateDevServer(
CreateDevServer { kind, .. },
) = &mut this.mode
{
*kind = NewServerKind::Manual;
}
cx.notify()
}
}),
)),
)
})
.when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
el.child(
if kind == NewServerKind::Manual {
Label::new(MANUAL_SETUP_MESSAGE)
} else {
Label::new(SSH_SETUP_MESSAGE)
@ -811,17 +1259,15 @@ impl DevServerProjects {
.color(Color::Muted),
)
})
.when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
.when(dev_server_id.is_some() && access_token.is_none(), |el| {
el.child(
if manual_setup {
if kind == NewServerKind::Manual {
Label::new(
"Note: updating the dev server generate a new token",
)
} else {
Label::new(
"Enter the command you use to ssh into this server.\n\
For example: `ssh me@my.server` or `gh cs ssh -c example`.",
)
Label::new(SSH_SETUP_MESSAGE)
}
.size(LabelSize::Small)
.color(Color::Muted),
@ -832,7 +1278,7 @@ impl DevServerProjects {
el.child(self.render_dev_server_token_creating(
access_token,
name,
manual_setup,
kind,
status,
creating,
cx,
@ -854,7 +1300,7 @@ impl DevServerProjects {
} else {
Button::new(
"create-dev-server",
if manual_setup {
if kind == NewServerKind::Manual {
if dev_server_id.is_some() {
"Update"
} else {
@ -874,8 +1320,12 @@ impl DevServerProjects {
.on_click(cx.listener({
let access_token = access_token.clone();
move |this, _, cx| {
if kind == NewServerKind::DirectSSH {
this.create_ssh_server(cx);
return;
}
this.create_or_update_dev_server(
manual_setup,
kind,
dev_server_id,
access_token.clone(),
cx,
@ -890,13 +1340,13 @@ impl DevServerProjects {
&self,
access_token: String,
dev_server_name: String,
manual_setup: bool,
kind: NewServerKind,
status: DevServerStatus,
creating: bool,
cx: &mut ViewContext<Self>,
) -> Div {
self.markdown.update(cx, |markdown, cx| {
if manual_setup {
if kind == NewServerKind::Manual {
markdown.reset(format!("Please log into '{}'. If you don't yet have zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen to start zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
} else {
markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using manual setup.".to_string(), cx);
@ -909,7 +1359,8 @@ impl DevServerProjects {
.gap_2()
.child(v_flex().w_full().text_sm().child(self.markdown.clone()))
.map(|el| {
if status == DevServerStatus::Offline && !manual_setup && !creating {
if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
{
el.child(
h_flex()
.gap_2()
@ -941,6 +1392,9 @@ impl DevServerProjects {
fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let dev_servers = self.dev_server_store.read(cx).dev_servers();
let ssh_connections = SshSettings::get_global(cx)
.ssh_connections()
.collect::<Vec<_>>();
let Mode::Default(create_dev_server_project) = &self.mode else {
unreachable!()
@ -998,16 +1452,19 @@ impl DevServerProjects {
List::new()
.empty_message("No dev servers registered.")
.header(Some(
ListHeader::new("Dev Servers").end_slot(
Button::new("register-dev-server-button", "New Server")
ListHeader::new("Connections").end_slot(
Button::new("register-dev-server-button", "Connect")
.icon(IconName::Plus)
.icon_position(IconPosition::Start)
.tooltip(|cx| {
Tooltip::text("Register a new dev server", cx)
Tooltip::text("Connect to a new server", cx)
})
.on_click(cx.listener(|this, _, cx| {
this.mode = Mode::CreateDevServer(
CreateDevServer::default(),
CreateDevServer {
kind: if SshSettings::get_global(cx).use_direct_ssh() { NewServerKind::DirectSSH } else { NewServerKind::LegacySSH },
..Default::default()
}
);
this.dev_server_name_input.update(
cx,
@ -1024,6 +1481,10 @@ impl DevServerProjects {
})),
),
))
.children(ssh_connections.iter().cloned().enumerate().map(|(ix, connection)| {
self.render_ssh_connection(ix, connection, cx)
.into_any_element()
}))
.children(dev_servers.iter().map(|dev_server| {
let creating = if creating_dev_server == Some(dev_server.id) {
is_creating
@ -1093,7 +1554,7 @@ pub fn reconnect_to_dev_server_project(
dev_server_project_id: DevServerProjectId,
replace_current_window: bool,
cx: &mut WindowContext,
) -> Task<anyhow::Result<()>> {
) -> Task<Result<()>> {
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 {
@ -1128,7 +1589,7 @@ pub fn reconnect_to_dev_server(
workspace: View<Workspace>,
dev_server: DevServer,
cx: &mut WindowContext,
) -> Task<anyhow::Result<()>> {
) -> Task<Result<()>> {
let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
};
@ -1159,7 +1620,7 @@ pub async fn spawn_ssh_task(
ssh_connection_string: String,
access_token: String,
cx: &mut AsyncWindowContext,
) -> anyhow::Result<()> {
) -> Result<()> {
let terminal_panel = workspace
.update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
.ok()

View File

@ -1,5 +1,8 @@
mod dev_servers;
pub mod disconnected_overlay;
mod ssh_connections;
mod ssh_remotes;
pub use ssh_connections::open_ssh_project;
use client::{DevServerProjectId, ProjectId};
use dev_servers::reconnect_to_dev_server_project;
@ -17,6 +20,8 @@ use picker::{
};
use rpc::proto::DevServerStatus;
use serde::Deserialize;
use settings::Settings;
use ssh_connections::SshSettings;
use std::{
path::{Path, PathBuf},
sync::Arc,
@ -44,6 +49,7 @@ gpui::impl_actions!(projects, [OpenRecent]);
gpui::actions!(projects, [OpenRemote]);
pub fn init(cx: &mut AppContext) {
SshSettings::register(cx);
cx.observe_new_views(RecentProjects::register).detach();
cx.observe_new_views(DevServerProjects::register).detach();
cx.observe_new_views(DisconnectedOverlay::register).detach();

View File

@ -0,0 +1,412 @@
use std::{path::PathBuf, sync::Arc, time::Duration};
use anyhow::Result;
use auto_update::AutoUpdater;
use editor::Editor;
use futures::channel::oneshot;
use gpui::AppContext;
use gpui::{
percentage, px, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion, SharedString, Task,
Transformation, View,
};
use release_channel::{AppVersion, ReleaseChannel};
use remote::{SshConnectionOptions, SshPlatform, SshSession};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
use ui::{
h_flex, v_flex, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement,
Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext, WindowContext,
};
use util::paths::PathLikeWithPosition;
use workspace::{AppState, ModalView, Workspace};
#[derive(Deserialize)]
pub struct SshSettings {
pub ssh_connections: Option<Vec<SshConnection>>,
}
impl SshSettings {
pub fn use_direct_ssh(&self) -> bool {
self.ssh_connections.is_some()
}
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>,
prompt: Option<(SharedString, oneshot::Sender<Result<String>>)>,
editor: View<Editor>,
}
pub struct SshConnectionModal {
pub(crate) prompt: View<SshPrompt>,
}
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,
prompt: 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_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 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 {
v_flex()
.key_context("PasswordPrompt")
.p_4()
.size_full()
.child(
h_flex()
.gap_2()
.child(
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)))
},
),
)
.child(
Label::new(format!("ssh {}", self.connection_string))
.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 SshConnectionModal {
pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
Self {
prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
}
}
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.remove_window();
}
}
impl Render for SshConnectionModal {
fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
v_flex()
.elevation_3(cx)
.p_4()
.gap_2()
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::confirm))
.w(px(400.))
.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 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();
}
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"])).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::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: {}", e))?;
Ok((binary_path, version))
}
}
pub fn connect_over_ssh(
connection_options: SshConnectionOptions,
ui: View<SshPrompt>,
cx: &mut WindowContext,
) -> Task<Result<Arc<SshSession>>> {
let window = cx.window_handle();
let known_password = connection_options.password.clone();
cx.spawn(|mut cx| async move {
remote::SshSession::client(
connection_options,
Arc::new(SshClientDelegate {
window,
ui,
known_password,
}),
&mut cx,
)
.await
})
}
pub async fn open_ssh_project(
connection_options: SshConnectionOptions,
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 result = window
.update(cx, |workspace, cx| {
cx.activate_window();
workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
let ui = workspace
.active_modal::<SshConnectionModal>(cx)
.unwrap()
.read(cx)
.prompt
.clone();
connect_over_ssh(connection_options, ui, cx)
})?
.await;
if result.is_err() {
window.update(cx, |_, cx| cx.remove_window()).ok();
}
let session = result?;
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(())
}

View File

@ -0,0 +1 @@

View File

@ -2,4 +2,4 @@ pub mod json_log;
pub mod protocol;
pub mod ssh_session;
pub use ssh_session::{SshClientDelegate, SshPlatform, SshSession};
pub use ssh_session::{SshClientDelegate, SshConnectionOptions, SshPlatform, SshSession};

View File

@ -58,13 +58,57 @@ pub struct SshSession {
}
struct SshClientState {
connection_options: SshConnectionOptions,
socket_path: PathBuf,
port: u16,
url: String,
_master_process: process::Child,
_temp_dir: TempDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshConnectionOptions {
pub host: String,
pub username: Option<String>,
pub port: Option<u16>,
pub password: Option<String>,
}
impl SshConnectionOptions {
pub fn ssh_url(&self) -> String {
let mut result = String::from("ssh://");
if let Some(username) = &self.username {
result.push_str(username);
result.push('@');
}
result.push_str(&self.host);
if let Some(port) = self.port {
result.push(':');
result.push_str(&port.to_string());
}
result
}
fn scp_url(&self) -> String {
if let Some(username) = &self.username {
format!("{}@{}", username, self.host)
} else {
self.host.clone()
}
}
pub fn connection_string(&self) -> String {
let host = if let Some(username) = &self.username {
format!("{}@{}", username, self.host)
} else {
self.host.clone()
};
if let Some(port) = &self.port {
format!("{}:{}", host, port)
} else {
host
}
}
}
struct SpawnRequest {
command: String,
process_tx: oneshot::Sender<process::Child>,
@ -95,13 +139,11 @@ type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, ones
impl SshSession {
pub async fn client(
user: String,
host: String,
port: u16,
connection_options: SshConnectionOptions,
delegate: Arc<dyn SshClientDelegate>,
cx: &mut AsyncAppContext,
) -> Result<Arc<Self>> {
let client_state = SshClientState::new(user, host, port, delegate.clone(), cx).await?;
let client_state = SshClientState::new(connection_options, delegate.clone(), cx).await?;
let platform = client_state.query_platform().await?;
let (local_binary_path, version) = delegate.get_server_binary(platform, cx).await??;
@ -424,9 +466,7 @@ impl ProtoClient for SshSession {
impl SshClientState {
#[cfg(not(unix))]
async fn new(
_user: String,
_host: String,
_port: u16,
connection_options: SshConnectionOptions,
_delegate: Arc<dyn SshClientDelegate>,
_cx: &mut AsyncAppContext,
) -> Result<Self> {
@ -435,9 +475,7 @@ impl SshClientState {
#[cfg(unix)]
async fn new(
user: String,
host: String,
port: u16,
connection_options: SshConnectionOptions,
delegate: Arc<dyn SshClientDelegate>,
cx: &mut AsyncAppContext,
) -> Result<Self> {
@ -447,7 +485,7 @@ impl SshClientState {
delegate.set_status(Some("connecting"), cx);
let url = format!("{user}@{host}");
let url = connection_options.ssh_url();
let temp_dir = tempfile::Builder::new()
.prefix("zed-ssh-session")
.tempdir()?;
@ -500,7 +538,6 @@ impl SshClientState {
.env("SSH_ASKPASS", &askpass_script_path)
.args(["-N", "-o", "ControlMaster=yes", "-o"])
.arg(format!("ControlPath={}", socket_path.display()))
.args(["-p", &port.to_string()])
.arg(&url)
.spawn()?;
@ -522,8 +559,7 @@ impl SshClientState {
}
Ok(Self {
url,
port,
connection_options,
socket_path,
_master_process: master_process,
_temp_dir: temp_dir,
@ -610,10 +646,18 @@ impl SshClientState {
let mut command = process::Command::new("scp");
let output = self
.ssh_options(&mut command)
.arg("-P")
.arg(&self.port.to_string())
.args(
self.connection_options
.port
.map(|port| vec!["-P".to_string(), port.to_string()])
.unwrap_or_default(),
)
.arg(&src_path)
.arg(&format!("{}:{}", self.url, dest_path.display()))
.arg(&format!(
"{}:{}",
self.connection_options.scp_url(),
dest_path.display()
))
.output()
.await?;
@ -632,9 +676,7 @@ impl SshClientState {
fn ssh_command<S: AsRef<OsStr>>(&self, program: S) -> process::Command {
let mut command = process::Command::new("ssh");
self.ssh_options(&mut command)
.arg("-p")
.arg(&self.port.to_string())
.arg(&self.url)
.arg(self.connection_options.ssh_url())
.arg(program);
command
}

View File

@ -33,6 +33,7 @@ rpc.workspace = true
settings.workspace = true
serde.workspace = true
serde_json.workspace = true
shellexpand.workspace = true
smol.workspace = true
util.workspace = true
worktree.workspace = true

View File

@ -12,6 +12,7 @@ use rpc::{
TypedEnvelope,
};
use settings::{Settings as _, SettingsStore};
use smol::stream::StreamExt;
use std::{
path::{Path, PathBuf},
sync::{atomic::AtomicUsize, Arc},
@ -45,6 +46,7 @@ impl HeadlessProject {
cx.subscribe(&buffer_store, Self::on_buffer_store_event)
.detach();
session.add_request_handler(this.clone(), Self::handle_list_remote_directory);
session.add_request_handler(this.clone(), Self::handle_add_worktree);
session.add_request_handler(this.clone(), Self::handle_open_buffer_by_path);
@ -87,10 +89,11 @@ impl HeadlessProject {
message: TypedEnvelope<proto::AddWorktree>,
mut cx: AsyncAppContext,
) -> Result<proto::AddWorktreeResponse> {
let path = shellexpand::tilde(&message.payload.path).to_string();
let worktree = this
.update(&mut cx.clone(), |this, _| {
Worktree::local(
Path::new(&message.payload.path),
Path::new(&path),
true,
this.fs.clone(),
this.next_entry_id.clone(),
@ -157,6 +160,24 @@ impl HeadlessProject {
})
}
pub async fn handle_list_remote_directory(
this: Model<Self>,
envelope: TypedEnvelope<proto::ListRemoteDirectory>,
cx: AsyncAppContext,
) -> Result<proto::ListRemoteDirectoryResponse> {
let expanded = shellexpand::tilde(&envelope.payload.path).to_string();
let fs = cx.read_model(&this, |this, _| this.fs.clone())?;
let mut entries = Vec::new();
let mut response = fs.read_dir(Path::new(&expanded)).await?;
while let Some(path) = response.next().await {
if let Some(file_name) = path?.file_name() {
entries.push(file_name.to_string_lossy().to_string());
}
}
Ok(proto::ListRemoteDirectoryResponse { entries })
}
pub fn on_buffer_store_event(
&mut self,
_: Model<BufferStore>,

View File

@ -104,6 +104,14 @@ pub struct PathLikeWithPosition<P> {
}
impl<P> PathLikeWithPosition<P> {
/// Returns a PathLikeWithPosition from a path.
pub fn from_path(path: P) -> Self {
Self {
path_like: path,
row: None,
column: None,
}
}
/// Parses a string that possibly has `:row:column` suffix.
/// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
/// If any of the row/column component parsing fails, the whole string is then parsed as a path like.

View File

@ -1057,9 +1057,6 @@ pub enum UseSystemClipboard {
#[derive(Deserialize)]
struct VimSettings {
// all vim uses vim clipboard
// vim always uses system cliupbaord
// some magic where yy is system and dd is not.
pub use_system_clipboard: UseSystemClipboard,
pub use_multiline_find: bool,
pub use_smartcase_find: bool,

View File

@ -625,13 +625,13 @@ where
}
}
pub trait DetachAndPromptErr {
pub trait DetachAndPromptErr<R> {
fn prompt_err(
self,
msg: &str,
cx: &mut WindowContext,
f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
) -> Task<()>;
) -> Task<Option<R>>;
fn detach_and_prompt_err(
self,
@ -641,7 +641,7 @@ pub trait DetachAndPromptErr {
);
}
impl<R> DetachAndPromptErr for Task<anyhow::Result<R>>
impl<R> DetachAndPromptErr<R> for Task<anyhow::Result<R>>
where
R: 'static,
{
@ -650,10 +650,11 @@ where
msg: &str,
cx: &mut WindowContext,
f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option<String> + 'static,
) -> Task<()> {
) -> Task<Option<R>> {
let msg = msg.to_owned();
cx.spawn(|mut cx| async move {
if let Err(err) = self.await {
let result = self.await;
if let Err(err) = result.as_ref() {
log::error!("{err:?}");
if let Ok(prompt) = cx.update(|cx| {
let detail = f(&err, cx).unwrap_or_else(|| format!("{err}. Please try again."));
@ -661,7 +662,9 @@ where
}) {
prompt.await.ok();
}
return None;
}
return Some(result.unwrap());
})
}

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 {}

View File

@ -90,3 +90,24 @@ If you'd like to install language-server extensions, you can add them to the lis
## Feedback
Please join the #remoting-feedback channel in the [Zed Discord](https://discord.gg/qSDQ8VWc7k).
# Direct SSH Connections
The current alpha release of Zed always connects via our servers. This was to get experience building the feature on top of our existing collaboration support. We plan to move to direct SSH connections for any machine that can be SSH'd into.
We are working on a direct SSH connection feature, which you can try out if you'd like.
> **Note:** Direct SSH support does not support most features yet! You cannot use project search, language servers, or basically do anything except edit files...
To try this out you can either from the command line run:
```
zed ssh://user@host:port/path/to/project
```
Or you can (in your settings file) add:
```
"ssh_connections": []
```
And then from the command palette choose `projects: Open Remote` and configure an SSH connection from there.