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

+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