дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
47 files changed
+1512
-220
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Заблокувати користувача