From b0f65769c0da76bd81e15d83bc2107eda88dc342 Mon Sep 17 00:00:00 2001 From: ntbang0901 <121003585+ntbang0901@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:37:19 +0700 Subject: [PATCH] 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\\...) 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\\... 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 --exec --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: ` / `SSH: `. - 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 Co-authored-by: NguyenBang Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/wsl.rs | 137 +++++++++ crates/ide-host/src/trust.rs | 2 + crates/ide-jsonrpc/src/lib.rs | 4 + crates/ide-jsonrpc/src/tests.rs | 15 + crates/ide-terminal/src/lib.rs | 36 ++- crates/ide-vfs/src/lib.rs | 2 +- crates/ide-vfs/src/rules.rs | 117 +++++++- src-tauri/Cargo.toml | 3 + src-tauri/src/commands/mod.rs | 3 + src-tauri/src/commands/window.rs | 8 +- src-tauri/src/commands/wsl.rs | 22 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/remote_dev.rs | 181 +++++++---- src-tauri/src/remote_wsl.rs | 282 ++++++++++++++++++ src-tauri/src/windows.rs | 143 ++++++++- src/bindings.ts | 52 +++- src/features/app/__tests__/windows.test.ts | 21 +- src/features/app/windows.ts | 9 +- src/features/project-tree/fs.test.ts | 8 +- src/features/project-tree/operations.test.ts | 48 ++- src/features/project-tree/operations.ts | 15 +- .../remote-dev/ConnectDialog.test.tsx | 34 ++- src/features/remote-dev/ConnectDialog.tsx | 100 +++++-- src/features/remote-dev/RemoteStatus.tsx | 25 ++ src/features/remote-dev/index.ts | 18 +- src/features/remote-dev/ports.test.tsx | 12 +- src/features/remote-dev/ports.ts | 10 +- src/features/remote-dev/remoteDev.test.ts | 71 ++++- src/features/run/jvmTypes.test.ts | 32 ++ src/features/run/jvmTypes.ts | 4 +- src/features/run/runtimes.ts | 4 +- .../services/ServicesTree.reorder.test.tsx | 12 +- src/features/services/runTypes.ts | 3 +- src/features/welcome/RecentList.tsx | 2 +- src/features/welcome/openRecent.test.ts | 52 ++++ src/features/welcome/recent.ts | 36 ++- src/features/welcome/session.test.ts | 22 +- src/features/welcome/session.ts | 6 +- src/platform/i18n.test.ts | 2 +- src/platform/locales/text/en.json | 8 + src/platform/locales/text/zh.json | 6 + src/platform/locales/textCatalog.test.ts | 1 + src/platform/remote.ts | 109 +++++-- src/platform/remoteSettings.test.ts | 40 ++- src/workbench/projectWindow.ts | 12 +- 47 files changed, 1512 insertions(+), 220 deletions(-) create mode 100644 crates/ide-core/src/wsl.rs create mode 100644 src-tauri/src/commands/wsl.rs create mode 100644 src-tauri/src/remote_wsl.rs create mode 100644 src/features/remote-dev/RemoteStatus.tsx create mode 100644 src/features/welcome/openRecent.test.ts create mode 100644 src/platform/locales/text/en.json diff --git a/Cargo.lock b/Cargo.lock index 4cf6740f..c1a0500f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6374,6 +6374,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tauri-specta", + "tempfile", "tokio", "tracing", "tracing-appender", diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 023f9d72..7bbb35da 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod text; pub mod tool_path; pub mod version_managers; pub mod workflow_settings; +pub mod wsl; pub use error::{IdeError, IdeResult}; pub use event::{EventSink, IdeEvent, NullSink, SharedSink}; diff --git a/crates/ide-core/src/wsl.rs b/crates/ide-core/src/wsl.rs new file mode 100644 index 00000000..985280c4 --- /dev/null +++ b/crates/ide-core/src/wsl.rs @@ -0,0 +1,137 @@ +//! Projects that live inside a WSL distro are reached from Windows through the +//! `\\wsl.localhost\` (or legacy `\\wsl$\`) share. + +use std::path::Path; + +/// The WSL launcher on Windows. +pub const EXE: &str = "wsl.exe"; + +/// A location inside a WSL distro. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WslPath { + pub distro: String, + /// Absolute Linux path, e.g. `/home/me/app`. + pub path: String, +} + +/// Splits a WSL share path into distro and Linux path. Pure string work, so it +/// accepts every spelling the UI and the OS produce: `\\wsl.localhost\X\a`, +/// `//wsl$/X/a` and the verbatim `\\?\UNC\wsl.localhost\X\a`. +pub fn parse(path: &Path) -> Option { + let s = path.to_string_lossy().replace('\\', "/"); + let s = match s.strip_prefix("//?/UNC/") { + Some(rest) => format!("//{rest}"), + None => s, + }; + let rest = s.strip_prefix("//")?; + let (host, rest) = rest.split_once('/')?; + if !host.eq_ignore_ascii_case("wsl.localhost") && !host.eq_ignore_ascii_case("wsl$") { + return None; + } + let (distro, tail) = rest.split_once('/').unwrap_or((rest, "")); + if distro.is_empty() { + return None; + } + Some(WslPath { distro: distro.to_owned(), path: format!("/{}", tail.trim_end_matches('/')) }) +} + +/// A distro name `wsl.exe -d` can be given safely: Windows creates them from letters, digits, +/// `.`, `_` and `-`. A leading `-` would be read as an option, so it is refused too. +pub fn is_valid_distro(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && !name.starts_with('-') + && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +/// Text of `wsl.exe`'s own output: UTF-16LE (with or without BOM) for its management commands +/// and its error messages, UTF-8 for what a command inside the distro prints. +pub fn decode_output(raw: &[u8]) -> String { + if raw.starts_with(&[0xff, 0xfe]) || raw.get(1) == Some(&0) { + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + String::from_utf16_lossy(&units) + } else { + String::from_utf8_lossy(raw).into_owned() + } +} + +/// Distro names from the output of `wsl.exe -l -q`. That output is UTF-16LE (UTF-8 when +/// `WSL_UTF8=1`), one name per line; names that fail [`is_valid_distro`] are dropped, which also +/// removes the localized "no installed distributions" message `wsl.exe` prints when there are none. +pub fn parse_distro_list(raw: &[u8]) -> Vec { + let text = decode_output(raw); + let mut names: Vec = Vec::new(); + for line in text.lines() { + let name = line.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}'); + if is_valid_distro(name) && !names.iter().any(|n| n == name) { + names.push(name.to_owned()); + } + } + names +} + +/// Installed WSL distros. Empty off Windows, when WSL is not installed or when `wsl.exe` fails. +pub fn list_distros() -> Vec { + #[cfg(windows)] + { + let mut cmd = std::process::Command::new(EXE); + cmd.args(["-l", "-q"]); + crate::process::prepare(&mut cmd); + match cmd.output() { + Ok(out) if out.status.success() => parse_distro_list(&out.stdout), + _ => Vec::new(), + } + } + #[cfg(not(windows))] + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> Option<(String, String)> { + parse(Path::new(s)).map(|w| (w.distro, w.path)) + } + + #[test] + fn every_spelling_of_the_share_is_recognised() { + let want = Some(("Ubuntu-24.04".to_owned(), "/home/me/app".to_owned())); + assert_eq!(p(r"\\wsl.localhost\Ubuntu-24.04\home\me\app"), want); + assert_eq!(p("//wsl.localhost/Ubuntu-24.04/home/me/app"), want); + assert_eq!(p(r"\\wsl$\Ubuntu-24.04\home\me\app\"), want); + assert_eq!(p(r"\\?\UNC\WSL.LOCALHOST\Ubuntu-24.04\home\me\app"), want); + } + + #[test] + fn distro_root_and_foreign_paths() { + assert_eq!(p(r"\\wsl.localhost\Debian"), Some(("Debian".to_owned(), "/".to_owned()))); + assert_eq!(p(r"\\server\share\a"), None); + assert_eq!(p(r"\\wsl.localhost\"), None); + assert_eq!(p("C:/Users/me"), None); + assert_eq!(p("/home/me"), None); + } + + fn utf16(s: &str) -> Vec { + std::iter::once(0xfeffu16).chain(s.encode_utf16()).flat_map(u16::to_le_bytes).collect() + } + + #[test] + fn distro_list_is_decoded_from_utf16_and_utf8() { + let want = vec!["Ubuntu-24.04".to_owned(), "Debian".to_owned()]; + assert_eq!(parse_distro_list(&utf16("Ubuntu-24.04\r\nDebian\r\n\r\n")), want); + assert_eq!(parse_distro_list(b"Ubuntu-24.04\nDebian\nDebian\n"), want); + assert!( + parse_distro_list(&utf16("Windows Subsystem for Linux has no installed distributions.\r\n")).is_empty() + ); + assert!(parse_distro_list(b"").is_empty()); + } + + #[test] + fn distro_names_cannot_smuggle_options_or_paths() { + for bad in ["", "-d", "--exec", "a b", "a/b", r"a\b", "a;b", "a$(x)", "a\"b"] { + assert!(!is_valid_distro(bad), "{bad}"); + } + assert!(is_valid_distro("Ubuntu-22.04") && is_valid_distro("docker_desktop")); + } +} diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index adb41ae4..c429bfaf 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -251,6 +251,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "remote_disconnect" | "remote_connect" | "remote_bootstrap_list" + | "wsl_distros" + | "wsl_resolve" | "remote_list" | "remote_open" | "remote_of" diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index f78646ce..6aa79979 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -49,6 +49,10 @@ pub mod framing { } return Ok(None); } + // A UTF-16LE line break is `\n\0`: the cut after `\n` leaves a NUL at the start of the + // next line, which would turn the `Content-Length` after it into noise. + let nuls = line.iter().take_while(|&&b| b == 0).count(); + line.drain(..nuls); if line.last() != Some(&b'\n') { if n as u64 >= MAX_HEADER_LINE { return Err(Error::new(ErrorKind::InvalidData, "header line too long")); diff --git a/crates/ide-jsonrpc/src/tests.rs b/crates/ide-jsonrpc/src/tests.rs index 9efee601..c851cf66 100644 --- a/crates/ide-jsonrpc/src/tests.rs +++ b/crates/ide-jsonrpc/src/tests.rs @@ -69,6 +69,21 @@ mod framing_tests { assert_eq!(noise[2], "Picked up JAVA_TOOL_OPTIONS: -Dx"); } + #[tokio::test] + async fn utf16_output_of_wsl_exe_before_a_message_does_not_swallow_it() { + // `wsl.exe` reports its own errors as UTF-16LE with a BOM; the real server only starts + // after it, so the first message must still be read whole. + let notice = "The Windows Subsystem for Linux instance has terminated."; + let mut input: Vec = [0xff, 0xfe].into(); + input.extend(format!("{notice}\r\n").encode_utf16().flat_map(u16::to_le_bytes)); + input.extend(b"Content-Length: 2\r\n\r\n{}"); + let (msgs, noise) = read_with_noise(&input).await; + assert_eq!(msgs.unwrap(), vec![b"{}".to_vec()]); + // The text is kept for the error shown to the user, whatever the BOM decodes to. + let ascii: String = noise.concat().chars().filter(char::is_ascii).filter(|c| *c != '\0').collect(); + assert_eq!(ascii.trim(), notice); + } + #[tokio::test] async fn unterminated_output_then_eof_is_reported() { // `pause` prints its prompt without a newline and the process is gone. diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 56c6aaf2..ebe53e31 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -95,7 +95,8 @@ impl TerminalManager { let size = pty_size(options.cols, options.rows); let pair = native_pty_system().openpty(size).map_err(|e| IdeError::internal(format!("openpty failed: {e}")))?; - let (shell, mut args) = resolve_shell(options.shell.as_deref(), &options.args); + let wsl = if cfg!(windows) { wsl_shell(&options) } else { None }; + let (shell, mut args) = wsl.clone().unwrap_or_else(|| resolve_shell(options.shell.as_deref(), &options.args)); let mut env = terminal_env(&options.env); if let Some(msystem) = default_msystem(&shell, &options.env, std::env::var("MSYSTEM").ok().as_deref()) { env.insert("MSYSTEM".to_owned(), msystem.to_owned()); @@ -108,7 +109,8 @@ impl TerminalManager { } let mut cmd = CommandBuilder::new(&shell); cmd.args(&args); - cmd.cwd(resolve_cwd(&options.cwd)); + // wsl.exe takes the directory through `--cd`; a UNC share is no valid process cwd. + cmd.cwd(if wsl.is_some() { resolve_cwd(Path::new("")) } else { resolve_cwd(&options.cwd) }); for (k, v) in ide_core::process::host_env_fixes() { match v { Some(v) => cmd.env(k, v), @@ -479,6 +481,16 @@ fn pty_size(cols: u16, rows: u16) -> PtySize { } } +/// `wsl.exe` in the distro of a `\\wsl.localhost\...` folder, so its terminal is a Linux +/// shell and not PowerShell (#925). An explicitly chosen shell or arguments win. +fn wsl_shell(options: &TerminalOptions) -> Option<(String, Vec)> { + if options.shell.as_deref().is_some_and(|s| !s.trim().is_empty()) || !options.args.is_empty() { + return None; + } + let w = ide_core::wsl::parse(&options.cwd)?; + Some((ide_core::wsl::EXE.into(), vec!["-d".into(), w.distro, "--cd".into(), w.path])) +} + fn resolve_cwd(cwd: &Path) -> PathBuf { if cwd.is_dir() { return cwd.to_path_buf(); @@ -527,7 +539,7 @@ pub fn available_shells() -> Vec { } #[cfg(windows)] { - for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", "wsl.exe"] { + for exe in ["pwsh.exe", "powershell.exe", "cmd.exe", ide_core::wsl::EXE] { if let Some(p) = find_in_path(exe) { add(p); } @@ -882,6 +894,24 @@ mod tests { assert!(h.events.try_recv().is_err() && other_events.try_recv().is_err(), "no cross delivery"); } + #[test] + fn wsl_folder_gets_the_distro_shell_unless_one_is_chosen() { + let mut o = sh("true"); + o.shell = None; + o.args = vec![]; + let (distro, linux_path) = ("Debian-12", "/srv/app"); + let want = (ide_core::wsl::EXE.to_owned(), vec!["-d".into(), distro.into(), "--cd".into(), linux_path.into()]); + for host in ["wsl.localhost", "wsl$"] { + o.cwd = PathBuf::from(format!(r"\\{host}\{distro}{}", linux_path.replace('/', r"\"))).into(); + assert_eq!(wsl_shell(&o), Some(want.clone()), "{host}"); + } + o.shell = Some("pwsh.exe".into()); + assert_eq!(wsl_shell(&o), None); + o.shell = None; + o.cwd = PathBuf::from("/tmp").into(); + assert_eq!(wsl_shell(&o), None); + } + #[test] fn shell_resolution() { let (shell, args) = resolve_shell(Some("/bin/zsh"), &[]); diff --git a/crates/ide-vfs/src/lib.rs b/crates/ide-vfs/src/lib.rs index 3941e8ba..ab9d2dfd 100644 --- a/crates/ide-vfs/src/lib.rs +++ b/crates/ide-vfs/src/lib.rs @@ -124,7 +124,7 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult> { if !fs::metadata(dir)?.is_dir() { return Err(IdeError::invalid(format!("{} is not a directory", dir.display()))); } - let gitignore = rules::GitignoreStack::new(dir); + let gitignore = rules::GitignoreStack::cached(dir); let base = gitignore.repo_root().unwrap_or(dir).to_path_buf(); let excludes = ExcludeMatcher::new(excludes); diff --git a/crates/ide-vfs/src/rules.rs b/crates/ide-vfs/src/rules.rs index 59a45cbd..cad487a4 100644 --- a/crates/ide-vfs/src/rules.rs +++ b/crates/ide-vfs/src/rules.rs @@ -8,6 +8,7 @@ use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; /// Exclude globs from settings (`files.exclude`), e.g. `**/node_modules`. /// @@ -72,6 +73,14 @@ pub(crate) fn find_repo_root(start: &Path) -> Option { start.ancestors().find(|dir| dir.join(".git").exists()).map(Path::to_path_buf) } +/// `info/exclude` of the repository at `root` (shared by its worktrees). +fn exclude_path(root: &Path) -> PathBuf { + gix::discover(root) + .ok() + .map(|r| r.common_dir().join("info/exclude")) + .unwrap_or_else(|| root.join(".git/info/exclude")) +} + /// Layered gitignore matching: per-directory `.gitignore` / `.ignore` from the /// path's directory up to the repository root (or the file system root when /// not in a repository), then `.git/info/exclude` and the global excludes file. @@ -83,6 +92,29 @@ pub(crate) struct GitignoreStack { nested: Mutex>>>, } +/// How long [`GitignoreStack::cached`] reuses the repository lookup of a directory. +const REPO_LEVEL_TTL: Duration = Duration::from_secs(3); + +/// What changes when `.git/info/exclude` is edited. +type Stamp = Option<(SystemTime, u64)>; + +struct RepoLevel { + at: Instant, + repo_root: Option, + /// `/info/exclude`, found by a repository discovery. + exclude: Option, + stamp: Stamp, + rules: Vec, +} + +/// Repo-level rules by the directory they were looked up for. +static REPO_LEVEL: Mutex>>> = Mutex::new(None); + +fn stamp_of(file: Option<&Path>) -> Stamp { + let meta = std::fs::metadata(file?).ok()?; + Some((meta.modified().ok()?, meta.len())) +} + impl GitignoreStack { pub fn new(near: &Path) -> Self { let repo_root = find_repo_root(near); @@ -90,15 +122,52 @@ impl GitignoreStack { Self { repo_root, repo_level, cache: Mutex::default(), nested: Mutex::default() } } + /// [`new`](Self::new) for listing a directory over and over (expanding a tree, a slow share): + /// the walk up to the repository root and the repository discovery are reused for + /// [`REPO_LEVEL_TTL`]. An edit of `.git/info/exclude` is still seen at once (its mtime and size + /// are checked), as is any `.gitignore` / `.ignore`, which are read fresh. Only a repository + /// created in the directory, or a change of the global excludes file, can lag by the TTL. + pub fn cached(near: &Path) -> Self { + let now = Instant::now(); + let hit = REPO_LEVEL + .lock() + .as_ref() + .and_then(|m| m.get(near)) + .cloned() + .filter(|e| now.duration_since(e.at) < REPO_LEVEL_TTL && stamp_of(e.exclude.as_deref()) == e.stamp); + let entry = hit.unwrap_or_else(|| { + let repo_root = find_repo_root(near); + let exclude = repo_root.as_deref().map(exclude_path); + let entry = Arc::new(RepoLevel { + at: now, + stamp: stamp_of(exclude.as_deref()), + rules: Self::load_repo_level_from(repo_root.as_deref(), exclude.as_deref()), + repo_root, + exclude, + }); + let mut guard = REPO_LEVEL.lock(); + let map = guard.get_or_insert_with(HashMap::new); + map.retain(|_, e| now.duration_since(e.at) < REPO_LEVEL_TTL); + map.insert(near.to_path_buf(), entry.clone()); + entry + }); + Self { + repo_root: entry.repo_root.clone(), + repo_level: RwLock::new(entry.rules.clone()), + cache: Mutex::default(), + nested: Mutex::default(), + } + } + fn load_repo_level(repo_root: Option<&Path>) -> Vec { + Self::load_repo_level_from(repo_root, repo_root.map(exclude_path).as_deref()) + } + + fn load_repo_level_from(repo_root: Option<&Path>, exclude: Option<&Path>) -> Vec { let mut repo_level = Vec::new(); if let Some(root) = repo_root { let mut builder = GitignoreBuilder::new(root); - let exclude = gix::discover(root) - .ok() - .map(|r| r.common_dir().join("info/exclude")) - .unwrap_or_else(|| root.join(".git/info/exclude")); - if exclude.is_file() { + if let Some(exclude) = exclude.filter(|f| f.is_file()) { builder.add(exclude); } if let Ok(gi) = builder.build() @@ -119,6 +188,9 @@ impl GitignoreStack { /// Re-reads `.git/info/exclude` and the global excludes file. pub fn reload_repo_level(&self) { + if let (Some(root), Some(map)) = (&self.repo_root, REPO_LEVEL.lock().as_mut()) { + map.retain(|dir, _| !dir.starts_with(root)); + } self.nested.lock().clear(); *self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref()); } @@ -298,4 +370,39 @@ mod tests { assert!(anchored.is_excluded(Path::new("build/out.o"))); assert!(!anchored.is_excluded(Path::new("web/build/out.o"))); } + + fn repo_with_exclude(rule: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap(); + std::fs::create_dir_all(tmp.path().join("src")).unwrap(); + std::fs::write(tmp.path().join(".git/info/exclude"), rule).unwrap(); + tmp + } + + #[test] + fn cached_stack_sees_every_edit_of_the_ignore_files_at_once() { + let tmp = repo_with_exclude("secret.txt\n"); + let (src, secret, note) = + (tmp.path().join("src"), tmp.path().join("src/secret.txt"), tmp.path().join("src/note.md")); + assert!(GitignoreStack::cached(&src).is_ignored(&secret, false)); + assert!(!GitignoreStack::cached(&src).is_ignored(¬e, false)); + std::fs::write(src.join(".gitignore"), "*.md\n").unwrap(); + assert!(GitignoreStack::cached(&src).is_ignored(¬e, false), "directory .gitignore"); + std::fs::write(tmp.path().join(".git/info/exclude"), "").unwrap(); + assert!(!GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude"); + std::fs::write(tmp.path().join(".git/info/exclude"), "note.md\nsecret.txt\n").unwrap(); + assert!(GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude again"); + } + + #[test] + fn cached_stack_reuses_the_repository_lookup() { + let tmp = repo_with_exclude(""); + let src = tmp.path().join("src"); + let first = GitignoreStack::cached(&src); + let entry = REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap().clone(); + let again = GitignoreStack::cached(&src); + assert!(Arc::ptr_eq(&entry, REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap())); + assert_eq!(first.repo_root(), again.repo_root()); + assert_eq!(again.repo_root(), Some(tmp.path())); + } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0057b6ac..418eef4b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1ffdd6dd..31722bf4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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 { 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, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index abec5dae..ad0f8c11 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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 { - 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. diff --git a/src-tauri/src/commands/wsl.rs b/src-tauri/src/commands/wsl.rs new file mode 100644 index 00000000..d205337b --- /dev/null +++ b/src-tauri/src/commands/wsl.rs @@ -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> { + 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\\…` 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::wsl_launch(&path) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76acd0b6..74b4f3ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ mod macos; mod menu; mod plugin_web; mod remote_dev; +mod remote_wsl; mod state; mod theme_frame; diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 2d210b4b..cb025ec8 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -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, /// 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, + /// Keeps the SSH connection alive; `None` for WSL. + session: Option>, /// Remote ports forwarded to this machine (Ports tool window), by remote port. forwards: Mutex>, } @@ -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 { + /// The window connected to folder `root` of `target`. + pub fn window_of(&self, target: &RemoteTarget, root: &str) -> Option { 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, } -type Progress = dyn Fn(RemoteStage, Option) + Send + Sync; +pub(crate) type Progress = dyn Fn(RemoteStage, Option) + 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) -> IdeResult { - 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, +) -> IdeResult { + 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) -> IdeResult { + 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 = 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 { 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, ) -> IdeResult { @@ -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, #[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, } @@ -535,9 +590,10 @@ pub struct RemoteSession { pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option { 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> { 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 diff --git a/src-tauri/src/remote_wsl.rs b/src-tauri/src/remote_wsl.rs new file mode 100644 index 00000000..d0ee378c --- /dev/null +++ b/src-tauri/src/remote_wsl.rs @@ -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, +} + +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 --exec sh -c