From 3e31955b7f7cbd9f3ae9a3476dd4bb5a2bdac431 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 26 Jul 2024 16:45:44 -0600 Subject: [PATCH] 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 --- Cargo.lock | 7 + assets/settings/default.json | 18 +- crates/editor/src/display_map.rs | 10 +- crates/editor/src/display_map/block_map.rs | 16 +- crates/editor/src/editor.rs | 14 +- crates/gpui/src/geometry.rs | 9 + crates/recent_projects/Cargo.toml | 7 + crates/recent_projects/src/dev_servers.rs | 617 +++++++++++++++--- crates/recent_projects/src/recent_projects.rs | 6 + crates/recent_projects/src/ssh_connections.rs | 412 ++++++++++++ crates/recent_projects/src/ssh_remotes.rs | 1 + crates/remote/src/remote.rs | 2 +- crates/remote/src/ssh_session.rs | 86 ++- crates/remote_server/Cargo.toml | 1 + crates/remote_server/src/headless_project.rs | 23 +- crates/util/src/paths.rs | 8 + crates/vim/src/vim.rs | 3 - crates/workspace/src/notifications.rs | 13 +- crates/zed/src/main.rs | 5 +- crates/zed/src/zed.rs | 1 - crates/zed/src/zed/open_listener.rs | 219 +------ crates/zed/src/zed/ssh_connection_modal.rs | 97 --- docs/src/remote-development.md | 21 + 23 files changed, 1161 insertions(+), 435 deletions(-) create mode 100644 crates/recent_projects/src/ssh_connections.rs create mode 100644 crates/recent_projects/src/ssh_remotes.rs delete mode 100644 crates/zed/src/zed/ssh_connection_modal.rs diff --git a/Cargo.lock b/Cargo.lock index 9cbd3bd29bc8f69a3c81a4442f2f0f2c0eeb1c01..325e456b571210f42283ecd0da323c2e0524f318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/settings/default.json b/assets/settings/default.json index 4a1ed777fd51fbced7c455127d330b2221c5e276..517f43baf7f01a6c899be77e0c4dbcb7147eb8dd 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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 } diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3b0d3b89ebe483b6636759a52a4ae0f3d1abf9ff..883359d17e73ed39607c6afc136a39eff72555ac 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -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 { - (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::>() .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), diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 5302bfa73b60616665061114f4227be5361c9487..87a3917786706557f1c7591df55906a5a1327526 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -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, 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) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cd6354889821a8034897e9cf632b7d1bf1a47764..8e5c75dcb4aa10416de0d676c5c7a52e678ce17d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -480,7 +480,6 @@ pub struct Editor { mode: EditorMode, show_breadcrumbs: bool, show_gutter: bool, - redact_all: bool, show_line_numbers: Option, show_git_diff_gutter: Option, show_code_actions: Option, @@ -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.redact_all = redact_all; - cx.notify(); + pub fn set_masked(&mut self, masked: bool, cx: &mut ViewContext) { + 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) { @@ -11108,10 +11108,6 @@ impl Editor { display_snapshot: &DisplaySnapshot, cx: &WindowContext, ) -> Vec> { - if self.redact_all { - return vec![DisplayPoint::zero()..display_snapshot.max_point()]; - } - display_snapshot .buffer_snapshot .redacted_ranges(search_range, |file| { diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index e85dc339d9fb08777faf6c186b67da9752321267..11010d555bb94ee8c1b1cda7f7d5fc989a93a8fd 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -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, size: Size) -> Self { + let origin = Point { + x: center.x - size.width.half(), + y: center.y - size.height.half(), + }; + Self::new(origin, size) + } } impl + Sub> Bounds { diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 2289f9cf63092b62fd66001f268c7358fba56faa..da4ee210e1acfe9359a516260b5ffcbce5c23c45 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -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 diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 9a3aab5236c6a5c5b207a46f3ad7463fdfc27d84..eb507b49acde31e9de08703dc283501b1986585b 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -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>, + creating: Option>>, dev_server_id: Option, access_token: Option, - manual_setup: bool, + ssh_prompt: Option>, + 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.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) { + 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, + 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, + 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, cx)); + let prompt = workspace + .active_modal::(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::(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| { + Workspace::new(None, project.clone(), app_state.clone(), cx) + }) + }) + .log_err(); + }) + .detach() + }) + }) + } + + fn create_or_update_dev_server( &mut self, - manual_setup: bool, + kind: NewServerKind, existing_id: Option, access_token: Option, cx: &mut ViewContext, @@ -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) { - 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, + ) -> 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, + ) -> 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::(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, + 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::(fs, cx, move |setting, _| f(setting)); + } + + 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_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; + .when(ssh_prompt.is_none(), |el| { + el.child( + 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() } - cx.notify() - } - }), - )), - ) - .when(dev_server_id.is_none(), |el| { + }), + )), + ) + }) + .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| { el.child( - if manual_setup { + 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, ) -> 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) -> impl IntoElement { let dev_servers = self.dev_server_store.read(cx).dev_servers(); + let ssh_connections = SshSettings::get_global(cx) + .ssh_connections() + .collect::>(); 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> { +) -> 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 { @@ -1128,7 +1589,7 @@ pub fn reconnect_to_dev_server( workspace: View, dev_server: DevServer, cx: &mut WindowContext, -) -> Task> { +) -> Task> { 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::(cx)) .ok() diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 513a75bd46af76c056889083956d162a06067f2d..dd49d8fb6937e925666ed8367f849d77c7d25bac 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -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(); diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs new file mode 100644 index 0000000000000000000000000000000000000000..55e823d1a006f7edd633842603ca69eee70baeb1 --- /dev/null +++ b/crates/recent_projects/src/ssh_connections.rs @@ -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>, +} + +impl SshSettings { + pub fn use_direct_ssh(&self) -> bool { + self.ssh_connections.is_some() + } + + pub fn ssh_connections(&self) -> impl Iterator { + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + pub projects: Vec, +} +impl From 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, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct RemoteSettingsContent { + pub ssh_connections: Option>, +} + +impl Settings for SshSettings { + const KEY: Option<&'static str> = None; + + type FileContent = RemoteSettingsContent; + + fn load(sources: SettingsSources, _: &mut AppContext) -> Result { + sources.json_merge() + } +} + +pub struct SshPrompt { + connection_string: SharedString, + status_message: Option, + prompt: Option<(SharedString, oneshot::Sender>)>, + editor: View, +} + +pub struct SshConnectionModal { + pub(crate) prompt: View, +} +impl SshPrompt { + pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext) -> 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>, + cx: &mut ViewContext, + ) { + 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, cx: &mut ViewContext) { + self.status_message = status.map(|s| s.into()); + cx.notify(); + } + + pub fn confirm(&mut self, cx: &mut ViewContext) { + 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) -> 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 { + prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)), + } + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + self.prompt.update(cx, |prompt, cx| prompt.confirm(cx)) + } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + cx.remove_window(); + } +} + +impl Render for SshConnectionModal { + fn render(&mut self, cx: &mut ui::ViewContext) -> 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 for SshConnectionModal {} + +impl ModalView for SshConnectionModal {} + +#[derive(Clone)] +pub struct SshClientDelegate { + window: AnyWindowHandle, + ui: View, + known_password: Option, +} + +impl remote::SshClientDelegate for SshClientDelegate { + fn ask_password( + &self, + prompt: String, + cx: &mut AsyncAppContext, + ) -> oneshot::Receiver> { + 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> { + 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 { + 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, + cx: &mut WindowContext, +) -> Task>> { + 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>, + app_state: Arc, + _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::(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(()) +} diff --git a/crates/recent_projects/src/ssh_remotes.rs b/crates/recent_projects/src/ssh_remotes.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/crates/recent_projects/src/ssh_remotes.rs @@ -0,0 +1 @@ + diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 0ef552bf64a76bc2d6c36c65a01c16c334e45fff..23f798c1914dbf42fc1cac605cc0acb1246d4f9f 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -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}; diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index b2a8237e974c927342e4a7d6bea334733ccab382..8fa90eb437f98df11cbc3b2e4820fa69b95627c3 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -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, + pub port: Option, + pub password: Option, +} + +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, @@ -95,13 +139,11 @@ type ResponseChannels = Mutex, cx: &mut AsyncAppContext, ) -> Result> { - 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, _cx: &mut AsyncAppContext, ) -> Result { @@ -435,9 +475,7 @@ impl SshClientState { #[cfg(unix)] async fn new( - user: String, - host: String, - port: u16, + connection_options: SshConnectionOptions, delegate: Arc, cx: &mut AsyncAppContext, ) -> Result { @@ -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>(&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 } diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index ce38197b5f7d48fe163a9c8f55f6401d90d7d027..f0a1662ef362eaa1887b6e9dd558b2c7965df04b 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -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 diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index feac87a2b1b4ce308de0841cfcfd6f30ae265f08..d08bdc4b743cc7a7c5cf2c1d3c14e776bf40ea58 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -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, mut cx: AsyncAppContext, ) -> Result { + 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, + envelope: TypedEnvelope, + cx: AsyncAppContext, + ) -> Result { + 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, diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 64983fdf0e10fb67af8d34a878fa9248b15c0f01..a038161ca4ea96ffdee6f27b10b2f0ac9993e504 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -104,6 +104,14 @@ pub struct PathLikeWithPosition

{ } impl

PathLikeWithPosition

{ + /// 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. diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index cdc79d0564b01078acc19e4536c57f50d8d87532..fc3f5b7c497956f3fb1d7f65be37a0ba5af7277d 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -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, diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 453cee987f8aace31e8c126a762f530989594381..2cffb83584f28cebe02b283d12309178bd288454 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -625,13 +625,13 @@ where } } -pub trait DetachAndPromptErr { +pub trait DetachAndPromptErr { fn prompt_err( self, msg: &str, cx: &mut WindowContext, f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option + 'static, - ) -> Task<()>; + ) -> Task>; fn detach_and_prompt_err( self, @@ -641,7 +641,7 @@ pub trait DetachAndPromptErr { ); } -impl DetachAndPromptErr for Task> +impl DetachAndPromptErr for Task> where R: 'static, { @@ -650,10 +650,11 @@ where msg: &str, cx: &mut WindowContext, f: impl FnOnce(&anyhow::Error, &mut WindowContext) -> Option + 'static, - ) -> Task<()> { + ) -> Task> { 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()); }) } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 2c3d1d7d9ff4b2d0be9c05ffa1b1dab363b717eb..5da423262665429beb593d7846bd419c79ecab31 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -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, 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, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7d3b5050a8d964e257d68c0bf5e0bb80707ff0ee..c6be8fb684e19f344ee12b321ae10d7aefe93f07 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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; diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 0e1040fc17acef68cc1d9f1168c265066614d053..307dd3294cefd68e292b178c9e74cb26674a1f3b 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -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>, pub open_channel_notes: Vec<(u64, Option)>, pub join_channel: Option, - pub ssh_connection: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub struct SshConnectionInfo { - pub username: String, - pub password: Option, - pub host: String, - pub port: u16, + pub ssh_connection: Option, } 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, - modal: View, - known_password: Option, -} - -impl remote::SshClientDelegate for SshClientDelegate { - fn ask_password( - &self, - prompt: String, - cx: &mut AsyncAppContext, - ) -> oneshot::Receiver> { - 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> { - 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 { - 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>, - app_state: Arc, - _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::(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>, app_state: Arc, diff --git a/crates/zed/src/zed/ssh_connection_modal.rs b/crates/zed/src/zed/ssh_connection_modal.rs deleted file mode 100644 index 6eae96153f49ac9ae87652650f2356d4ae642568..0000000000000000000000000000000000000000 --- a/crates/zed/src/zed/ssh_connection_modal.rs +++ /dev/null @@ -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, - prompt: Option<(SharedString, oneshot::Sender>)>, - editor: View, -} - -impl SshConnectionModal { - pub fn new(host: String, cx: &mut ViewContext) -> 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>, - cx: &mut ViewContext, - ) { - 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, cx: &mut ViewContext) { - self.status_message = status.map(|s| s.into()); - cx.notify(); - } - - fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - 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) { - cx.remove_window(); - } -} - -impl Render for SshConnectionModal { - fn render(&mut self, cx: &mut ui::ViewContext) -> 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 for SshConnectionModal {} - -impl ModalView for SshConnectionModal {} diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index 67d9e5497ef3735dd879842a9ccaa7a8293e924c..354514b0afbad4344471f01b7ea24b6cc661a5d3 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -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.