feat(remote): open WSL projects through a WSL transport (#989)

* perf(vfs): list directories off the main thread and reuse the repository lookup

Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow
because every listing ran on the main thread and walked up to the repository
root and ran a repository discovery again.

- fs_read_dir runs on the blocking pool.
- GitignoreStack::cached keeps the repository root and the repo-level rules
  for a few seconds. An edit of .git/info/exclude is still seen at once (its
  mtime and size are checked) and every .gitignore / .ignore is read fresh.

Refs #916

* feat(remote): open WSL projects through a WSL transport

A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens
as a remote project whose openidea-server runs inside the distro, so the
terminal, SDK discovery, run, language servers and git are native Linux.

- RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote
  launch, connection, session and recents; old recents are read as SSH and a
  WSL target never inherits the legacy trust migration.
- remote_wsl: probe the distro, stream the server into ~/.openidea/server and
  start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are
  validated and every command goes through --exec with positional arguments.
- wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route
  WSL folders to the distro and focus the window that already has them.
- Remote Development dialog: SSH/WSL choice with the detected distros; the
  status bar shows `WSL: <distro>` / `SSH: <host>`.
- ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of
  wsl.exe, which made the first message of the server disappear.

Not run on Windows yet: only a stand-in for wsl.exe is tested.

Refs #925, #327, #721

* fix(remote): satisfy clippy in WSL output decoding

* style(run): format run types

* fix: regenerate bindings and drop untranslated WSL catalog entry

* test(i18n): list WSL as an untranslated product name

* fix(run): pick build wrappers and shell quoting by the execution host

A remote window runs commands on its Linux or macOS server, so a Windows
desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it.

* fix(welcome): open legacy WSL UNC recents and sessions through the distro

Recents and the restored session called the local openProject for a
\\wsl.localhost folder saved before WSL projects, which fails with OS
error 123. They now resolve it like the other entry points.

* fix(windows): keep WSL files out of remote project launches

path_launch tested the WSL share before is_dir, so a first-instance CLI
launch with a WSL file opened a remote project instead of LightEdit.

* fix(remote): run-command OS comes from the remote host, not an assumption

The server's OS was dropped after connect, so a restored session had none and
isWindows() assumed every remote host is Unix. Keep it on the connection and
the session, and resolve it through hostOs().

---------

Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-05 15:37:19 +08:00
1 джерело 9bf28dd451
коміт b0f65769c0
47 files changed
+1512 -220

No files matched your search

+3
Переглянути файл
@@ -74,6 +74,9 @@ ide-docker = { path = "../crates/ide-docker" }
ide-android = { path = "../crates/ide-android" }
ide-github = { path = "../crates/ide-github" }
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.6"
# Plugin secrets in the login keychain.
+3
Переглянути файл
@@ -49,6 +49,7 @@ pub mod window;
pub mod window_drag;
pub mod window_group;
pub mod workspace;
pub mod wsl;
use tauri_specta::collect_commands;
@@ -535,6 +536,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
db::db_set_ssh_secret,
db::db_trust_ssh_host,
ssh::ssh_test,
wsl::wsl_distros,
wsl::wsl_resolve,
ssh::ssh_set_secret,
ssh::ssh_needs_secret,
ssh::ssh_trust_host,
+4 -4
Переглянути файл
@@ -52,17 +52,17 @@ pub async fn window_open_file_in_new_window(
windows::open_file_in_new_window(&app, project, file)
}
/// [`window_open_project`] for folder `path` on the host of SSH configuration
/// `ssh_config` (Remote Development); a new window connects itself.
/// [`window_open_project`] for folder `path` of `target` (an SSH host or a WSL distribution;
/// Remote Development); a new window connects itself.
#[tauri::command]
#[specta::specta]
pub async fn window_open_remote(
app: AppHandle,
ssh_config: String,
target: crate::remote_dev::RemoteTarget,
path: String,
new_window: bool,
) -> IdeResult<OpenOutcome> {
windows::open_remote_window(&app, &ssh_config, &path, new_window)
windows::open_remote_window(&app, &target, &path, new_window)
}
/// Opens an empty (welcome) window; returns its label.
+22
Переглянути файл
@@ -0,0 +1,22 @@
use ide_core::{IdeError, IdeResult};
use std::time::Duration;
/// Installed WSL distros, for the "Connect to WSL" picker. `wsl.exe` can stall while the WSL
/// service starts, so the wait is bounded.
#[tauri::command]
#[specta::specta]
pub async fn wsl_distros() -> IdeResult<Vec<String>> {
let list = tokio::task::spawn_blocking(ide_core::wsl::list_distros);
match tokio::time::timeout(Duration::from_secs(8), list).await {
Ok(joined) => joined.map_err(|e| IdeError::internal(e.to_string())),
Err(_) => Err(IdeError::internal("wsl.exe did not answer in time")),
}
}
/// The WSL project a `\\wsl.localhost\<distro>\…` folder stands for, so the frontend opens it
/// through the distro and not as a network folder. `None` for any other path.
#[tauri::command]
#[specta::specta]
pub fn wsl_resolve(path: std::path::PathBuf) -> Option<crate::windows::RemoteLaunch> {
crate::windows::wsl_launch(&path)
}
+1
Переглянути файл
@@ -16,6 +16,7 @@ mod macos;
mod menu;
mod plugin_web;
mod remote_dev;
mod remote_wsl;
mod state;
mod theme_frame;
+122 -59
Переглянути файл
@@ -28,24 +28,46 @@ pub async fn remote_bootstrap_list(
state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await
}
/// Where a remote project lives and how the server there is reached.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum RemoteTarget {
/// The host of SSH configuration `config`.
Ssh { config: String },
/// A WSL distribution on this machine.
Wsl { distro: String },
}
/// Where the server is installed on the remote host (per version).
fn install_dir(version: &str) -> String {
format!("~/.openidea/server/{version}")
format!("~/{SERVER_DIR}/{version}")
}
/// Under the remote home: where the server builds are kept, one folder per version.
pub(crate) const SERVER_DIR: &str = ".openidea/server";
/// File name of the server binary on every host.
pub(crate) const SERVER_BIN: &str = "openidea-server";
/// Printed before a probe's answer: shell start-up files and `wsl.exe` may print first.
pub(crate) const PROBE_MARK: &str = "__OPENIDEA_PROBE__";
/// One remote window's connection.
pub struct Connection {
peer: Peer,
pub ssh_config: String,
pub target: RemoteTarget,
pub root: String,
pub host: String,
/// The server's `std::env::consts::OS`: where the project's commands run.
pub os: String,
pub commands: Vec<String>,
/// Streaming arguments by connection-wide id: the frontend channel and the id
/// it expects (each webview numbers its own, and a settings window shares this
/// connection with its parent).
channels: Channels,
next_channel: std::sync::atomic::AtomicU64,
session: Arc<ide_ssh::Session>,
/// Keeps the SSH connection alive; `None` for WSL.
session: Option<Arc<ide_ssh::Session>>,
/// Remote ports forwarded to this machine (Ports tool window), by remote port.
forwards: Mutex<Vec<(u16, ide_ssh::Tunnel)>>,
}
@@ -82,13 +104,13 @@ impl Connections {
self.0.lock().get(window).cloned()
}
/// The window connected to folder `root` on the host of SSH configuration `ssh_config`.
pub fn window_of(&self, ssh_config: &str, root: &str) -> Option<String> {
/// The window connected to folder `root` of `target`.
pub fn window_of(&self, target: &RemoteTarget, root: &str) -> Option<String> {
let root = root.trim_end_matches('/');
self.0
.lock()
.iter()
.find(|(_, c)| c.ssh_config == ssh_config && c.root.trim_end_matches('/') == root)
.find(|(_, c)| c.target == *target && c.root.trim_end_matches('/') == root)
.map(|(label, _)| label.clone())
}
@@ -123,7 +145,7 @@ pub struct RemoteProgress {
pub fraction: Option<f64>,
}
type Progress = dyn Fn(RemoteStage, Option<f64>) + Send + Sync;
pub(crate) type Progress = dyn Fn(RemoteStage, Option<f64>) + Send + Sync;
/// A value for one streaming argument of a remote command (`data`: JSON text).
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -136,9 +158,9 @@ pub struct ChannelFrame {
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteProject {
pub ssh_config: String,
pub target: RemoteTarget,
pub root: String,
/// `user@host:port` of the SSH configuration.
/// `user@host:port` of the SSH configuration; the distribution name for WSL.
pub host: String,
pub server_version: String,
pub os: String,
@@ -244,7 +266,7 @@ async fn download_server(
}
/// `uname -sm` → (`linux` | `macos`, `x86_64` | `aarch64`), as Rust names them.
fn platform(uname: &str) -> (String, String) {
pub(crate) fn platform(uname: &str) -> (String, String) {
let mut parts = uname.split_whitespace();
let os = match parts.next().unwrap_or_default().to_ascii_lowercase().as_str() {
"darwin" => "macos".to_owned(),
@@ -258,29 +280,30 @@ fn platform(uname: &str) -> (String, String) {
(os, arch)
}
/// Makes sure this version of the server is installed on the host; returns its path.
async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc<Progress>) -> IdeResult<String> {
progress(RemoteStage::Checking, None);
let version = env!("CARGO_PKG_VERSION");
let dir = install_dir(version);
let q = ide_ssh::shell::tilde_quote;
// Between markers: shell start-up files may print to stdout too.
const MARK: &str = "__OPENIDEA_PROBE__";
let probe = session
.exec(&format!("echo {MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None)
.await?
.stdout_str();
let probe = probe.split_once(MARK).map_or(probe.as_str(), |(_, rest)| rest);
let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty());
let (os, arch) = platform(lines.next().unwrap_or_default());
let installed: Vec<&str> = lines.collect();
let local = match local_server_binary(state, &os, &arch) {
/// What [`local_server`] settled on.
pub(crate) enum LocalServer {
/// A server binary on this machine and its [`build_id`].
Binary(std::path::PathBuf, String),
/// No binary available (offline): this build of the version already on the host.
Installed(String),
}
/// The server binary to put on a host of `os`/`arch` whose `installed` builds are known.
pub(crate) async fn local_server(
state: &AppState,
version: &str,
os: &str,
arch: &str,
installed: &[String],
progress: Arc<Progress>,
) -> IdeResult<LocalServer> {
let local = match local_server_binary(state, os, arch) {
Some(local) => local,
None => match download_server(state, version, &os, &arch, progress.clone()).await {
None => match download_server(state, version, os, arch, progress).await {
Ok(local) => local,
// Offline: any build of this version already on the host will do.
Err(e) => {
return installed.first().map(|b| b.to_string()).ok_or_else(|| {
return installed.first().cloned().map(LocalServer::Installed).ok_or_else(|| {
IdeError::unavailable(format!(
"no openidea-server {version} for {os}-{arch} ({}). Build it with \
`cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \
@@ -304,6 +327,27 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
Ok(LocalServer::Binary(local, build))
}
/// Makes sure this version of the server is installed on the host; returns its path.
async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc<Progress>) -> IdeResult<String> {
progress(RemoteStage::Checking, None);
let version = env!("CARGO_PKG_VERSION");
let dir = install_dir(version);
let q = ide_ssh::shell::tilde_quote;
let probe = session
.exec(&format!("echo {PROBE_MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None)
.await?
.stdout_str();
let probe = probe.split_once(PROBE_MARK).map_or(probe.as_str(), |(_, rest)| rest);
let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty());
let (os, arch) = platform(lines.next().unwrap_or_default());
let installed: Vec<String> = lines.map(str::to_owned).collect();
let (local, build) = match local_server(state, version, &os, &arch, &installed, progress.clone()).await? {
LocalServer::Installed(bin) => return Ok(bin),
LocalServer::Binary(local, build) => (local, build),
};
if let Some(bin) = installed.iter().find(|b| b.ends_with(&format!("/{build}/openidea-server"))) {
return Ok(bin.to_string());
}
@@ -359,14 +403,14 @@ fn build_id(path: &std::path::Path) -> IdeResult<String> {
Ok(id)
}
/// File › Remote Development: opens `path` on the host of SSH configuration
/// `ssh_config` in this window (installs / starts the server as needed).
/// File › Remote Development: opens `path` on `target` (an SSH host or a WSL distribution)
/// in this window (installs / starts the server as needed).
#[tauri::command]
#[specta::specta]
pub async fn remote_connect(
window: Window,
state: State<'_, AppState>,
ssh_config: String,
target: RemoteTarget,
path: String,
on_progress: Channel<RemoteProgress>,
) -> IdeResult<RemoteProject> {
@@ -377,23 +421,39 @@ pub async fn remote_connect(
let label = AppState::owner_window(window.label()).to_owned();
state.remote_dev.disconnect(&label);
state.host.close_project(&label);
let config = state.ssh.config(&ssh_config)?;
let session = state.ssh.session(&ssh_config).await?;
let bin = ensure_server(&state, &session, progress.clone()).await?;
progress(RemoteStage::Starting, None);
let stream = session
.exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin)))
.await
.map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?;
let (reader, writer) = tokio::io::split(stream);
let channels: Channels = Arc::default();
let handler =
Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() });
let peer = Peer::connect(reader, writer, handler);
let tail = crate::remote_wsl::Tail::default();
let (peer, session, host, title_tag) = match &target {
RemoteTarget::Ssh { config: id } => {
let config = state.ssh.config(id)?;
let session = state.ssh.session(id).await?;
let bin = ensure_server(&state, &session, progress.clone()).await?;
progress(RemoteStage::Starting, None);
let stream = session
.exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin)))
.await
.map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?;
let (reader, writer) = tokio::io::split(stream);
(Peer::connect(reader, writer, handler), Some(session), config.label(), config.label())
}
RemoteTarget::Wsl { distro } => {
let bin = crate::remote_wsl::ensure_server(&state, distro, progress.clone()).await?;
progress(RemoteStage::Starting, None);
let peer = crate::remote_wsl::spawn(distro, &bin, handler, &tail)?;
(peer, None, distro.clone(), format!("WSL: {distro}"))
}
};
// A server that dies on start-up says why on stderr (`wsl.exe` prints its own errors too).
let with_output = |e: IdeError| match tail.text() {
t if t.is_empty() => e,
t => IdeError::unavailable(format!("{}: {t}", e.message)),
};
let info: ServerInfo = peer
.request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None)
.await
.map_err(ide_host::rpc::error_from_reply)?;
.map_err(|e| with_output(ide_host::rpc::error_from_reply(e)))?;
if info.protocol != ide_host::rpc::PROTOCOL_VERSION {
return Err(IdeError::unavailable(format!(
"openidea-server {} speaks protocol {}, this app {}",
@@ -417,12 +477,13 @@ pub async fn remote_connect(
peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
// As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's.
let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root);
let _ = window.set_title(&format!("{} [{}]", project.name, config.label()));
let _ = window.set_title(&format!("{} [{title_tag}]", project.name));
let connection = Arc::new(Connection {
peer,
ssh_config: ssh_config.clone(),
target: target.clone(),
root: root.clone(),
host: config.label(),
host: host.clone(),
os: info.os.clone(),
commands: commands.clone(),
channels,
next_channel: std::sync::atomic::AtomicU64::new(1),
@@ -435,15 +496,7 @@ pub async fn remote_connect(
sync_plugin_languages(&plugins, &connection, &label).await;
sync_plugin_packages(&plugins, &connection, &label).await;
});
Ok(RemoteProject {
ssh_config,
root,
host: config.label(),
server_version: info.version,
os: info.os,
commands,
project,
})
Ok(RemoteProject { target, root, host, server_version: info.version, os: info.os, commands, project })
}
/// Sends the desktop's plugin languages, with the programs granted to them, to
@@ -523,9 +576,11 @@ pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>,
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteSession {
pub ssh_config: String,
pub target: RemoteTarget,
pub root: String,
pub host: String,
/// `linux`, `macos` or `windows`: the OS the project's commands run on.
pub os: String,
pub commands: Vec<String>,
}
@@ -535,9 +590,10 @@ pub struct RemoteSession {
pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option<RemoteSession> {
let c = state.remote_dev.get(AppState::owner_window(window.label()))?;
Some(RemoteSession {
ssh_config: c.ssh_config.clone(),
target: c.target.clone(),
root: c.root.clone(),
host: c.host.clone(),
os: c.os.clone(),
commands: c.commands.clone(),
})
}
@@ -584,7 +640,11 @@ pub async fn remote_forward_port(
if let Some(p) = forwarded(&c).into_iter().find(|p| p.remote_port == remote_port) {
return Ok(p);
}
let tunnel = ide_ssh::Tunnel::listen_on(c.session.clone(), Some(remote_port), "localhost", remote_port).await?;
let session = c
.session
.clone()
.ok_or_else(|| IdeError::unavailable("Forwarding ports is not supported in a WSL project yet"))?;
let tunnel = ide_ssh::Tunnel::listen_on(session, Some(remote_port), "localhost", remote_port).await?;
let port = ForwardedPort { remote_port, local_port: tunnel.port() };
let mut forwards = c.forwards.lock();
if let Some(existing) = forwards.iter().find(|(remote, _)| *remote == remote_port) {
@@ -681,10 +741,13 @@ pub async fn remote_import_local(
) -> IdeResult<Vec<String>> {
let label = AppState::owner_window(window.label());
let c = state.remote_dev.get(label).ok_or_else(|| IdeError::unavailable("Remote project is disconnected"))?;
let Some(session) = c.session.clone() else {
return Err(IdeError::unavailable("Importing local files is not supported in a WSL project yet"));
};
c.authorize_command(label, "fs_copy").await?;
let cancel = state.host.begin_request(request);
let result = async {
let fs = ide_ssh::RemoteFs::new(c.session.sftp().await?);
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
let result = ide_remote::import::import_local(&fs, &from, &to, &cancel).await;
fs.close().await;
result
+282
Переглянути файл
@@ -0,0 +1,282 @@
//! WSL transport of remote development: `openidea-server --stdio` started with
//! `wsl.exe --exec` inside a distro, instead of over SSH.
//!
//! Every command goes through `--exec`, which runs the program directly without a shell on
//! the Windows side; only fixed scripts reach `sh -c` inside the distro and everything variable
//! (paths, versions) is passed as positional arguments, never spliced into the script.
use crate::remote_dev::{PROBE_MARK, Progress, RemoteStage, SERVER_BIN, SERVER_DIR};
use ide_core::{IdeError, IdeResult};
use ide_jsonrpc::{Handler, Peer};
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
/// Bound for the short probes; `wsl.exe` can wait on a distro that is still starting.
const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
const INSTALL_TIMEOUT: Duration = Duration::from_secs(300);
const UPLOAD_CHUNK: usize = 256 * 1024;
/// Output lines of the server kept for the error shown when it dies early.
const KEPT_LINES: usize = 20;
/// What the probe learned about a distro.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Probe {
pub home: String,
/// `uname -sm`.
pub uname: String,
/// Installed builds of this version (paths of `openidea-server`).
pub installed: Vec<String>,
}
fn checked(distro: &str) -> IdeResult<&str> {
if ide_core::wsl::is_valid_distro(distro) {
Ok(distro)
} else {
Err(IdeError::invalid(format!("'{distro}' is not a valid WSL distribution name")))
}
}
/// `wsl.exe -d <distro> --exec sh -c <script> sh <args>…`.
fn sh(distro: &str, script: &str, args: &[&str]) -> IdeResult<tokio::process::Command> {
let mut cmd = tokio::process::Command::new("wsl.exe");
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(["-d", checked(distro)?, "--exec", "sh", "-c", script, "sh"])
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
Ok(cmd)
}
/// The reason a `wsl.exe` call failed, as text (its own messages are UTF-16).
fn failure(distro: &str, out: &std::process::Output) -> IdeError {
let mut text = ide_core::wsl::decode_output(&out.stderr);
if text.trim().is_empty() {
text = ide_core::wsl::decode_output(&out.stdout);
}
let text: String = text.trim().chars().filter(|c| !c.is_control() || *c == '\n').take(400).collect();
IdeError::unavailable(format!("cannot run a command in WSL distribution '{distro}': {text}"))
}
async fn output(distro: &str, mut cmd: tokio::process::Command, limit: Duration) -> IdeResult<std::process::Output> {
let out = tokio::time::timeout(limit, cmd.output())
.await
.map_err(|_| IdeError::unavailable(format!("WSL distribution '{distro}' did not answer in time")))?
.map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))?;
if out.status.success() { Ok(out) } else { Err(failure(distro, &out)) }
}
/// Splits the probe output (after its marker) into [`Probe`].
fn parse_probe(text: &str) -> IdeResult<Probe> {
// Between markers: `wsl.exe` may print notices to stdout before the script runs.
let text = text.split_once(PROBE_MARK).map_or("", |(_, rest)| rest);
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let home = lines.next().unwrap_or_default().to_owned();
// The path is spliced into nothing, but it must be one clean absolute path.
if !home.starts_with('/') || home.chars().any(char::is_control) {
return Err(IdeError::unavailable("cannot find the home directory in the WSL distribution"));
}
let uname = lines.next().unwrap_or_default().to_owned();
Ok(Probe { home, uname, installed: lines.map(str::to_owned).collect() })
}
/// Home directory, platform and installed server builds of `distro` (starts it if stopped).
pub(crate) async fn probe(distro: &str, version: &str) -> IdeResult<Probe> {
let script = format!(
r#"echo {PROBE_MARK}; echo "$HOME"; uname -sm; ls -d "$HOME/{SERVER_DIR}/$1"/*/{SERVER_BIN} 2>/dev/null; true"#
);
let out = output(distro, sh(distro, &script, &[version])?, PROBE_TIMEOUT).await?;
parse_probe(&String::from_utf8_lossy(&out.stdout))
}
/// Where the builds of `version` live in a distro whose home is `home`.
pub(crate) fn version_dir(home: &str, version: &str) -> String {
format!("{}/{SERVER_DIR}/{version}", home.trim_end_matches('/'))
}
/// Streams `data` into `<version_dir>/<build>/openidea-server` of `distro` and removes the other
/// builds of the version. Returns the installed path.
pub(crate) async fn install(
distro: &str,
version_dir: &str,
build: &str,
data: &[u8],
progress: &Arc<Progress>,
) -> IdeResult<String> {
let build_dir = format!("{version_dir}/{build}");
// Per install: another window may be installing the same build right now.
let script = format!(
r#"set -e; mkdir -p "$1"; p="$1/{SERVER_BIN}.part.$$"; trap 'rm -f "$p"' EXIT; cat > "$p"; chmod 755 "$p"; mv -f "$p" "$1/{SERVER_BIN}"; find "$2" -mindepth 1 -maxdepth 1 ! -name "$3" -exec rm -rf {{}} +"#
);
let mut cmd = sh(distro, &script, &[&build_dir, version_dir, build])?;
cmd.stdin(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))?;
let mut stdin = child.stdin.take().ok_or_else(|| IdeError::internal("wsl.exe stdin unavailable"))?;
// `stdin` drops at the end of the upload: that is the EOF `cat` waits for.
let upload = async move {
for (i, chunk) in data.chunks(UPLOAD_CHUNK).enumerate() {
stdin.write_all(chunk).await?;
progress(RemoteStage::Uploading, Some(((i + 1) * UPLOAD_CHUNK).min(data.len()) as f64 / data.len() as f64));
}
stdin.flush().await
};
let result = tokio::time::timeout(INSTALL_TIMEOUT, async {
let uploaded = upload.await;
(uploaded, child.wait_with_output().await)
})
.await
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in '{distro}' timed out")))?;
match result {
(Ok(()), Ok(out)) if out.status.success() => Ok(format!("{build_dir}/{SERVER_BIN}")),
(_, Ok(out)) => Err(failure(distro, &out)),
(_, Err(e)) => Err(IdeError::unavailable(format!("installing {SERVER_BIN} failed: {e}"))),
}
}
/// The last lines the server (or `wsl.exe`) wrote outside the protocol.
#[derive(Clone, Default)]
pub(crate) struct Tail(Arc<Mutex<VecDeque<String>>>);
impl Tail {
fn push(&self, line: String) {
let mut lines = self.0.lock();
if lines.len() == KEPT_LINES {
lines.pop_front();
}
lines.push_back(line);
}
pub fn text(&self) -> String {
self.0.lock().iter().cloned().collect::<Vec<_>>().join("\n")
}
}
/// Starts `bin --stdio` in `distro` and speaks the protocol over its stdio. The process dies
/// with the connection.
pub(crate) fn spawn(distro: &str, bin: &str, handler: Arc<dyn Handler>, tail: &Tail) -> IdeResult<Peer> {
spawn_with(ide_core::wsl::EXE, distro, bin, handler, tail)
}
/// [`spawn`] through `launcher` instead of `wsl.exe` (tests put a stand-in there).
fn spawn_with(launcher: &str, distro: &str, bin: &str, handler: Arc<dyn Handler>, tail: &Tail) -> IdeResult<Peer> {
let args: Vec<String> = ["-d", checked(distro)?, "--exec", bin, "--stdio"].map(str::to_owned).into();
let tail = tail.clone();
Peer::spawn(launcher, &args, &std::env::temp_dir(), &[], handler, Box::new(move |line| tail.push(line)))
.map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))
}
/// Makes sure this version of the server is installed in `distro`; returns its path there.
pub(crate) async fn ensure_server(
state: &crate::state::AppState,
distro: &str,
progress: Arc<Progress>,
) -> IdeResult<String> {
use crate::remote_dev::{LocalServer, local_server, platform};
progress(RemoteStage::Checking, None);
let version = env!("CARGO_PKG_VERSION");
let probe = probe(distro, version).await?;
let (os, arch) = platform(&probe.uname);
if os != "linux" {
return Err(IdeError::unavailable(format!("'{distro}' reports {os} instead of linux")));
}
let (local, build) = match local_server(state, version, &os, &arch, &probe.installed, progress.clone()).await? {
LocalServer::Installed(bin) => return Ok(bin),
LocalServer::Binary(local, build) => (local, build),
};
if let Some(bin) = probe.installed.iter().find(|b| b.ends_with(&format!("/{build}/{SERVER_BIN}"))) {
return Ok(bin.clone());
}
let started = std::time::Instant::now();
progress(RemoteStage::Uploading, Some(0.0));
let data = tokio::fs::read(&local).await?;
let bin = install(distro, &version_dir(&probe.home, version), &build, &data, &progress).await?;
tracing::info!("installed {SERVER_BIN} in WSL '{distro}' in {:?}", started.elapsed());
Ok(bin)
}
#[cfg(test)]
mod tests {
use super::*;
const HOME: &str = "/home/me";
const UNAME: &str = "Linux x86_64";
#[test]
fn probe_output_is_read_after_the_marker() {
let installed = format!("{}/ab/{SERVER_BIN}", version_dir(HOME, "1"));
let text = format!("wsl: notice\n{PROBE_MARK}\n{HOME}\n{UNAME}\n{installed}\n");
let p = parse_probe(&text).unwrap();
assert_eq!(p, Probe { home: HOME.into(), uname: UNAME.into(), installed: vec![installed] });
}
#[test]
fn a_probe_without_a_clean_home_is_refused() {
assert!(parse_probe(&format!("{HOME}\n{UNAME}\n")).is_err(), "no marker");
assert!(parse_probe(&format!("{PROBE_MARK}\nrelative\n{UNAME}\n")).is_err());
assert!(parse_probe(&format!("{PROBE_MARK}\n")).is_err());
}
#[test]
fn invalid_distro_names_never_reach_wsl_exe() {
for bad in ["", "--exec", "a b", "a;b"] {
assert!(sh(bad, "true", &[]).is_err(), "{bad}");
}
assert!(sh("Ubuntu-24.04", "true", &[]).is_ok());
}
#[test]
fn version_dir_ignores_a_trailing_slash() {
assert_eq!(version_dir(&format!("{HOME}/"), "1.2.3"), format!("{HOME}/{SERVER_DIR}/1.2.3"));
}
struct Silent;
impl Handler for Silent {
fn on_notification(&self, _: &str, _: serde_json::Value) {}
fn on_request(&self, method: &str, _: serde_json::Value) -> Result<serde_json::Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
/// A stand-in for `wsl.exe` that checks the arguments it is given, prints a UTF-16 notice
/// the way the real one does, then answers one request.
#[cfg(unix)]
#[tokio::test]
async fn speaks_the_protocol_through_the_launcher_after_its_utf16_notice() {
use std::os::unix::fs::PermissionsExt;
let (distro, bin) = ("Debian-12", "/home/me/server");
let dir = tempfile::tempdir().unwrap();
let launcher = dir.path().join("fake-wsl");
let script = format!(
r#"#!/bin/sh
[ "$*" = "-d {distro} --exec {bin} --stdio" ] || {{ echo "unexpected arguments: $*" >&2; exit 2; }}
printf '\377\376N\000o\000\r\000\n\000'
while read -r line; do
line=${{line%?}}
case "$line" in Content-Length:*) len=${{line#*: }} ;; "") break ;; esac
done
head -c "$len" >/dev/null
body='{{"jsonrpc":"2.0","id":1,"result":"pong"}}'
printf 'Content-Length: %s\r\n\r\n%s' "${{#body}}" "$body"
"#
);
std::fs::write(&launcher, script).unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
let tail = Tail::default();
let peer = spawn_with(launcher.to_str().unwrap(), distro, bin, Arc::new(Silent), &tail).unwrap();
let reply: String = peer.request("ping", serde_json::Value::Null, None).await.unwrap_or_else(|e| {
panic!("{e:?}; launcher said: {}", tail.text());
});
assert_eq!(reply, "pong");
}
#[tokio::test]
async fn a_bad_distro_name_never_starts_the_launcher() {
let err = spawn_with("does-not-exist", "-d", "/x", Arc::new(Silent), &Tail::default()).err().unwrap();
assert!(err.message.contains("not a valid"), "{}", err.message);
}
}
+127 -16
Переглянути файл
@@ -10,6 +10,7 @@
//! [`AppState::project`]). Their labels name the kind and the parent
//! (`settings-main`, `comparison-project-2-3`); their [`Launch`] says what to show.
use crate::remote_dev::RemoteTarget;
use crate::state::AppState;
use crate::theme_frame::builtin_frame;
use ide_core::settings::AppearanceSettings;
@@ -101,14 +102,20 @@ pub struct FilePosition {
pub column: u32,
}
/// A project folder on the host of an SSH configuration.
/// A project folder on an SSH host or in a WSL distribution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteLaunch {
pub ssh_config: String,
pub target: RemoteTarget,
pub path: String,
}
/// The remote launch for a folder of the `\\wsl.localhost\<distro>` share, if it is one.
pub fn wsl_launch(path: &Path) -> Option<RemoteLaunch> {
let w = ide_core::wsl::parse(path)?;
Some(RemoteLaunch { target: RemoteTarget::Wsl { distro: w.distro }, path: w.path })
}
#[derive(Default)]
pub struct WindowsState {
pub groups: Mutex<ide_workspace::window_groups::WindowGroups>,
@@ -647,6 +654,10 @@ pub fn open_project_window(
new_window: bool,
files: Vec<PathBuf>,
) -> IdeResult<OpenOutcome> {
// Before touching the share: a stat on `\\wsl.localhost` is what makes it slow.
if let Some(remote) = wsl_launch(path) {
return open_remote_window(app, &remote.target, &remote.path, new_window);
}
let root = dunce::canonicalize(path).map_err(|e| IdeError::not_found(format!("{}: {e}", path.display())))?;
tracing::info!("open_project_window {} new_window={new_window}", root.display());
if let Some(w) = find_project_window(app, &root) {
@@ -683,12 +694,16 @@ pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec<PathBuf>) ->
here.into_iter().map(Into::into).collect()
}
/// [`open_project_window`] for folder `path` on the host of SSH configuration
/// `ssh_config` (Remote Development).
pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_window: bool) -> IdeResult<OpenOutcome> {
tracing::info!("open_remote_window {ssh_config}:{path} new_window={new_window}");
/// [`open_project_window`] for folder `path` of `target` (Remote Development).
pub fn open_remote_window(
app: &AppHandle,
target: &RemoteTarget,
path: &str,
new_window: bool,
) -> IdeResult<OpenOutcome> {
tracing::info!("open_remote_window {target:?}:{path} new_window={new_window}");
let windows = app.webview_windows();
let open = app.try_state::<AppState>().and_then(|s| s.remote_dev.window_of(ssh_config, path));
let open = app.try_state::<AppState>().and_then(|s| s.remote_dev.window_of(target, path));
if let Some(w) = open.and_then(|label| windows.get(&label)) {
focus(w);
return Ok(OpenOutcome::Focused { label: w.label().to_owned() });
@@ -697,7 +712,7 @@ pub fn open_remote_window(app: &AppHandle, ssh_config: &str, path: &str, new_win
return Ok(OpenOutcome::Current);
}
let title = path.trim_end_matches('/').rsplit('/').next().filter(|n| !n.is_empty() && *n != "~").map(str::to_owned);
let remote = RemoteLaunch { ssh_config: ssh_config.to_owned(), path: path.to_owned() };
let remote = RemoteLaunch { target: target.clone(), path: path.to_owned() };
let window = create_window_for(app, title, Launch { remote: Some(remote), ..Default::default() }, false)?;
Ok(OpenOutcome::Opened { label: window.label().to_owned() })
}
@@ -709,18 +724,41 @@ pub enum Holds {
Nothing,
LightEdit,
Project(PathBuf),
/// A remote project.
/// A project inside a WSL distribution (`path` is the Linux path).
Wsl {
distro: String,
path: String,
},
/// An SSH remote project.
Other,
}
impl Holds {
fn of_remote(remote: &RemoteLaunch) -> Self {
match &remote.target {
RemoteTarget::Wsl { distro } => Holds::Wsl { distro: distro.clone(), path: remote.path.clone() },
RemoteTarget::Ssh { .. } => Holds::Other,
}
}
fn is_wsl_folder(&self, remote: &RemoteLaunch) -> bool {
match (self, &remote.target) {
(Holds::Wsl { distro, path }, RemoteTarget::Wsl { distro: d }) => {
distro.eq_ignore_ascii_case(d) && path.trim_end_matches('/') == remote.path.trim_end_matches('/')
}
_ => false,
}
}
}
/// What window `label` holds: its open project, else what its pending launch is about to open.
fn holds(app: &AppHandle, label: &str) -> Holds {
let state = app.try_state::<AppState>();
if let Some(project) = state.as_ref().and_then(|s| s.project(label).ok()) {
return if project.light { Holds::LightEdit } else { Holds::Project(project.root.clone()) };
}
if state.is_some_and(|s| s.remote_dev.get(label).is_some()) {
return Holds::Other;
if let Some(c) = state.as_ref().and_then(|s| s.remote_dev.get(label)) {
return Holds::of_remote(&RemoteLaunch { target: c.target.clone(), path: c.root.clone() });
}
pending_holds(app.state::<WindowsState>().pending(label).as_ref())
}
@@ -728,7 +766,7 @@ fn holds(app: &AppHandle, label: &str) -> Holds {
fn pending_holds(launch: Option<&Launch>) -> Holds {
match launch {
Some(Launch { project: Some(root), .. }) => Holds::Project(root.to_path_buf()),
Some(Launch { remote: Some(_), .. }) => Holds::Other,
Some(Launch { remote: Some(remote), .. }) => Holds::of_remote(remote),
Some(l) if !l.files.is_empty() => Holds::LightEdit,
_ => Holds::Nothing,
}
@@ -745,6 +783,8 @@ pub enum OpenStep {
Launch(String, Launch),
/// A new window opens the project folder or (without one) the files in LightEdit.
New(Option<PathBuf>, Vec<PathBuf>),
/// A new window connects to a WSL distribution.
NewRemote(RemoteLaunch),
}
/// Where a file inside a project goes: a window with it open, or the step opening it.
@@ -776,6 +816,17 @@ pub fn plan_open(paths: Vec<(PathBuf, bool)>, windows: &[(String, Holds)]) -> Ve
})
.collect();
for dir in dirs {
// A folder of a WSL distro opens as a WSL project, not as a network folder.
if let Some(remote) = wsl_launch(&dir) {
steps.push(match windows.iter().find(|(_, h)| h.is_wsl_folder(&remote)) {
Some((label, _)) => OpenStep::Focus(label.clone()),
None => match free.next() {
Some(label) => OpenStep::Launch(label, Launch { remote: Some(remote), ..Default::default() }),
None => OpenStep::NewRemote(remote),
},
});
continue;
}
if let Some((label, _)) = windows.iter().find(|(_, h)| matches!(h, Holds::Project(root) if *root == dir)) {
steps.push(OpenStep::Focus(label.clone()));
continue;
@@ -799,7 +850,9 @@ pub fn plan_open(paths: Vec<(PathBuf, bool)>, windows: &[(String, Holds)]) -> Ve
Some(ProjectTarget::Step(i)) => match &mut steps[*i] {
OpenStep::Launch(_, launch) => launch.files.push(file.into()),
OpenStep::New(_, list) => list.push(file),
OpenStep::Focus(_) | OpenStep::Files(..) => unreachable!("folders opened by the plan launch or open"),
OpenStep::Focus(_) | OpenStep::Files(..) | OpenStep::NewRemote(_) => {
unreachable!("folders opened by the plan launch or open")
}
},
None => loose.push(file),
}
@@ -911,6 +964,17 @@ fn run_open(app: &AppHandle, steps: Vec<OpenStep>) {
}
}
OpenStep::New(project, files) => spawn_window_with(app, project, files),
OpenStep::NewRemote(remote) => {
let app = app.clone();
// Off the event-loop thread, like `spawn_window`.
tauri::async_runtime::spawn(async move {
let title = remote.path.trim_end_matches('/').rsplit('/').next().map(str::to_owned);
let launch = Launch { remote: Some(remote), ..Default::default() };
if let Err(e) = create_window_for(&app, title, launch, false) {
tracing::warn!("cannot open window: {}", e.message);
}
});
}
}
}
}
@@ -923,10 +987,19 @@ pub fn opened_paths(urls: &[tauri::Url]) -> Vec<PathBuf> {
/// What a window opens for a command-line path: a folder as its project, a file in LightEdit.
pub fn path_launch(path: PathBuf) -> Launch {
if path.is_dir() {
Launch { project: Some(path.into()), ..Default::default() }
} else {
let is_dir = path.is_dir();
launch_for(path, is_dir)
}
/// [`path_launch`] once the path is known to be a folder or not: only a WSL *folder* is a
/// remote project, a file of the share stays a LightEdit file.
fn launch_for(path: PathBuf, is_dir: bool) -> Launch {
if !is_dir {
Launch { files: vec![path.into()], ..Default::default() }
} else if let Some(remote) = wsl_launch(&path) {
Launch { remote: Some(remote), ..Default::default() }
} else {
Launch { project: Some(path.into()), ..Default::default() }
}
}
@@ -1382,6 +1455,17 @@ mod tests {
assert_eq!((launch.project, launch.files), (None, vec![file.into()]));
}
#[test]
fn first_launch_keeps_wsl_files_apart_from_wsl_folders() {
let share = PathBuf::from(r"\\wsl.localhost\Ubuntu\home\me\app");
let folder = launch_for(share.clone(), true);
let remote =
RemoteLaunch { target: RemoteTarget::Wsl { distro: "Ubuntu".into() }, path: "/home/me/app".into() };
assert_eq!((folder.remote, folder.project, folder.files.len()), (Some(remote), None, 0));
let file = launch_for(share.join("main.py"), false);
assert_eq!((file.remote, file.project, file.files.len()), (None, None, 1));
}
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
@@ -1478,6 +1562,33 @@ mod tests {
);
}
#[test]
fn wsl_folders_open_as_wsl_projects() {
let (distro, linux_path) = ("Debian-12", "/srv/app");
let share = format!(r"\\wsl.localhost\{distro}{}", linux_path.replace('/', r"\"));
let share = share.as_str();
let remote = wsl_launch(&p(share)).unwrap();
assert_eq!(
remote,
RemoteLaunch { target: RemoteTarget::Wsl { distro: distro.into() }, path: linux_path.into() }
);
assert!(wsl_launch(&p(linux_path)).is_none());
let launch = Launch { remote: Some(remote.clone()), ..Default::default() };
let folder = vec![(p(share), true)];
// A free window connects, else a new one; the window that has it open is focused.
assert_eq!(plan_open(folder.clone(), &[w("main", Holds::Nothing)]), [OpenStep::Launch("main".into(), launch)]);
assert_eq!(plan_open(folder.clone(), &[w("main", Holds::LightEdit)]), [OpenStep::NewRemote(remote.clone())]);
let open = Holds::Wsl { distro: distro.to_lowercase(), path: format!("{linux_path}/") };
assert_eq!(
plan_open(folder, &[w("main", Holds::Nothing), w("project-1", open)]),
[OpenStep::Focus("project-1".into())]
);
// Another distro's folder is a different project.
let other = Holds::Wsl { distro: format!("{distro}-other"), path: linux_path.into() };
assert!(!other.is_wsl_folder(&remote));
assert_eq!(launch_for(p(share), true).remote, Some(remote));
}
#[test]
fn files_opened_from_a_window_go_to_their_project_window_else_light_edit() {
let windows = [