From 83d9f07547f596d945db5d2a65ad6e35318c51b6 Mon Sep 17 00:00:00 2001 From: localcc Date: Wed, 17 Sep 2025 11:44:16 +0200 Subject: [PATCH] Add WSL opening UI (#38260) This PR adds an option to open WSL machines from the UI. - [x] Open wsl from open remote - [ ] Open local folder in wsl action - [ ] Open wsl shortcut (shortcuts to open remote) Release Notes: - N/A --- Cargo.lock | 54 +- crates/recent_projects/Cargo.toml | 3 + .../recent_projects/src/remote_connections.rs | 83 +- crates/recent_projects/src/remote_servers.rs | 1503 ++++++++++++----- 4 files changed, 1216 insertions(+), 427 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43a2fc4041fbf76b57e62d335e94c695ef07fc12..ee14efd2d800aeabb29a75959f9a929d06b78f81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2977,7 +2977,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -13790,6 +13790,7 @@ dependencies = [ "theme", "ui", "util", + "windows-registry 0.6.0", "workspace", "workspace-hack", "zed_actions", @@ -19613,7 +19614,7 @@ dependencies = [ "windows-collections", "windows-core 0.61.0", "windows-future", - "windows-link", + "windows-link 0.1.1", "windows-numerics", ] @@ -19683,7 +19684,7 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ "windows-implement 0.60.0", "windows-interface 0.59.1", - "windows-link", + "windows-link 0.1.1", "windows-result 0.3.2", "windows-strings 0.4.0", ] @@ -19695,7 +19696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ "windows-core 0.61.0", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -19770,6 +19771,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-numerics" version = "0.2.0" @@ -19777,7 +19784,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ "windows-core 0.61.0", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -19797,11 +19804,22 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1da3e436dc7653dfdf3da67332e22bff09bb0e28b0239e1624499c7830842e" dependencies = [ - "windows-link", + "windows-link 0.1.1", "windows-result 0.3.2", "windows-strings 0.4.0", ] +[[package]] +name = "windows-registry" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f91f87ce112ffb7275000ea98eb1940912c21c1567c9312fde20261f3eadd29" +dependencies = [ + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -19826,7 +19844,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" dependencies = [ - "windows-link", + "windows-link 0.1.1", +] + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -19845,7 +19872,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" dependencies = [ - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -19854,7 +19881,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" dependencies = [ - "windows-link", + "windows-link 0.1.1", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", ] [[package]] diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index d48beeaab6bfbe54e3cac6d7f836248cc0ff2f3e..91879c5d4175ad66428a255655f3c8bd4a5059e3 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -44,6 +44,9 @@ workspace.workspace = true zed_actions.workspace = true workspace-hack.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +windows-registry = "0.6.0" + [dev-dependencies] dap.workspace = true editor = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 3e6810239c80c72d74624bcc243157290fcd93fa..72e2844d501f8f8860d62964d22430af80bab4b6 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -17,7 +17,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use release_channel::ReleaseChannel; use remote::{ ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform, - SshConnectionOptions, SshPortForwardOption, + SshConnectionOptions, SshPortForwardOption, WslConnectionOptions, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -33,6 +33,7 @@ use workspace::{AppState, ModalView, Workspace}; #[derive(Deserialize)] pub struct SshSettings { pub ssh_connections: Option>, + pub wsl_connections: Option>, /// Whether to read ~/.ssh/config for ssh connection sources. #[serde(default = "default_true")] pub read_ssh_config: bool, @@ -43,6 +44,10 @@ impl SshSettings { self.ssh_connections.clone().into_iter().flatten() } + pub fn wsl_connections(&self) -> impl Iterator + use<> { + self.wsl_connections.clone().into_iter().flatten() + } + pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) { for conn in self.ssh_connections() { if conn.host == options.host @@ -116,6 +121,51 @@ impl From for SshConnectionOptions { } } +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct WslConnection { + pub distro_name: SharedString, + #[serde(default)] + pub user: Option, + #[serde(default)] + pub projects: BTreeSet, +} + +impl From for WslConnectionOptions { + fn from(val: WslConnection) -> Self { + WslConnectionOptions { + distro_name: val.distro_name.into(), + user: val.user, + } + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +pub enum Connection { + Ssh(SshConnection), + Wsl(WslConnection), +} + +impl From for RemoteConnectionOptions { + fn from(val: Connection) -> Self { + match val { + Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()), + Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()), + } + } +} + +impl From for Connection { + fn from(val: SshConnection) -> Self { + Connection::Ssh(val) + } +} + +impl From for Connection { + fn from(val: WslConnection) -> Self { + Connection::Wsl(val) + } +} + #[derive(Clone, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema)] pub struct SshProject { pub paths: Vec, @@ -125,6 +175,7 @@ pub struct SshProject { #[settings_key(None)] pub struct RemoteSettingsContent { pub ssh_connections: Option>, + pub wsl_connections: Option>, pub read_ssh_config: Option, } @@ -561,6 +612,36 @@ pub fn connect_over_ssh( ) } +pub fn connect( + unique_identifier: ConnectionIdentifier, + connection_options: RemoteConnectionOptions, + ui: Entity, + window: &mut Window, + cx: &mut App, +) -> Task>>> { + let window = window.window_handle(); + let known_password = match &connection_options { + RemoteConnectionOptions::Ssh(ssh_connection_options) => { + ssh_connection_options.password.clone() + } + _ => None, + }; + let (tx, rx) = oneshot::channel(); + ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx)); + + remote::RemoteClient::new( + unique_identifier, + connection_options, + rx, + Arc::new(RemoteClientDelegate { + window, + ui: ui.downgrade(), + known_password, + }), + cx, + ) +} + pub async fn open_remote_project( connection_options: RemoteConnectionOptions, paths: Vec, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 39032642b887350730c16a12d696253c256cfd72..d7e7505851a2b0cd2f86c807d6850937096dac7c 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1,7 +1,8 @@ use crate::{ remote_connections::{ - RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettingsContent, SshConnection, - SshConnectionHeader, SshProject, SshSettings, connect_over_ssh, open_remote_project, + Connection, RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettingsContent, + SshConnection, SshConnectionHeader, SshProject, SshSettings, connect, connect_over_ssh, + open_remote_project, }, ssh_config::parse_ssh_config_hosts, }; @@ -13,11 +14,12 @@ use gpui::{ FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window, canvas, }; +use log::info; use paths::{global_ssh_config_file, user_ssh_config_file}; use picker::Picker; use project::{Fs, Project}; use remote::{ - RemoteClient, RemoteConnectionOptions, SshConnectionOptions, + RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, remote_client::ConnectionIdentifier, }; use settings::{Settings, SettingsStore, update_settings_file, watch_config_file}; @@ -79,27 +81,253 @@ impl CreateRemoteServer { } } +#[cfg(target_os = "windows")] +struct AddWslDistro { + picker: Entity>, + connection_prompt: Option>, + _creating: Option>, +} + +#[cfg(target_os = "windows")] +impl AddWslDistro { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let delegate = WslPickerDelegate::new(); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); + + cx.subscribe_in( + &picker, + window, + |this, _, _: &WslDistroSelected, window, cx| { + this.confirm(&menu::Confirm, window, cx); + }, + ) + .detach(); + + cx.subscribe_in( + &picker, + window, + |this, _, _: &WslPickerDismissed, window, cx| { + this.cancel(&menu::Cancel, window, cx); + }, + ) + .detach(); + + AddWslDistro { + picker, + connection_prompt: None, + _creating: None, + } + } +} + +#[cfg(target_os = "windows")] +#[derive(Clone, Debug)] +pub struct WslDistroSelected(pub String); + +#[cfg(target_os = "windows")] +#[derive(Clone, Debug)] +pub struct WslPickerDismissed; + +#[cfg(target_os = "windows")] +struct WslPickerDelegate { + selected_index: usize, + distro_list: Option>, + matches: Vec, +} + +#[cfg(target_os = "windows")] +impl WslPickerDelegate { + fn new() -> Self { + WslPickerDelegate { + selected_index: 0, + distro_list: None, + matches: Vec::new(), + } + } + + pub fn selected_distro(&self) -> Option { + self.matches + .get(self.selected_index) + .map(|m| m.string.clone()) + } +} + +#[cfg(target_os = "windows")] +impl WslPickerDelegate { + fn fetch_distros() -> anyhow::Result> { + use anyhow::Context; + use windows_registry::CURRENT_USER; + + let lxss_key = CURRENT_USER + .open("Software\\Microsoft\\Windows\\CurrentVersion\\Lxss") + .context("failed to get lxss wsl key")?; + + let distros = lxss_key + .keys() + .context("failed to get wsl distros")? + .filter_map(|key| { + lxss_key + .open(&key) + .context("failed to open subkey for distro") + .log_err() + }) + .filter_map(|distro| distro.get_string("DistributionName").ok()) + .collect::>(); + + Ok(distros) + } +} + +#[cfg(target_os = "windows")] +impl EventEmitter for Picker {} + +#[cfg(target_os = "windows")] +impl EventEmitter for Picker {} + +#[cfg(target_os = "windows")] +impl picker::PickerDelegate for WslPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context>, + ) { + self.selected_index = ix; + cx.notify(); + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + Arc::from("Enter WSL distro name") + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + use fuzzy::StringMatchCandidate; + + let needs_fetch = self.distro_list.is_none(); + if needs_fetch { + let distros = Self::fetch_distros().log_err(); + self.distro_list = distros; + } + + if let Some(distro_list) = &self.distro_list { + use ordered_float::OrderedFloat; + + let candidates = distro_list + .iter() + .enumerate() + .map(|(id, distro)| StringMatchCandidate::new(id, distro)) + .collect::>(); + + let query = query.trim_start(); + let smart_case = query.chars().any(|c| c.is_uppercase()); + self.matches = smol::block_on(fuzzy::match_strings( + candidates.as_slice(), + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + self.matches.sort_unstable_by_key(|m| m.candidate_id); + + self.selected_index = self + .matches + .iter() + .enumerate() + .rev() + .max_by_key(|(_, m)| OrderedFloat(m.score)) + .map(|(index, _)| index) + .unwrap_or(0); + } + + Task::ready(()) + } + + fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context>) { + if let Some(distro) = self.matches.get(self.selected_index) { + cx.emit(WslDistroSelected(distro.string.clone())); + } + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + cx.emit(WslPickerDismissed); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + _: &mut Context>, + ) -> Option { + use ui::HighlightedLabel; + + let matched = self.matches.get(ix)?; + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .child( + h_flex() + .flex_grow() + .gap_3() + .child(Icon::new(IconName::Server)) + .child(v_flex().child(HighlightedLabel::new( + matched.string.clone(), + matched.positions.clone(), + ))), + ), + ) + } +} + +enum ProjectPickerData { + Ssh { + connection_string: SharedString, + nickname: Option, + }, + Wsl { + distro_name: SharedString, + }, +} + struct ProjectPicker { - connection_string: SharedString, - nickname: Option, + data: ProjectPickerData, picker: Entity>, _path_task: Shared>>, } struct EditNicknameState { - index: usize, + index: SshServerIndex, editor: Entity, } impl EditNicknameState { - fn new(index: usize, window: &mut Window, cx: &mut App) -> Self { + fn new(index: SshServerIndex, window: &mut Window, cx: &mut App) -> Self { let this = Self { index, editor: cx.new(|cx| Editor::single_line(window, cx)), }; let starting_text = SshSettings::get_global(cx) .ssh_connections() - .nth(index) + .nth(index.0) .and_then(|state| state.nickname) .filter(|text| !text.is_empty()); this.editor.update(cx, |this, cx| { @@ -122,8 +350,8 @@ impl Focusable for ProjectPicker { impl ProjectPicker { fn new( create_new_window: bool, - ix: usize, - connection: SshConnectionOptions, + index: ServerIndex, + connection: RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, path_style: PathStyle, @@ -142,8 +370,16 @@ impl ProjectPicker { picker.set_query(home_dir.to_string(), window, cx); picker }); - let connection_string = connection.connection_string().into(); - let nickname = connection.nickname.clone().map(|nick| nick.into()); + + let data = match &connection { + RemoteConnectionOptions::Ssh(connection) => ProjectPickerData::Ssh { + connection_string: connection.connection_string().into(), + nickname: connection.nickname.clone().map(|nick| nick.into()), + }, + RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl { + distro_name: connection.distro_name.clone().into(), + }, + }; let _path_task = cx .spawn_in(window, { let workspace = workspace; @@ -178,13 +414,24 @@ impl ProjectPicker { .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.insert(SshProject { paths }); + move |setting, _| match index { + ServerIndex::Ssh(index) => { + if let Some(server) = setting + .ssh_connections + .as_mut() + .and_then(|connections| connections.get_mut(index.0)) + { + server.projects.insert(SshProject { paths }); + }; + } + ServerIndex::Wsl(index) => { + if let Some(server) = setting + .wsl_connections + .as_mut() + .and_then(|connections| connections.get_mut(index.0)) + { + server.projects.insert(SshProject { paths }); + }; } } }); @@ -204,12 +451,7 @@ impl ProjectPicker { .log_err()?; open_remote_project_with_existing_connection( - RemoteConnectionOptions::Ssh(connection), - project, - paths, - app_state, - window, - cx, + connection, project, paths, app_state, window, cx, ) .await .log_err(); @@ -225,8 +467,7 @@ impl ProjectPicker { cx.new(|_| Self { _path_task, picker, - connection_string, - nickname, + data, }) } } @@ -234,14 +475,23 @@ impl ProjectPicker { impl gpui::Render for ProjectPicker { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .child( - SshConnectionHeader { - connection_string: self.connection_string.clone(), + .child(match &self.data { + ProjectPickerData::Ssh { + connection_string, + nickname, + } => SshConnectionHeader { + connection_string: connection_string.clone(), paths: Default::default(), - nickname: self.nickname.clone(), + nickname: nickname.clone(), } .render(window, cx), - ) + ProjectPickerData::Wsl { distro_name } => SshConnectionHeader { + connection_string: distro_name.clone(), + paths: Default::default(), + nickname: None, + } + .render(window, cx), + }) .child( div() .border_t_1() @@ -251,13 +501,48 @@ impl gpui::Render for ProjectPicker { } } +#[repr(transparent)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +struct SshServerIndex(usize); +impl std::fmt::Display for SshServerIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[repr(transparent)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +struct WslServerIndex(usize); +impl std::fmt::Display for WslServerIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +enum ServerIndex { + Ssh(SshServerIndex), + Wsl(WslServerIndex), +} +impl From for ServerIndex { + fn from(index: SshServerIndex) -> Self { + Self::Ssh(index) + } +} +impl From for ServerIndex { + fn from(index: WslServerIndex) -> Self { + Self::Wsl(index) + } +} + #[derive(Clone)] enum RemoteEntry { Project { open_folder: NavigableEntry, projects: Vec<(NavigableEntry, SshProject)>, configure: NavigableEntry, - connection: SshConnection, + connection: Connection, + index: ServerIndex, }, SshConfig { open_folder: NavigableEntry, @@ -270,13 +555,16 @@ impl RemoteEntry { matches!(self, Self::Project { .. }) } - fn connection(&self) -> Cow<'_, SshConnection> { + fn connection(&self) -> Cow<'_, Connection> { match self { Self::Project { connection, .. } => Cow::Borrowed(connection), - Self::SshConfig { host, .. } => Cow::Owned(SshConnection { - host: host.clone(), - ..SshConnection::default() - }), + Self::SshConfig { host, .. } => Cow::Owned( + SshConnection { + host: host.clone(), + ..SshConnection::default() + } + .into(), + ), } } } @@ -285,6 +573,7 @@ impl RemoteEntry { struct DefaultState { scroll_handle: ScrollHandle, add_new_server: NavigableEntry, + add_new_wsl: NavigableEntry, servers: Vec, } @@ -292,13 +581,15 @@ impl DefaultState { fn new(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { let handle = ScrollHandle::new(); let add_new_server = NavigableEntry::new(&handle, cx); + let add_new_wsl = NavigableEntry::new(&handle, cx); let ssh_settings = SshSettings::get_global(cx); let read_ssh_config = ssh_settings.read_ssh_config; - let mut servers: Vec = ssh_settings + let ssh_servers = ssh_settings .ssh_connections() - .map(|connection| { + .enumerate() + .map(|(index, connection)| { let open_folder = NavigableEntry::new(&handle, cx); let configure = NavigableEntry::new(&handle, cx); let projects = connection @@ -310,16 +601,42 @@ impl DefaultState { open_folder, configure, projects, - connection, + index: ServerIndex::Ssh(SshServerIndex(index)), + connection: connection.into(), } - }) - .collect(); + }); + + let wsl_servers = ssh_settings + .wsl_connections() + .enumerate() + .map(|(index, connection)| { + let open_folder = NavigableEntry::new(&handle, cx); + let configure = NavigableEntry::new(&handle, cx); + let projects = connection + .projects + .iter() + .map(|project| (NavigableEntry::new(&handle, cx), project.clone())) + .collect(); + RemoteEntry::Project { + open_folder, + configure, + projects, + index: ServerIndex::Wsl(WslServerIndex(index)), + connection: connection.into(), + } + }); + + let mut servers = ssh_servers.chain(wsl_servers).collect::>(); if read_ssh_config { let mut extra_servers_from_config = ssh_config_servers.clone(); for server in &servers { - if let RemoteEntry::Project { connection, .. } = server { - extra_servers_from_config.remove(&connection.host); + if let RemoteEntry::Project { + connection: Connection::Ssh(ssh_options), + .. + } = server + { + extra_servers_from_config.remove(&SharedString::new(ssh_options.host.clone())); } } servers.extend(extra_servers_from_config.into_iter().map(|host| { @@ -333,23 +650,43 @@ impl DefaultState { Self { scroll_handle: handle, add_new_server, + add_new_wsl, servers, } } } #[derive(Clone)] -struct ViewServerOptionsState { - server_index: usize, - connection: SshConnection, - entries: [NavigableEntry; 4], +enum ViewServerOptionsState { + Ssh { + connection: SshConnectionOptions, + server_index: SshServerIndex, + entries: [NavigableEntry; 4], + }, + Wsl { + connection: WslConnectionOptions, + server_index: WslServerIndex, + entries: [NavigableEntry; 2], + }, } + +impl ViewServerOptionsState { + fn entries(&self) -> &[NavigableEntry] { + match self { + Self::Ssh { entries, .. } => entries, + Self::Wsl { entries, .. } => entries, + } + } +} + enum Mode { Default(DefaultState), ViewServerOptions(ViewServerOptionsState), EditNickname(EditNicknameState), ProjectPicker(Entity), CreateRemoteServer(CreateRemoteServer), + #[cfg(target_os = "windows")] + AddWslDistro(AddWslDistro), } impl Mode { @@ -357,6 +694,7 @@ impl Mode { Self::Default(DefaultState::new(ssh_config_servers, cx)) } } + impl RemoteServerProjects { pub fn new( create_new_window: bool, @@ -405,10 +743,10 @@ impl RemoteServerProjects { } } - pub fn project_picker( + fn project_picker( create_new_window: bool, - ix: usize, - connection_options: remote::SshConnectionOptions, + index: ServerIndex, + connection_options: remote::RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, path_style: PathStyle, @@ -420,7 +758,7 @@ impl RemoteServerProjects { let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx); this.mode = Mode::ProjectPicker(ProjectPicker::new( create_new_window, - ix, + index, connection_options, project, home_dir, @@ -480,6 +818,7 @@ impl RemoteServerProjects { match connection.await { Some(Some(client)) => this .update_in(cx, |this, window, cx| { + info!("ssh server created"); telemetry::event!("SSH Server Created"); this.retained_connections.push(client); this.add_ssh_server(connection_options, cx); @@ -517,25 +856,100 @@ impl RemoteServerProjects { }); } + #[cfg(target_os = "windows")] + fn connect_wsl_distro( + &mut self, + picker: Entity>, + distro: String, + window: &mut Window, + cx: &mut Context, + ) { + let connection_options = WslConnectionOptions { + distro_name: distro, + user: None, + }; + + let prompt = cx.new(|cx| { + RemoteConnectionPrompt::new(connection_options.distro_name.clone(), None, window, cx) + }); + let connection = connect( + ConnectionIdentifier::setup(), + connection_options.clone().into(), + prompt.clone(), + window, + cx, + ) + .prompt_err("Failed to connect", window, cx, |_, _, _| None); + + let wsl_picker = picker.clone(); + let creating = cx.spawn_in(window, async move |this, cx| { + match connection.await { + Some(Some(client)) => this + .update_in(cx, |this, window, cx| { + telemetry::event!("WSL Distro Added"); + this.retained_connections.push(client); + this.add_wsl_distro(connection_options, cx); + this.mode = Mode::default_mode(&BTreeSet::new(), cx); + this.focus_handle(cx).focus(window); + cx.notify() + }) + .log_err(), + _ => this + .update(cx, |this, cx| { + this.mode = Mode::AddWslDistro(AddWslDistro { + picker: wsl_picker, + connection_prompt: None, + _creating: None, + }); + cx.notify() + }) + .log_err(), + }; + () + }); + + self.mode = Mode::AddWslDistro(AddWslDistro { + picker, + connection_prompt: Some(prompt), + _creating: Some(creating), + }); + } + fn view_server_options( &mut self, - (server_index, connection): (usize, SshConnection), + (server_index, connection): (ServerIndex, RemoteConnectionOptions), window: &mut Window, cx: &mut Context, ) { - self.mode = Mode::ViewServerOptions(ViewServerOptionsState { - server_index, - connection, - entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)), + self.mode = Mode::ViewServerOptions(match (server_index, connection) { + (ServerIndex::Ssh(server_index), RemoteConnectionOptions::Ssh(connection)) => { + ViewServerOptionsState::Ssh { + connection, + server_index, + entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)), + } + } + (ServerIndex::Wsl(server_index), RemoteConnectionOptions::Wsl(connection)) => { + ViewServerOptionsState::Wsl { + connection, + server_index, + entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)), + } + } + _ => { + log::error!("server index and connection options mismatch"); + self.mode = Mode::default_mode(&BTreeSet::default(), cx); + return; + } }); self.focus_handle(cx).focus(window); cx.notify(); } - fn create_ssh_project( + fn create_remote_project( &mut self, - ix: usize, - ssh_connection: SshConnection, + index: ServerIndex, + connection_options: RemoteConnectionOptions, window: &mut Window, cx: &mut Context, ) { @@ -544,17 +958,11 @@ impl RemoteServerProjects { }; let create_new_window = self.create_new_window; - let connection_options: SshConnectionOptions = ssh_connection.into(); workspace.update(cx, |_, cx| { cx.defer_in(window, move |workspace, window, cx| { let app_state = workspace.app_state().clone(); workspace.toggle_modal(window, cx, |window, cx| { - RemoteConnectionModal::new( - &RemoteConnectionOptions::Ssh(connection_options.clone()), - Vec::new(), - window, - cx, - ) + RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) }); let prompt = workspace .active_modal::(cx) @@ -563,7 +971,7 @@ impl RemoteServerProjects { .prompt .clone(); - let connect = connect_over_ssh( + let connect = connect( ConnectionIdentifier::setup(), connection_options.clone(), prompt, @@ -624,7 +1032,7 @@ impl RemoteServerProjects { workspace.toggle_modal(window, cx, |window, cx| { RemoteServerProjects::project_picker( create_new_window, - ix, + index, connection_options, project, home_dir, @@ -662,7 +1070,7 @@ impl RemoteServerProjects { let index = state.index; self.update_settings_file(cx, move |setting, _| { if let Some(connections) = setting.ssh_connections.as_mut() - && let Some(connection) = connections.get_mut(index) + && let Some(connection) = connections.get_mut(index.0) { connection.nickname = text; } @@ -670,6 +1078,12 @@ impl RemoteServerProjects { self.mode = Mode::default_mode(&self.ssh_config_servers, cx); self.focus_handle.focus(window); } + #[cfg(target_os = "windows")] + Mode::AddWslDistro(state) => { + let delegate = &state.picker.read(cx).delegate; + let distro = delegate.selected_distro().unwrap(); + self.connect_wsl_distro(state.picker.clone(), distro, window, cx); + } } } @@ -702,11 +1116,19 @@ impl RemoteServerProjects { cx: &mut Context, ) -> impl IntoElement { let connection = ssh_server.connection().into_owned(); - let (main_label, aux_label) = if let Some(nickname) = connection.nickname.clone() { - let aux_label = SharedString::from(format!("({})", connection.host)); - (nickname.into(), Some(aux_label)) - } else { - (connection.host.clone(), None) + + let (main_label, aux_label, is_wsl) = match &connection { + Connection::Ssh(connection) => { + if let Some(nickname) = connection.nickname.clone() { + let aux_label = SharedString::from(format!("({})", connection.host)); + (nickname.into(), Some(aux_label), false) + } else { + (connection.host.clone(), None, false) + } + } + Connection::Wsl(wsl_connection_options) => { + (wsl_connection_options.distro_name.clone(), None, true) + } }; v_flex() .w_full() @@ -720,11 +1142,23 @@ impl RemoteServerProjects { .gap_1() .overflow_hidden() .child( - div().max_w_96().overflow_hidden().text_ellipsis().child( - Label::new(main_label) - .size(LabelSize::Small) - .color(Color::Muted), - ), + h_flex() + .gap_1() + .max_w_96() + .overflow_hidden() + .text_ellipsis() + .when(is_wsl, |this| { + this.child( + Label::new("WSL:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .child( + Label::new(main_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), ) .children( aux_label.map(|label| { @@ -738,98 +1172,114 @@ impl RemoteServerProjects { projects, configure, connection, - } => List::new() - .empty_message("No projects.") - .children(projects.iter().enumerate().map(|(pix, p)| { - v_flex().gap_0p5().child(self.render_ssh_project( - ix, - ssh_server.clone(), - pix, - p, - window, - cx, - )) - })) - .child( - h_flex() - .id(("new-remote-project-container", ix)) - .track_focus(&open_folder.focus_handle) - .anchor_scroll(open_folder.scroll_anchor.clone()) - .on_action(cx.listener({ - let ssh_connection = connection.clone(); - move |this, _: &menu::Confirm, window, cx| { - this.create_ssh_project(ix, ssh_connection.clone(), window, cx); - } - })) - .child( - ListItem::new(("new-remote-project", ix)) - .toggle_state( - open_folder.focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Open Folder")) - .on_click(cx.listener({ - let ssh_connection = connection.clone(); - move |this, _, window, cx| { - this.create_ssh_project( - ix, - ssh_connection.clone(), - window, - cx, - ); - } - })), - ), - ) - .child( - h_flex() - .id(("server-options-container", ix)) - .track_focus(&configure.focus_handle) - .anchor_scroll(configure.scroll_anchor.clone()) - .on_action(cx.listener({ - let ssh_connection = connection.clone(); - move |this, _: &menu::Confirm, window, cx| { - this.view_server_options( - (ix, ssh_connection.clone()), - window, - cx, - ); - } - })) - .child( - ListItem::new(("server-options", ix)) - .toggle_state( - configure.focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Settings).color(Color::Muted)) - .child(Label::new("View Server Options")) - .on_click(cx.listener({ - let ssh_connection = connection.clone(); - move |this, _, window, cx| { - this.view_server_options( - (ix, ssh_connection.clone()), - window, - cx, - ); - } - })), - ), - ), + index, + } => { + let index = *index; + List::new() + .empty_message("No projects.") + .children(projects.iter().enumerate().map(|(pix, p)| { + v_flex().gap_0p5().child(self.render_ssh_project( + index, + ssh_server.clone(), + pix, + p, + window, + cx, + )) + })) + .child( + h_flex() + .id(("new-remote-project-container", ix)) + .track_focus(&open_folder.focus_handle) + .anchor_scroll(open_folder.scroll_anchor.clone()) + .on_action(cx.listener({ + let connection = connection.clone(); + move |this, _: &menu::Confirm, window, cx| { + this.create_remote_project( + index, + connection.clone().into(), + window, + cx, + ); + } + })) + .child( + ListItem::new(("new-remote-project", ix)) + .toggle_state( + open_folder.focus_handle.contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Open Folder")) + .on_click(cx.listener({ + let connection = connection.clone(); + move |this, _, window, cx| { + this.create_remote_project( + index, + connection.clone().into(), + window, + cx, + ); + } + })), + ), + ) + .child( + h_flex() + .id(("server-options-container", ix)) + .track_focus(&configure.focus_handle) + .anchor_scroll(configure.scroll_anchor.clone()) + .on_action(cx.listener({ + let connection = connection.clone(); + move |this, _: &menu::Confirm, window, cx| { + this.view_server_options( + (index, connection.clone().into()), + window, + cx, + ); + } + })) + .child( + ListItem::new(("server-options", ix)) + .toggle_state( + configure.focus_handle.contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Settings).color(Color::Muted), + ) + .child(Label::new("View Server Options")) + .on_click(cx.listener({ + let ssh_connection = connection.clone(); + move |this, _, window, cx| { + this.view_server_options( + (index, ssh_connection.clone().into()), + window, + cx, + ); + } + })), + ), + ) + } RemoteEntry::SshConfig { open_folder, host } => List::new().child( h_flex() .id(("new-remote-project-container", ix)) .track_focus(&open_folder.focus_handle) .anchor_scroll(open_folder.scroll_anchor.clone()) .on_action(cx.listener({ - let ssh_connection = connection.clone(); + let connection = connection.clone(); let host = host.clone(); move |this, _: &menu::Confirm, window, cx| { let new_ix = this.create_host_from_ssh_config(&host, cx); - this.create_ssh_project(new_ix, ssh_connection.clone(), window, cx); + this.create_remote_project( + new_ix.into(), + connection.clone().into(), + window, + cx, + ); } })) .child( @@ -840,13 +1290,12 @@ impl RemoteServerProjects { .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) .child(Label::new("Open Folder")) .on_click(cx.listener({ - let ssh_connection = connection; let host = host.clone(); move |this, _, window, cx| { let new_ix = this.create_host_from_ssh_config(&host, cx); - this.create_ssh_project( - new_ix, - ssh_connection.clone(), + this.create_remote_project( + new_ix.into(), + connection.clone().into(), window, cx, ); @@ -859,7 +1308,7 @@ impl RemoteServerProjects { fn render_ssh_project( &mut self, - server_ix: usize, + server_ix: ServerIndex, server: RemoteEntry, ix: usize, (navigation, project): &(NavigableEntry, SshProject), @@ -868,7 +1317,13 @@ impl RemoteServerProjects { ) -> impl IntoElement { let create_new_window = self.create_new_window; let is_from_zed = server.is_from_zed(); - let element_id_base = SharedString::from(format!("remote-project-{server_ix}")); + let element_id_base = SharedString::from(format!( + "remote-project-{}", + match server_ix { + ServerIndex::Ssh(index) => format!("ssh-{index}"), + ServerIndex::Wsl(index) => format!("wsl-{index}"), + } + )); let container_element_id_base = SharedString::from(format!("remote-project-container-{element_id_base}")); @@ -896,7 +1351,7 @@ impl RemoteServerProjects { cx.spawn_in(window, async move |_, cx| { let result = open_remote_project( - RemoteConnectionOptions::Ssh(server.into()), + server.into(), project.paths.into_iter().map(PathBuf::from).collect(), app_state, OpenOptions { @@ -953,25 +1408,31 @@ impl RemoteServerProjects { let secondary_confirm = e.modifiers().platform; callback(this, secondary_confirm, window, cx) })) - .when(is_from_zed, |server_list_item| { - server_list_item.end_hover_slot::(Some( - div() - .mr_2() - .child({ - let project = project.clone(); - // Right-margin to offset it from the Scrollbar - IconButton::new("remove-remote-project", IconName::Trash) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .size(ButtonSize::Large) - .tooltip(Tooltip::text("Delete Remote Project")) - .on_click(cx.listener(move |this, _, _, cx| { - this.delete_ssh_project(server_ix, &project, cx) - })) - }) - .into_any_element(), - )) - }), + .when( + is_from_zed && matches!(server_ix, ServerIndex::Ssh(_)), + |server_list_item| { + let ServerIndex::Ssh(server_ix) = server_ix else { + unreachable!() + }; + server_list_item.end_hover_slot::(Some( + div() + .mr_2() + .child({ + let project = project.clone(); + // Right-margin to offset it from the Scrollbar + IconButton::new("remove-remote-project", IconName::Trash) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(Tooltip::text("Delete Remote Project")) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_ssh_project(server_ix, &project, cx) + })) + }) + .into_any_element(), + )) + }, + ), ) } @@ -990,27 +1451,58 @@ impl RemoteServerProjects { update_settings_file::(fs, cx, move |setting, cx| f(setting, cx)); } - fn delete_ssh_server(&mut self, server: usize, cx: &mut Context) { + fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context) { self.update_settings_file(cx, move |setting, _| { if let Some(connections) = setting.ssh_connections.as_mut() { - connections.remove(server); + connections.remove(server.0); } }); } - fn delete_ssh_project(&mut self, server: usize, project: &SshProject, cx: &mut Context) { + fn delete_ssh_project( + &mut self, + server: SshServerIndex, + project: &SshProject, + cx: &mut Context, + ) { let project = project.clone(); self.update_settings_file(cx, move |setting, _| { if let Some(server) = setting .ssh_connections .as_mut() - .and_then(|connections| connections.get_mut(server)) + .and_then(|connections| connections.get_mut(server.0)) { server.projects.remove(&project); } }); } + #[cfg(target_os = "windows")] + fn add_wsl_distro( + &mut self, + connection_options: remote::WslConnectionOptions, + cx: &mut Context, + ) { + self.update_settings_file(cx, move |setting, _| { + setting + .wsl_connections + .get_or_insert(Default::default()) + .push(crate::remote_connections::WslConnection { + distro_name: SharedString::from(connection_options.distro_name), + user: connection_options.user, + projects: BTreeSet::new(), + }) + }); + } + + fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context) { + self.update_settings_file(cx, move |setting, _| { + if let Some(connections) = setting.wsl_connections.as_mut() { + connections.remove(server.0); + } + }); + } + fn add_ssh_server( &mut self, connection_options: remote::SshConnectionOptions, @@ -1108,222 +1600,94 @@ impl RemoteServerProjects { ) } + #[cfg(target_os = "windows")] + fn render_add_wsl_distro( + &self, + state: &AddWslDistro, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let connection_prompt = state.connection_prompt.clone(); + + state.picker.update(cx, |picker, cx| { + picker.focus_handle(cx).focus(window); + }); + + v_flex() + .id("add-wsl-distro") + .overflow_hidden() + .size_full() + .flex_1() + .map(|this| { + if let Some(connection_prompt) = connection_prompt { + this.child(connection_prompt) + } else { + this.child(state.picker.clone()) + } + }) + } + fn render_view_options( &mut self, - ViewServerOptionsState { - server_index, - connection, - entries, - }: ViewServerOptionsState, + options: ViewServerOptionsState, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let connection_string = connection.host.clone(); + let last_entry = options.entries().last().unwrap(); let mut view = Navigable::new( div() .track_focus(&self.focus_handle(cx)) .size_full() - .child( - SshConnectionHeader { - connection_string: connection_string.clone(), + .child(match &options { + ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader { + connection_string: connection.host.clone().into(), paths: Default::default(), nickname: connection.nickname.clone().map(|s| s.into()), } - .render(window, cx), - ) + .render(window, cx) + .into_any_element(), + ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader { + connection_string: connection.distro_name.clone().into(), + paths: Default::default(), + nickname: None, + } + .render(window, cx) + .into_any_element(), + }) .child( v_flex() .pb_1() .child(ListSeparator) - .child({ - let label = if connection.nickname.is_some() { - "Edit Nickname" - } else { - "Add Nickname to Server" - }; - div() - .id("ssh-options-add-nickname") - .track_focus(&entries[0].focus_handle) - .on_action(cx.listener( - move |this, _: &menu::Confirm, window, cx| { - this.mode = Mode::EditNickname(EditNicknameState::new( - server_index, - window, - cx, - )); - cx.notify(); - }, - )) - .child( - ListItem::new("add-nickname") - .toggle_state( - entries[0].focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) - .child(Label::new(label)) - .on_click(cx.listener(move |this, _, window, cx| { - this.mode = Mode::EditNickname(EditNicknameState::new( - server_index, - window, - cx, - )); - cx.notify(); - })), - ) - }) - .child({ - let workspace = self.workspace.clone(); - fn callback( - workspace: WeakEntity, - connection_string: SharedString, - cx: &mut App, - ) { - cx.write_to_clipboard(ClipboardItem::new_string( - connection_string.to_string(), - )); - workspace - .update(cx, |this, cx| { - struct SshServerAddressCopiedToClipboard; - let notification = format!( - "Copied server address ({}) to clipboard", - connection_string - ); - - this.show_toast( - Toast::new( - NotificationId::composite::< - SshServerAddressCopiedToClipboard, - >( - connection_string.clone() - ), - notification, - ) - .autohide(), - cx, - ); - }) - .ok(); - } - div() - .id("ssh-options-copy-server-address") - .track_focus(&entries[1].focus_handle) - .on_action({ - let connection_string = connection_string.clone(); - let workspace = self.workspace.clone(); - move |_: &menu::Confirm, _, cx| { - callback(workspace.clone(), connection_string.clone(), cx); - } - }) - .child( - ListItem::new("copy-server-address") - .toggle_state( - entries[1].focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) - .child(Label::new("Copy Server Address")) - .end_hover_slot( - Label::new(connection_string.clone()) - .color(Color::Muted), - ) - .on_click({ - let connection_string = connection_string.clone(); - move |_, _, cx| { - callback( - workspace.clone(), - connection_string.clone(), - cx, - ); - } - }), - ) - }) - .child({ - fn remove_ssh_server( - remote_servers: Entity, - index: usize, - connection_string: SharedString, - window: &mut Window, - cx: &mut App, - ) { - let prompt_message = - format!("Remove server `{}`?", connection_string); - - let confirmation = window.prompt( - PromptLevel::Warning, - &prompt_message, - None, - &["Yes, remove it", "No, keep it"], - cx, - ); - - cx.spawn(async move |cx| { - if confirmation.await.ok() == Some(0) { - remote_servers - .update(cx, |this, cx| { - this.delete_ssh_server(index, cx); - }) - .ok(); - remote_servers - .update(cx, |this, cx| { - this.mode = Mode::default_mode( - &this.ssh_config_servers, - cx, - ); - cx.notify(); - }) - .ok(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - div() - .id("ssh-options-copy-server-address") - .track_focus(&entries[2].focus_handle) - .on_action(cx.listener({ - let connection_string = connection_string.clone(); - move |_, _: &menu::Confirm, window, cx| { - remove_ssh_server( - cx.entity(), - server_index, - connection_string.clone(), - window, - cx, - ); - cx.focus_self(window); - } - })) - .child( - ListItem::new("remove-server") - .toggle_state( - entries[2].focus_handle.contains_focused(window, cx), - ) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Trash).color(Color::Error)) - .child(Label::new("Remove Server").color(Color::Error)) - .on_click(cx.listener(move |_, _, window, cx| { - remove_ssh_server( - cx.entity(), - server_index, - connection_string.clone(), - window, - cx, - ); - cx.focus_self(window); - })), - ) + .map(|this| match &options { + ViewServerOptionsState::Ssh { + connection, + entries, + server_index, + } => this.child(self.render_edit_ssh( + connection, + *server_index, + entries, + window, + cx, + )), + ViewServerOptionsState::Wsl { + connection, + entries, + server_index, + } => this.child(self.render_edit_wsl( + connection, + *server_index, + entries, + window, + cx, + )), }) .child(ListSeparator) .child({ div() .id("ssh-options-copy-server-address") - .track_focus(&entries[3].focus_handle) + .track_focus(&last_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.mode = Mode::default_mode(&this.ssh_config_servers, cx); cx.focus_self(window); @@ -1332,7 +1696,7 @@ impl RemoteServerProjects { .child( ListItem::new("go-back") .toggle_state( - entries[3].focus_handle.contains_focused(window, cx), + last_entry.focus_handle.contains_focused(window, cx), ) .inset(true) .spacing(ui::ListItemSpacing::Sparse) @@ -1351,13 +1715,253 @@ impl RemoteServerProjects { ) .into_any_element(), ); - for entry in entries { - view = view.entry(entry); + + for entry in options.entries() { + view = view.entry(entry.clone()); } view.render(window, cx).into_any_element() } + fn render_edit_wsl( + &self, + connection: &WslConnectionOptions, + index: WslServerIndex, + entries: &[NavigableEntry], + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let distro_name = SharedString::new(connection.distro_name.clone()); + + v_flex().child({ + fn remove_wsl_distro( + remote_servers: Entity, + index: WslServerIndex, + distro_name: SharedString, + window: &mut Window, + cx: &mut App, + ) { + let prompt_message = format!("Remove WSL distro `{}`?", distro_name); + + let confirmation = window.prompt( + PromptLevel::Warning, + &prompt_message, + None, + &["Yes, remove it", "No, keep it"], + cx, + ); + + cx.spawn(async move |cx| { + if confirmation.await.ok() == Some(0) { + remote_servers + .update(cx, |this, cx| { + this.delete_wsl_distro(index, cx); + }) + .ok(); + remote_servers + .update(cx, |this, cx| { + this.mode = Mode::default_mode(&this.ssh_config_servers, cx); + cx.notify(); + }) + .ok(); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + div() + .id("wsl-options-remove-distro") + .track_focus(&entries[0].focus_handle) + .on_action(cx.listener({ + let distro_name = distro_name.clone(); + move |_, _: &menu::Confirm, window, cx| { + remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); + cx.focus_self(window); + } + })) + .child( + ListItem::new("remove-distro") + .toggle_state(entries[0].focus_handle.contains_focused(window, cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Trash).color(Color::Error)) + .child(Label::new("Remove Distro").color(Color::Error)) + .on_click(cx.listener(move |_, _, window, cx| { + remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx); + cx.focus_self(window); + })), + ) + }) + } + + fn render_edit_ssh( + &self, + connection: &SshConnectionOptions, + index: SshServerIndex, + entries: &[NavigableEntry], + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let connection_string = SharedString::new(connection.host.clone()); + + v_flex() + .child({ + let label = if connection.nickname.is_some() { + "Edit Nickname" + } else { + "Add Nickname to Server" + }; + div() + .id("ssh-options-add-nickname") + .track_focus(&entries[0].focus_handle) + .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| { + this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx)); + cx.notify(); + })) + .child( + ListItem::new("add-nickname") + .toggle_state(entries[0].focus_handle.contains_focused(window, cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) + .child(Label::new(label)) + .on_click(cx.listener(move |this, _, window, cx| { + this.mode = + Mode::EditNickname(EditNicknameState::new(index, window, cx)); + cx.notify(); + })), + ) + }) + .child({ + let workspace = self.workspace.clone(); + fn callback( + workspace: WeakEntity, + connection_string: SharedString, + cx: &mut App, + ) { + cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string())); + workspace + .update(cx, |this, cx| { + struct SshServerAddressCopiedToClipboard; + let notification = format!( + "Copied server address ({}) to clipboard", + connection_string + ); + + this.show_toast( + Toast::new( + NotificationId::composite::( + connection_string.clone(), + ), + notification, + ) + .autohide(), + cx, + ); + }) + .ok(); + } + div() + .id("ssh-options-copy-server-address") + .track_focus(&entries[1].focus_handle) + .on_action({ + let connection_string = connection_string.clone(); + let workspace = self.workspace.clone(); + move |_: &menu::Confirm, _, cx| { + callback(workspace.clone(), connection_string.clone(), cx); + } + }) + .child( + ListItem::new("copy-server-address") + .toggle_state(entries[1].focus_handle.contains_focused(window, cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) + .child(Label::new("Copy Server Address")) + .end_hover_slot( + Label::new(connection_string.clone()).color(Color::Muted), + ) + .on_click({ + let connection_string = connection_string.clone(); + move |_, _, cx| { + callback(workspace.clone(), connection_string.clone(), cx); + } + }), + ) + }) + .child({ + fn remove_ssh_server( + remote_servers: Entity, + index: SshServerIndex, + connection_string: SharedString, + window: &mut Window, + cx: &mut App, + ) { + let prompt_message = format!("Remove server `{}`?", connection_string); + + let confirmation = window.prompt( + PromptLevel::Warning, + &prompt_message, + None, + &["Yes, remove it", "No, keep it"], + cx, + ); + + cx.spawn(async move |cx| { + if confirmation.await.ok() == Some(0) { + remote_servers + .update(cx, |this, cx| { + this.delete_ssh_server(index, cx); + }) + .ok(); + remote_servers + .update(cx, |this, cx| { + this.mode = Mode::default_mode(&this.ssh_config_servers, cx); + cx.notify(); + }) + .ok(); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + div() + .id("ssh-options-copy-server-address") + .track_focus(&entries[2].focus_handle) + .on_action(cx.listener({ + let connection_string = connection_string.clone(); + move |_, _: &menu::Confirm, window, cx| { + remove_ssh_server( + cx.entity(), + index, + connection_string.clone(), + window, + cx, + ); + cx.focus_self(window); + } + })) + .child( + ListItem::new("remove-server") + .toggle_state(entries[2].focus_handle.contains_focused(window, cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Trash).color(Color::Error)) + .child(Label::new("Remove Server").color(Color::Error)) + .on_click(cx.listener(move |_, _, window, cx| { + remove_ssh_server( + cx.entity(), + index, + connection_string.clone(), + window, + cx, + ); + cx.focus_self(window); + })), + ) + }) + } + fn render_edit_nickname( &self, state: &EditNicknameState, @@ -1366,7 +1970,7 @@ impl RemoteServerProjects { ) -> impl IntoElement { let Some(connection) = SshSettings::get_global(cx) .ssh_connections() - .nth(state.index) + .nth(state.index.0) else { return v_flex() .id("ssh-edit-nickname") @@ -1405,20 +2009,43 @@ impl RemoteServerProjects { let ssh_settings = SshSettings::get_global(cx); let mut should_rebuild = false; - if ssh_settings - .ssh_connections - .as_ref() - .is_some_and(|connections| { - state - .servers - .iter() - .filter_map(|server| match server { - RemoteEntry::Project { connection, .. } => Some(connection), - RemoteEntry::SshConfig { .. } => None, - }) - .ne(connections.iter()) - }) - { + let ssh_connections_changed = + ssh_settings + .ssh_connections + .as_ref() + .is_some_and(|connections| { + state + .servers + .iter() + .filter_map(|server| match server { + RemoteEntry::Project { + connection: Connection::Ssh(connection), + .. + } => Some(connection), + _ => None, + }) + .ne(connections.iter()) + }); + + let wsl_connections_changed = + ssh_settings + .wsl_connections + .as_ref() + .is_some_and(|connections| { + state + .servers + .iter() + .filter_map(|server| match server { + RemoteEntry::Project { + connection: Connection::Wsl(connection), + .. + } => Some(connection), + _ => None, + }) + .ne(connections.iter()) + }); + + if ssh_connections_changed || wsl_connections_changed { should_rebuild = true; }; @@ -1433,7 +2060,11 @@ impl RemoteServerProjects { .collect(); let mut expected_ssh_hosts = self.ssh_config_servers.clone(); for server in &state.servers { - if let RemoteEntry::Project { connection, .. } = server { + if let RemoteEntry::Project { + connection: Connection::Ssh(connection), + .. + } = server + { expected_ssh_hosts.remove(&connection.host); } } @@ -1477,14 +2108,47 @@ impl RemoteServerProjects { cx.notify(); })); + #[cfg(target_os = "windows")] + let wsl_connect_button = div() + .id("wsl-connect-new-server") + .track_focus(&state.add_new_wsl.focus_handle) + .anchor_scroll(state.add_new_wsl.scroll_anchor.clone()) + .child( + ListItem::new("wsl-add-new-server") + .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Add WSL Distro")) + .on_click(cx.listener(|this, _, window, cx| { + let state = AddWslDistro::new(window, cx); + this.mode = Mode::AddWslDistro(state); + + cx.notify(); + })), + ) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + let state = AddWslDistro::new(window, cx); + this.mode = Mode::AddWslDistro(state); + + cx.notify(); + })); + + let modal_section = v_flex() + .track_focus(&self.focus_handle(cx)) + .id("ssh-server-list") + .overflow_y_scroll() + .track_scroll(&state.scroll_handle) + .size_full() + .child(connect_button); + + #[cfg(target_os = "windows")] + let modal_section = modal_section.child(wsl_connect_button); + #[cfg(not(target_os = "windows"))] + let modal_section = modal_section; + let mut modal_section = Navigable::new( - v_flex() - .track_focus(&self.focus_handle(cx)) - .id("ssh-server-list") - .overflow_y_scroll() - .track_scroll(&state.scroll_handle) - .size_full() - .child(connect_button) + modal_section .child( List::new() .empty_message( @@ -1504,7 +2168,8 @@ impl RemoteServerProjects { ) .into_any_element(), ) - .entry(state.add_new_server.clone()); + .entry(state.add_new_server.clone()) + .entry(state.add_new_wsl.clone()); for server in &state.servers { match server { @@ -1587,7 +2252,7 @@ impl RemoteServerProjects { &mut self, ssh_config_host: &SharedString, cx: &mut Context<'_, Self>, - ) -> usize { + ) -> SshServerIndex { let new_ix = Arc::new(AtomicUsize::new(0)); let update_new_ix = new_ix.clone(); @@ -1609,7 +2274,7 @@ impl RemoteServerProjects { cx, ); self.mode = Mode::default_mode(&self.ssh_config_servers, cx); - new_ix.load(atomic::Ordering::Acquire) + SshServerIndex(new_ix.load(atomic::Ordering::Acquire)) } } @@ -1719,6 +2384,10 @@ impl Render for RemoteServerProjects { Mode::EditNickname(state) => self .render_edit_nickname(state, window, cx) .into_any_element(), + #[cfg(target_os = "windows")] + Mode::AddWslDistro(state) => self + .render_add_wsl_distro(state, window, cx) + .into_any_element(), }) } }