diff --git a/Cargo.lock b/Cargo.lock index 9cbd3bd29b..325e456b57 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 4a1ed777fd..517f43baf7 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 3b0d3b89eb..883359d17e 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 5302bfa73b..87a3917786 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 cd63548898..8e5c75dcb4 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 e85dc339d9..11010d555b 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 2289f9cf63..da4ee210e1 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 9a3aab5236..eb507b49ac 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, - manual_setup: bool, + 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, + 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; - } - 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, ) -> 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 513a75bd46..dd49d8fb69 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 0000000000..55e823d1a0 --- /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 0000000000..8b13789179 --- /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 0ef552bf64..23f798c191 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 b2a8237e97..8fa90eb437 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 ce38197b5f..f0a1662ef3 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 feac87a2b1..d08bdc4b74 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 64983fdf0e..a038161ca4 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 cdc79d0564..fc3f5b7c49 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 453cee987f..2cffb83584 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 2c3d1d7d9f..5da4232626 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 7d3b5050a8..c6be8fb684 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 0e1040fc17..307dd3294c 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 6eae96153f..0000000000 --- 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 67d9e5497e..354514b0af 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.