дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod macos;
|
||||
mod menu;
|
||||
mod plugin_web;
|
||||
mod remote_dev;
|
||||
mod remote_wsl;
|
||||
|
||||
mod state;
|
||||
mod theme_frame;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in new issue
Заблокувати користувача