mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00: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 <nguyenbang@NTBANG.local> Co-authored-by: NguyenBang <nguyenbang@Mac.lan> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
co-authored by
NguyenBang
NguyenBang
l0ng-ai
parent
9bf28dd451
commit
b0f65769c0
Generated
+1
@@ -6374,6 +6374,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tauri-specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Projects that live inside a WSL distro are reached from Windows through the
|
||||
//! `\\wsl.localhost\<distro>` (or legacy `\\wsl$\<distro>`) 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<WslPath> {
|
||||
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<u16> = 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<String> {
|
||||
let text = decode_output(raw);
|
||||
let mut names: Vec<String> = 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<String> {
|
||||
#[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<u8> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<u8> = [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.
|
||||
|
||||
@@ -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<String>)> {
|
||||
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<String> {
|
||||
}
|
||||
#[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"), &[]);
|
||||
|
||||
@@ -124,7 +124,7 @@ pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult<Vec<FileEntry>> {
|
||||
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);
|
||||
|
||||
|
||||
+112
-5
@@ -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<PathBuf> {
|
||||
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<HashMap<PathBuf, Option<Arc<GitignoreStack>>>>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
/// `<common dir>/info/exclude`, found by a repository discovery.
|
||||
exclude: Option<PathBuf>,
|
||||
stamp: Stamp,
|
||||
rules: Vec<Gitignore>,
|
||||
}
|
||||
|
||||
/// Repo-level rules by the directory they were looked up for.
|
||||
static REPO_LEVEL: Mutex<Option<HashMap<PathBuf, Arc<RepoLevel>>>> = 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<Gitignore> {
|
||||
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<Gitignore> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+122
-59
@@ -28,24 +28,46 @@ pub async fn remote_bootstrap_list(
|
||||
state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await
|
||||
}
|
||||
|
||||
/// Where a remote project lives and how the server there is reached.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum RemoteTarget {
|
||||
/// The host of SSH configuration `config`.
|
||||
Ssh { config: String },
|
||||
/// A WSL distribution on this machine.
|
||||
Wsl { distro: String },
|
||||
}
|
||||
|
||||
/// Where the server is installed on the remote host (per version).
|
||||
fn install_dir(version: &str) -> String {
|
||||
format!("~/.openidea/server/{version}")
|
||||
format!("~/{SERVER_DIR}/{version}")
|
||||
}
|
||||
|
||||
/// Under the remote home: where the server builds are kept, one folder per version.
|
||||
pub(crate) const SERVER_DIR: &str = ".openidea/server";
|
||||
|
||||
/// File name of the server binary on every host.
|
||||
pub(crate) const SERVER_BIN: &str = "openidea-server";
|
||||
|
||||
/// Printed before a probe's answer: shell start-up files and `wsl.exe` may print first.
|
||||
pub(crate) const PROBE_MARK: &str = "__OPENIDEA_PROBE__";
|
||||
|
||||
/// One remote window's connection.
|
||||
pub struct Connection {
|
||||
peer: Peer,
|
||||
pub ssh_config: String,
|
||||
pub target: RemoteTarget,
|
||||
pub root: String,
|
||||
pub host: String,
|
||||
/// The server's `std::env::consts::OS`: where the project's commands run.
|
||||
pub os: String,
|
||||
pub commands: Vec<String>,
|
||||
/// Streaming arguments by connection-wide id: the frontend channel and the id
|
||||
/// it expects (each webview numbers its own, and a settings window shares this
|
||||
/// connection with its parent).
|
||||
channels: Channels,
|
||||
next_channel: std::sync::atomic::AtomicU64,
|
||||
session: Arc<ide_ssh::Session>,
|
||||
/// Keeps the SSH connection alive; `None` for WSL.
|
||||
session: Option<Arc<ide_ssh::Session>>,
|
||||
/// Remote ports forwarded to this machine (Ports tool window), by remote port.
|
||||
forwards: Mutex<Vec<(u16, ide_ssh::Tunnel)>>,
|
||||
}
|
||||
@@ -82,13 +104,13 @@ impl Connections {
|
||||
self.0.lock().get(window).cloned()
|
||||
}
|
||||
|
||||
/// The window connected to folder `root` on the host of SSH configuration `ssh_config`.
|
||||
pub fn window_of(&self, ssh_config: &str, root: &str) -> Option<String> {
|
||||
/// The window connected to folder `root` of `target`.
|
||||
pub fn window_of(&self, target: &RemoteTarget, root: &str) -> Option<String> {
|
||||
let root = root.trim_end_matches('/');
|
||||
self.0
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|(_, c)| c.ssh_config == ssh_config && c.root.trim_end_matches('/') == root)
|
||||
.find(|(_, c)| c.target == *target && c.root.trim_end_matches('/') == root)
|
||||
.map(|(label, _)| label.clone())
|
||||
}
|
||||
|
||||
@@ -123,7 +145,7 @@ pub struct RemoteProgress {
|
||||
pub fraction: Option<f64>,
|
||||
}
|
||||
|
||||
type Progress = dyn Fn(RemoteStage, Option<f64>) + Send + Sync;
|
||||
pub(crate) type Progress = dyn Fn(RemoteStage, Option<f64>) + Send + Sync;
|
||||
|
||||
/// A value for one streaming argument of a remote command (`data`: JSON text).
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
@@ -136,9 +158,9 @@ pub struct ChannelFrame {
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteProject {
|
||||
pub ssh_config: String,
|
||||
pub target: RemoteTarget,
|
||||
pub root: String,
|
||||
/// `user@host:port` of the SSH configuration.
|
||||
/// `user@host:port` of the SSH configuration; the distribution name for WSL.
|
||||
pub host: String,
|
||||
pub server_version: String,
|
||||
pub os: String,
|
||||
@@ -244,7 +266,7 @@ async fn download_server(
|
||||
}
|
||||
|
||||
/// `uname -sm` → (`linux` | `macos`, `x86_64` | `aarch64`), as Rust names them.
|
||||
fn platform(uname: &str) -> (String, String) {
|
||||
pub(crate) fn platform(uname: &str) -> (String, String) {
|
||||
let mut parts = uname.split_whitespace();
|
||||
let os = match parts.next().unwrap_or_default().to_ascii_lowercase().as_str() {
|
||||
"darwin" => "macos".to_owned(),
|
||||
@@ -258,29 +280,30 @@ fn platform(uname: &str) -> (String, String) {
|
||||
(os, arch)
|
||||
}
|
||||
|
||||
/// Makes sure this version of the server is installed on the host; returns its path.
|
||||
async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc<Progress>) -> IdeResult<String> {
|
||||
progress(RemoteStage::Checking, None);
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let dir = install_dir(version);
|
||||
let q = ide_ssh::shell::tilde_quote;
|
||||
// Between markers: shell start-up files may print to stdout too.
|
||||
const MARK: &str = "__OPENIDEA_PROBE__";
|
||||
let probe = session
|
||||
.exec(&format!("echo {MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None)
|
||||
.await?
|
||||
.stdout_str();
|
||||
let probe = probe.split_once(MARK).map_or(probe.as_str(), |(_, rest)| rest);
|
||||
let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
let (os, arch) = platform(lines.next().unwrap_or_default());
|
||||
let installed: Vec<&str> = lines.collect();
|
||||
let local = match local_server_binary(state, &os, &arch) {
|
||||
/// What [`local_server`] settled on.
|
||||
pub(crate) enum LocalServer {
|
||||
/// A server binary on this machine and its [`build_id`].
|
||||
Binary(std::path::PathBuf, String),
|
||||
/// No binary available (offline): this build of the version already on the host.
|
||||
Installed(String),
|
||||
}
|
||||
|
||||
/// The server binary to put on a host of `os`/`arch` whose `installed` builds are known.
|
||||
pub(crate) async fn local_server(
|
||||
state: &AppState,
|
||||
version: &str,
|
||||
os: &str,
|
||||
arch: &str,
|
||||
installed: &[String],
|
||||
progress: Arc<Progress>,
|
||||
) -> IdeResult<LocalServer> {
|
||||
let local = match local_server_binary(state, os, arch) {
|
||||
Some(local) => local,
|
||||
None => match download_server(state, version, &os, &arch, progress.clone()).await {
|
||||
None => match download_server(state, version, os, arch, progress).await {
|
||||
Ok(local) => local,
|
||||
// Offline: any build of this version already on the host will do.
|
||||
Err(e) => {
|
||||
return installed.first().map(|b| b.to_string()).ok_or_else(|| {
|
||||
return installed.first().cloned().map(LocalServer::Installed).ok_or_else(|| {
|
||||
IdeError::unavailable(format!(
|
||||
"no openidea-server {version} for {os}-{arch} ({}). Build it with \
|
||||
`cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \
|
||||
@@ -304,6 +327,27 @@ async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: A
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
Ok(LocalServer::Binary(local, build))
|
||||
}
|
||||
|
||||
/// Makes sure this version of the server is installed on the host; returns its path.
|
||||
async fn ensure_server(state: &AppState, session: &ide_ssh::Session, progress: Arc<Progress>) -> IdeResult<String> {
|
||||
progress(RemoteStage::Checking, None);
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let dir = install_dir(version);
|
||||
let q = ide_ssh::shell::tilde_quote;
|
||||
let probe = session
|
||||
.exec(&format!("echo {PROBE_MARK}; uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None)
|
||||
.await?
|
||||
.stdout_str();
|
||||
let probe = probe.split_once(PROBE_MARK).map_or(probe.as_str(), |(_, rest)| rest);
|
||||
let mut lines = probe.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
let (os, arch) = platform(lines.next().unwrap_or_default());
|
||||
let installed: Vec<String> = lines.map(str::to_owned).collect();
|
||||
let (local, build) = match local_server(state, version, &os, &arch, &installed, progress.clone()).await? {
|
||||
LocalServer::Installed(bin) => return Ok(bin),
|
||||
LocalServer::Binary(local, build) => (local, build),
|
||||
};
|
||||
if let Some(bin) = installed.iter().find(|b| b.ends_with(&format!("/{build}/openidea-server"))) {
|
||||
return Ok(bin.to_string());
|
||||
}
|
||||
@@ -359,14 +403,14 @@ fn build_id(path: &std::path::Path) -> IdeResult<String> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// File › Remote Development: opens `path` on the host of SSH configuration
|
||||
/// `ssh_config` in this window (installs / starts the server as needed).
|
||||
/// File › Remote Development: opens `path` on `target` (an SSH host or a WSL distribution)
|
||||
/// in this window (installs / starts the server as needed).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_connect(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
ssh_config: String,
|
||||
target: RemoteTarget,
|
||||
path: String,
|
||||
on_progress: Channel<RemoteProgress>,
|
||||
) -> IdeResult<RemoteProject> {
|
||||
@@ -377,23 +421,39 @@ pub async fn remote_connect(
|
||||
let label = AppState::owner_window(window.label()).to_owned();
|
||||
state.remote_dev.disconnect(&label);
|
||||
state.host.close_project(&label);
|
||||
let config = state.ssh.config(&ssh_config)?;
|
||||
let session = state.ssh.session(&ssh_config).await?;
|
||||
let bin = ensure_server(&state, &session, progress.clone()).await?;
|
||||
progress(RemoteStage::Starting, None);
|
||||
let stream = session
|
||||
.exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin)))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
let channels: Channels = Arc::default();
|
||||
let handler =
|
||||
Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() });
|
||||
let peer = Peer::connect(reader, writer, handler);
|
||||
let tail = crate::remote_wsl::Tail::default();
|
||||
let (peer, session, host, title_tag) = match &target {
|
||||
RemoteTarget::Ssh { config: id } => {
|
||||
let config = state.ssh.config(id)?;
|
||||
let session = state.ssh.session(id).await?;
|
||||
let bin = ensure_server(&state, &session, progress.clone()).await?;
|
||||
progress(RemoteStage::Starting, None);
|
||||
let stream = session
|
||||
.exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin)))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
(Peer::connect(reader, writer, handler), Some(session), config.label(), config.label())
|
||||
}
|
||||
RemoteTarget::Wsl { distro } => {
|
||||
let bin = crate::remote_wsl::ensure_server(&state, distro, progress.clone()).await?;
|
||||
progress(RemoteStage::Starting, None);
|
||||
let peer = crate::remote_wsl::spawn(distro, &bin, handler, &tail)?;
|
||||
(peer, None, distro.clone(), format!("WSL: {distro}"))
|
||||
}
|
||||
};
|
||||
// A server that dies on start-up says why on stderr (`wsl.exe` prints its own errors too).
|
||||
let with_output = |e: IdeError| match tail.text() {
|
||||
t if t.is_empty() => e,
|
||||
t => IdeError::unavailable(format!("{}: {t}", e.message)),
|
||||
};
|
||||
let info: ServerInfo = peer
|
||||
.request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None)
|
||||
.await
|
||||
.map_err(ide_host::rpc::error_from_reply)?;
|
||||
.map_err(|e| with_output(ide_host::rpc::error_from_reply(e)))?;
|
||||
if info.protocol != ide_host::rpc::PROTOCOL_VERSION {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"openidea-server {} speaks protocol {}, this app {}",
|
||||
@@ -417,12 +477,13 @@ pub async fn remote_connect(
|
||||
peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?;
|
||||
// As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's.
|
||||
let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root);
|
||||
let _ = window.set_title(&format!("{} [{}]", project.name, config.label()));
|
||||
let _ = window.set_title(&format!("{} [{title_tag}]", project.name));
|
||||
let connection = Arc::new(Connection {
|
||||
peer,
|
||||
ssh_config: ssh_config.clone(),
|
||||
target: target.clone(),
|
||||
root: root.clone(),
|
||||
host: config.label(),
|
||||
host: host.clone(),
|
||||
os: info.os.clone(),
|
||||
commands: commands.clone(),
|
||||
channels,
|
||||
next_channel: std::sync::atomic::AtomicU64::new(1),
|
||||
@@ -435,15 +496,7 @@ pub async fn remote_connect(
|
||||
sync_plugin_languages(&plugins, &connection, &label).await;
|
||||
sync_plugin_packages(&plugins, &connection, &label).await;
|
||||
});
|
||||
Ok(RemoteProject {
|
||||
ssh_config,
|
||||
root,
|
||||
host: config.label(),
|
||||
server_version: info.version,
|
||||
os: info.os,
|
||||
commands,
|
||||
project,
|
||||
})
|
||||
Ok(RemoteProject { target, root, host, server_version: info.version, os: info.os, commands, project })
|
||||
}
|
||||
|
||||
/// Sends the desktop's plugin languages, with the programs granted to them, to
|
||||
@@ -523,9 +576,11 @@ pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>,
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteSession {
|
||||
pub ssh_config: String,
|
||||
pub target: RemoteTarget,
|
||||
pub root: String,
|
||||
pub host: String,
|
||||
/// `linux`, `macos` or `windows`: the OS the project's commands run on.
|
||||
pub os: String,
|
||||
pub commands: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -535,9 +590,10 @@ pub struct RemoteSession {
|
||||
pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option<RemoteSession> {
|
||||
let c = state.remote_dev.get(AppState::owner_window(window.label()))?;
|
||||
Some(RemoteSession {
|
||||
ssh_config: c.ssh_config.clone(),
|
||||
target: c.target.clone(),
|
||||
root: c.root.clone(),
|
||||
host: c.host.clone(),
|
||||
os: c.os.clone(),
|
||||
commands: c.commands.clone(),
|
||||
})
|
||||
}
|
||||
@@ -584,7 +640,11 @@ pub async fn remote_forward_port(
|
||||
if let Some(p) = forwarded(&c).into_iter().find(|p| p.remote_port == remote_port) {
|
||||
return Ok(p);
|
||||
}
|
||||
let tunnel = ide_ssh::Tunnel::listen_on(c.session.clone(), Some(remote_port), "localhost", remote_port).await?;
|
||||
let session = c
|
||||
.session
|
||||
.clone()
|
||||
.ok_or_else(|| IdeError::unavailable("Forwarding ports is not supported in a WSL project yet"))?;
|
||||
let tunnel = ide_ssh::Tunnel::listen_on(session, Some(remote_port), "localhost", remote_port).await?;
|
||||
let port = ForwardedPort { remote_port, local_port: tunnel.port() };
|
||||
let mut forwards = c.forwards.lock();
|
||||
if let Some(existing) = forwards.iter().find(|(remote, _)| *remote == remote_port) {
|
||||
@@ -681,10 +741,13 @@ pub async fn remote_import_local(
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let label = AppState::owner_window(window.label());
|
||||
let c = state.remote_dev.get(label).ok_or_else(|| IdeError::unavailable("Remote project is disconnected"))?;
|
||||
let Some(session) = c.session.clone() else {
|
||||
return Err(IdeError::unavailable("Importing local files is not supported in a WSL project yet"));
|
||||
};
|
||||
c.authorize_command(label, "fs_copy").await?;
|
||||
let cancel = state.host.begin_request(request);
|
||||
let result = async {
|
||||
let fs = ide_ssh::RemoteFs::new(c.session.sftp().await?);
|
||||
let fs = ide_ssh::RemoteFs::new(session.sftp().await?);
|
||||
let result = ide_remote::import::import_local(&fs, &from, &to, &cancel).await;
|
||||
fs.close().await;
|
||||
result
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+38
-14
@@ -802,10 +802,10 @@ export const commands = {
|
||||
*/
|
||||
windowOpenFileInNewWindow: (project: string | null, file: string) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_file_in_new_window", { 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.
|
||||
*/
|
||||
windowOpenRemote: (sshConfig: string, path: string, newWindow: boolean) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_remote", { sshConfig, path, newWindow })),
|
||||
windowOpenRemote: (target: RemoteTarget, path: string, newWindow: boolean) => typedError<OpenOutcome, IdeError>(__TAURI_INVOKE("window_open_remote", { target, path, newWindow })),
|
||||
/** Opens an empty (welcome) window; returns its label. */
|
||||
windowNew: () => typedError<string, IdeError>(__TAURI_INVOKE("window_new")),
|
||||
/**
|
||||
@@ -1149,6 +1149,19 @@ export const commands = {
|
||||
dbTrustSshHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_trust_ssh_host", { id })),
|
||||
/** Test Connection: connects with `config` as edited (not necessarily saved). */
|
||||
sshTest: (config: SshConfiguration, secret: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("ssh_test", { config, secret })),
|
||||
/**
|
||||
* Installed WSL distros, for the "Connect to WSL" picker. `wsl.exe` can stall while the WSL
|
||||
* service starts, so the wait is bounded.
|
||||
*/
|
||||
wslDistros: () => typedError<string[], IdeError>(__TAURI_INVOKE("wsl_distros")),
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
wslResolve: (path: string) => __TAURI_INVOKE<{
|
||||
target: RemoteTarget,
|
||||
path: string,
|
||||
} | null>("wsl_resolve", { path }),
|
||||
/** Remembers the password / passphrase of configuration `id` (`None` forgets it). */
|
||||
sshSetSecret: (id: string, secret: string | null, save: boolean) => __TAURI_INVOKE<void>("ssh_set_secret", { id, secret, save }),
|
||||
/** Whether connecting with configuration `id` needs a password first. */
|
||||
@@ -1191,15 +1204,17 @@ export const commands = {
|
||||
/** Browse before connecting, using the desktop's SSH configuration even in a remote window. */
|
||||
remoteBootstrapList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })),
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
remoteConnect: (sshConfig: string, path: string, onProgress: Channel<RemoteProgress>) => typedError<RemoteProject_Serialize, IdeError>(__TAURI_INVOKE("remote_connect", { sshConfig, path, onProgress })),
|
||||
remoteConnect: (target: RemoteTarget, path: string, onProgress: Channel<RemoteProgress>) => typedError<RemoteProject_Serialize, IdeError>(__TAURI_INVOKE("remote_connect", { target, path, onProgress })),
|
||||
/** The remote session of this window (a settings window: its parent's), if any. */
|
||||
remoteCurrent: () => __TAURI_INVOKE<{
|
||||
sshConfig: string,
|
||||
target: RemoteTarget,
|
||||
root: string,
|
||||
host: string,
|
||||
/** `linux`, `macos` or `windows`: the OS the project's commands run on. */
|
||||
os: string,
|
||||
commands: string[],
|
||||
} | null>("remote_current"),
|
||||
remoteDisconnect: () => __TAURI_INVOKE<void>("remote_disconnect"),
|
||||
@@ -6110,9 +6125,9 @@ export type RemoteEntry = {
|
||||
permissions: string,
|
||||
};
|
||||
|
||||
/** A project folder on the host of an SSH configuration. */
|
||||
/** A project folder on an SSH host or in a WSL distribution. */
|
||||
export type RemoteLaunch = {
|
||||
sshConfig: string,
|
||||
target: RemoteTarget,
|
||||
path: string,
|
||||
};
|
||||
|
||||
@@ -6140,9 +6155,9 @@ export type RemoteProgress = {
|
||||
export type RemoteProject = RemoteProject_Serialize | RemoteProject_Deserialize;
|
||||
|
||||
export type RemoteProject_Deserialize = {
|
||||
sshConfig: string,
|
||||
target: RemoteTarget,
|
||||
root: string,
|
||||
/** `user@host:port` of the SSH configuration. */
|
||||
/** `user@host:port` of the SSH configuration; the distribution name for WSL. */
|
||||
host: string,
|
||||
serverVersion: string,
|
||||
os: string,
|
||||
@@ -6152,9 +6167,9 @@ export type RemoteProject_Deserialize = {
|
||||
};
|
||||
|
||||
export type RemoteProject_Serialize = {
|
||||
sshConfig: string,
|
||||
target: RemoteTarget,
|
||||
root: string,
|
||||
/** `user@host:port` of the SSH configuration. */
|
||||
/** `user@host:port` of the SSH configuration; the distribution name for WSL. */
|
||||
host: string,
|
||||
serverVersion: string,
|
||||
os: string,
|
||||
@@ -6164,15 +6179,24 @@ export type RemoteProject_Serialize = {
|
||||
};
|
||||
|
||||
export type RemoteSession = {
|
||||
sshConfig: string,
|
||||
target: RemoteTarget,
|
||||
root: string,
|
||||
host: string,
|
||||
/** `linux`, `macos` or `windows`: the OS the project's commands run on. */
|
||||
os: string,
|
||||
commands: string[],
|
||||
};
|
||||
|
||||
/** Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). */
|
||||
export type RemoteStage = "connecting" | "checking" | "downloading" | "uploading" | "starting" | "opening";
|
||||
|
||||
/** Where a remote project lives and how the server there is reached. */
|
||||
export type RemoteTarget =
|
||||
/** The host of SSH configuration `config`. */
|
||||
{ kind: "ssh"; config: string } |
|
||||
/** A WSL distribution on this machine. */
|
||||
{ kind: "wsl"; distro: string };
|
||||
|
||||
export type Renamed = {
|
||||
edit: WorkspaceEdit,
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
|
||||
@@ -6,11 +6,13 @@ const projectCurrent = vi.fn();
|
||||
const windowOpenProject = vi.fn();
|
||||
const windowOpenFiles = vi.fn();
|
||||
const windowTakeLaunch = vi.fn();
|
||||
const wslResolve = vi.fn();
|
||||
const cancelClose = vi.fn();
|
||||
const notify = vi.fn();
|
||||
vi.mock("@/platform/remote", async (original) => ({
|
||||
...(await original<typeof import("@/platform/remote")>()),
|
||||
isRemoteWindow: vi.fn(() => false),
|
||||
connectRemote: vi.fn(async () => {}),
|
||||
}));
|
||||
// The backend answers with the global settings, here: whatever the test set up.
|
||||
const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} }));
|
||||
@@ -34,6 +36,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
windowOpenProject: (...a: unknown[]) => windowOpenProject(...a),
|
||||
windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a),
|
||||
windowTakeLaunch: () => windowTakeLaunch(),
|
||||
wslResolve: (p: string) => wslResolve(p),
|
||||
lightEditOpen: (p: string) => lightEditOpen(p),
|
||||
fsReadDir: (p: string) => fsReadDir(p),
|
||||
},
|
||||
@@ -55,7 +58,7 @@ const {
|
||||
} = await import("../windows");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions");
|
||||
const { isRemoteWindow } = await import("@/platform/remote");
|
||||
const { isRemoteWindow, connectRemote } = await import("@/platform/remote");
|
||||
const { IpcError } = await import("@/platform/ipc");
|
||||
provideNotificationService({ notify });
|
||||
|
||||
@@ -77,7 +80,9 @@ const ok = (data: unknown) => ({ status: "ok", data });
|
||||
const projectState = (root: string) => ({ project: { root, name: root, trusted: true, isGit: false, settings: {} } });
|
||||
|
||||
beforeEach(() => {
|
||||
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset();
|
||||
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch, wslResolve]) f.mockReset();
|
||||
wslResolve.mockResolvedValue(null);
|
||||
vi.mocked(connectRemote).mockClear();
|
||||
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
|
||||
cancelClose.mockClear();
|
||||
notify.mockClear();
|
||||
@@ -95,6 +100,18 @@ describe("openProjectInWindow", () => {
|
||||
expect(openProject).toHaveBeenCalledWith("/p/a");
|
||||
});
|
||||
|
||||
it("connects to the distribution when the folder is on the WSL share", async () => {
|
||||
const [distro, linuxPath] = ["Debian", "/srv/app"];
|
||||
const share = `\\\\wsl.localhost\\${distro}${linuxPath.replaceAll("/", "\\")}`;
|
||||
const target = { kind: "wsl", distro };
|
||||
wslResolve.mockResolvedValue({ target, path: linuxPath });
|
||||
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
|
||||
await openProjectInWindow(share, false);
|
||||
expect(wslResolve).toHaveBeenCalledWith(share);
|
||||
expect(connectRemote).toHaveBeenCalledWith(target, linuxPath);
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for a new window when this one already has a project", async () => {
|
||||
useProject.setState(projectState("/p/b") as never);
|
||||
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
|
||||
|
||||
@@ -53,7 +53,10 @@ async function openHere(path: string): Promise<boolean> {
|
||||
if (!isRemoteWindow() && useProject.getState().project?.root === path) return true;
|
||||
if (!(await confirmLeaveProject())) return false;
|
||||
try {
|
||||
await openProject(path);
|
||||
// A folder of a WSL distro runs there, not as a network folder.
|
||||
const wsl = await commands.wslResolve(path).catch(() => null);
|
||||
if (wsl) await connectRemote(wsl.target, wsl.path);
|
||||
else await openProject(path);
|
||||
return true;
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
@@ -219,8 +222,8 @@ export async function consumeLaunch(): Promise<void> {
|
||||
if (await openHere(launch.project)) await openInEditor(files, positions);
|
||||
} else if (files.length) await openFilesHere(files, positions);
|
||||
else if (launch?.remote) {
|
||||
const { sshConfig, path } = launch.remote;
|
||||
await connectRemote(sshConfig, path).catch((e) =>
|
||||
const { target, path } = launch.remote;
|
||||
await connectRemote(target, path).catch((e) =>
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: t("app.window.openFailed", { name: basename(path) }),
|
||||
|
||||
@@ -13,7 +13,13 @@ vi.mock("@/platform/tauriCore", async (original) => ({
|
||||
beforeEach(() => {
|
||||
useTasks.setState({ tasks: [] });
|
||||
useRemote.setState({
|
||||
session: { sshConfig: "s", root: "/remote", host: "host", commands: ["fs_copy", "cancel_request"] },
|
||||
session: {
|
||||
target: { kind: "ssh", config: "s" },
|
||||
root: "/remote",
|
||||
host: "host",
|
||||
os: "linux",
|
||||
commands: ["fs_copy", "cancel_request"],
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MEMBER_PREFIX, type ProjectNode } from "./model";
|
||||
import {
|
||||
canPaste,
|
||||
checkEditingName,
|
||||
copySelection,
|
||||
importExternal,
|
||||
paste,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { MemoryFs, setProjectFsForTests } from "./fs";
|
||||
import { initProjectTree, loadDir, setClipboard, setSelection, useProjectTree } from "./store";
|
||||
@@ -21,6 +23,30 @@ import { setOsClipboardForTests } from "./clipboard";
|
||||
let fs: MemoryFs;
|
||||
const notify = vi.fn();
|
||||
|
||||
describe("file names follow the execution host", () => {
|
||||
const platform = usePlatform.getState();
|
||||
afterEach(() => {
|
||||
usePlatform.setState(platform);
|
||||
useRemote.setState({ session: null });
|
||||
setProjectFsForTests(null);
|
||||
initProjectTree(null);
|
||||
});
|
||||
|
||||
it.each(["linux", "windows", "macos"])("validates names on a %s host from a Windows desktop", async (os) => {
|
||||
usePlatform.setState({ os: "windows" });
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "host" }, root: "/p", host: "host", os, commands: [] },
|
||||
});
|
||||
fs = new MemoryFs({ "/p/readme.txt": "" }, "/p");
|
||||
setProjectFsForTests(fs, "/p");
|
||||
initProjectTree("/p", "p");
|
||||
await loadDir("/p");
|
||||
useProjectTree.setState({ editing: { kind: "new", parent: "/p", nodeKind: "file" } });
|
||||
expect(checkEditingName("con").error !== null).toBe(os === "windows");
|
||||
expect(checkEditingName("README.txt").error !== null).toBe(os !== "linux");
|
||||
});
|
||||
});
|
||||
|
||||
const node = (path: string, dir = false): ProjectNode => ({
|
||||
key: path,
|
||||
path,
|
||||
@@ -102,7 +128,9 @@ describe("OS file imports", () => {
|
||||
setProjectFsForTests(fs, "/p");
|
||||
initProjectTree("/p", "p");
|
||||
await loadDir("/p");
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
|
||||
});
|
||||
provideNotificationService({ notify });
|
||||
notify.mockClear();
|
||||
});
|
||||
@@ -292,7 +320,15 @@ describe("project clipboard ownership", () => {
|
||||
it.each(remoteCases)(
|
||||
"remote hand-off=$handedOff OS=$osList cut=$cut on $entry",
|
||||
async ({ handedOff, osList, cut, entry }) => {
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
useRemote.setState({
|
||||
session: {
|
||||
target: { kind: "ssh", config: "ssh" },
|
||||
root: "/p",
|
||||
host: "host",
|
||||
os: "linux",
|
||||
commands: ["fs_copy"],
|
||||
},
|
||||
});
|
||||
const writeText = vi.fn(async () => {
|
||||
if (!handedOff) throw new Error("unavailable");
|
||||
});
|
||||
@@ -338,7 +374,9 @@ describe("project clipboard ownership", () => {
|
||||
);
|
||||
|
||||
it("writes multiple remote paths as newline-joined text", async () => {
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
|
||||
});
|
||||
await fs.createFile("/p/second.txt");
|
||||
await loadDir("/p", true);
|
||||
setSelection(new Set(["/p/source.txt", "/p/second.txt"]), "/p/source.txt");
|
||||
@@ -350,7 +388,9 @@ describe("project clipboard ownership", () => {
|
||||
});
|
||||
|
||||
it("waits for a pending remote text write before reading file URLs", async () => {
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "ssh" }, root: "/p", host: "host", os: "linux", commands: ["fs_copy"] },
|
||||
});
|
||||
let finish!: () => void;
|
||||
vi.stubGlobal("navigator", {
|
||||
clipboard: {
|
||||
|
||||
@@ -39,17 +39,14 @@ import {
|
||||
useProjectTree,
|
||||
} from "./store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { hostOs, isRemoteWindow } from "@/platform/remote";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
|
||||
/** Nodes that may be renamed, moved or deleted (not roots, groups or library sources). */
|
||||
const mutable = (n: ProjectNode) => !n.root && !n.placeholder && !n.readOnly;
|
||||
|
||||
const get = () => useProjectTree.getState();
|
||||
|
||||
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
|
||||
const isWindows = /Win/i.test(ua);
|
||||
/** macOS and Windows file systems are case-insensitive by default. */
|
||||
const caseInsensitive = /Mac|Win/i.test(ua);
|
||||
const caseInsensitiveOs = (os: string) => os === "windows" || os === "macos";
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
@@ -236,6 +233,9 @@ export function cancelEditing() {
|
||||
export function checkEditingName(name: string) {
|
||||
const { editing } = get();
|
||||
if (!editing) return { error: null };
|
||||
const os = hostOs();
|
||||
const isWindows = os === "windows";
|
||||
const caseInsensitive = caseInsensitiveOs(os);
|
||||
if (editing.kind === "new" && editing.pkg) {
|
||||
const { segments, error } = packageSegments(name, editing.pkg);
|
||||
if (!segments) return { error };
|
||||
@@ -645,6 +645,7 @@ async function pasteFromOsClipboard(paths: string[], target: string) {
|
||||
/** The same files, as the clipboard keeps them (`/`-separated, any order). */
|
||||
function sameFiles(files: string[], paths: string[] | undefined): boolean {
|
||||
if (!paths || files.length !== paths.length) return false;
|
||||
const caseInsensitive = caseInsensitiveOs(usePlatform.getState().os);
|
||||
const key = (list: string[]) =>
|
||||
list
|
||||
.map((p) => (caseInsensitive ? p.toLowerCase() : p))
|
||||
@@ -687,6 +688,7 @@ export async function transfer(
|
||||
if (!root) return false;
|
||||
const fs = projectFs(root);
|
||||
await loadDir(target);
|
||||
const caseInsensitive = caseInsensitiveOs(hostOs());
|
||||
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
|
||||
const hasName = (n: string) => taken.has(caseInsensitive ? n.toLowerCase() : n);
|
||||
const isTaken = (n: string) => hasName(n) || (isUnityAssetFolder(target) && hasName(`${n}.meta`));
|
||||
@@ -744,6 +746,7 @@ export async function transfer(
|
||||
async function moveInto(paths: string[], target: string): Promise<boolean> {
|
||||
paths = assetPaths(paths);
|
||||
await loadDir(target);
|
||||
const caseInsensitive = caseInsensitiveOs(hostOs());
|
||||
const taken = new Set(siblingsOf(target).map((n) => (caseInsensitive ? n.toLowerCase() : n)));
|
||||
const errors: string[] = [];
|
||||
const moves: { from: string; to: string }[] = [];
|
||||
|
||||
@@ -6,8 +6,9 @@ import { useRemoteProgress } from "@/platform/remote";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { closeConnectDialog, openConnectDialog, useConnectDialog } from "./model";
|
||||
|
||||
const { list, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
|
||||
const { list, distros, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
distros: vi.fn(),
|
||||
globalSettings: vi.fn(),
|
||||
openRemote: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
@@ -15,7 +16,10 @@ const { list, globalSettings, openRemote, executeAction } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return { ...real, commands: { ...real.commands, remoteBootstrapList: list, settingsGetGlobal: globalSettings } };
|
||||
return {
|
||||
...real,
|
||||
commands: { ...real.commands, remoteBootstrapList: list, wslDistros: distros, settingsGetGlobal: globalSettings },
|
||||
};
|
||||
});
|
||||
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: openRemote }));
|
||||
vi.mock("@/platform/actions", async (importOriginal) => ({
|
||||
@@ -35,6 +39,7 @@ const entry = (path: string, isDir = true): RemoteEntry => ({
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
distros.mockResolvedValue({ status: "ok", data: [] });
|
||||
// A remote project's window: its settings are the remote host's, without this machine's SSH configurations.
|
||||
useProject.setState({ project: null, opening: false, settings: { ssh: { configs: [] } } as unknown as Settings });
|
||||
globalSettings.mockResolvedValue({
|
||||
@@ -87,7 +92,9 @@ it("browses the selected SSH host, navigates folders and opens one without a cur
|
||||
await waitFor(() => expect(screen.getByLabelText("Project directory:")).toHaveValue("/home/dev/projects"));
|
||||
expect(within(folders).getAllByRole("button")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(openRemote).toHaveBeenCalledWith("selected", "/home/dev/projects"));
|
||||
await waitFor(() =>
|
||||
expect(openRemote).toHaveBeenCalledWith({ kind: "ssh", config: "selected" }, "/home/dev/projects"),
|
||||
);
|
||||
expect(useConnectDialog.getState().open).toBe(false);
|
||||
expect(useProject.getState().project).toBeNull();
|
||||
});
|
||||
@@ -125,3 +132,24 @@ it("offers SSH configuration and disables Browse until one exists, then lists th
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Browse" })).toBeEnabled());
|
||||
expect(screen.getByRole("button", { name: "SSH configuration" })).toHaveTextContent("dev");
|
||||
});
|
||||
|
||||
it("hides the WSL choice when no distribution is installed", async () => {
|
||||
render(<ConnectHost />);
|
||||
await waitFor(() => expect(distros).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Connect through" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a folder of the chosen WSL distribution, without browsing over SFTP", async () => {
|
||||
const installed = ["Ubuntu-24.04", "Debian"];
|
||||
distros.mockResolvedValue({ status: "ok", data: installed });
|
||||
openRemote.mockResolvedValue(true);
|
||||
render(<ConnectHost />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Connect through" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "WSL" }));
|
||||
expect(screen.queryByRole("button", { name: "Browse" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "WSL distribution" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: installed[1] }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(openRemote).toHaveBeenCalledWith({ kind: "wsl", distro: installed[1] }, "~"));
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, onIdeEvent, type RemoteEntry } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, onIdeEvent, type RemoteEntry, type RemoteTarget } from "@/platform/ipc";
|
||||
import { useRemoteProgress } from "@/platform/remote";
|
||||
import { sshLabel, type SshConfigValid, validateRemote } from "@/platform/remoteSettings";
|
||||
import { Button, Dialog, Icon, Input, ProgressBar, Select, Spinner } from "@/ui";
|
||||
@@ -52,11 +52,34 @@ function ConnectDialog() {
|
||||
const configs = useLocalSshConfigs();
|
||||
const [picked, setSsh] = useState("");
|
||||
const ssh = configs.some((c) => c.id === picked) ? picked : (configs[0]?.id ?? "");
|
||||
const [kind, setKind] = useState<RemoteTarget["kind"]>("ssh");
|
||||
// Installed WSL distributions; none (or no WSL at all) hides the WSL choice.
|
||||
const [distros, setDistros] = useState<string[]>([]);
|
||||
const [distro, setDistro] = useState("");
|
||||
const [path, setPath] = useState("~");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browse, setBrowse] = useState<{ dir: string; folders: RemoteEntry[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// Detection is a convenience: any failure just leaves SSH as the only choice.
|
||||
void call(commands.wslDistros())
|
||||
.then((found) => {
|
||||
if (!live) return;
|
||||
setDistros(found);
|
||||
setDistro((d) => d || (found[0] ?? ""));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const wsl = kind === "wsl";
|
||||
const name = wsl ? distro : ssh;
|
||||
const target: RemoteTarget = wsl ? { kind: "wsl", distro } : { kind: "ssh", config: ssh };
|
||||
|
||||
const list = async (dir: string) => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
@@ -76,8 +99,7 @@ function ConnectDialog() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const target = path.trim() || "~";
|
||||
if (await openRemoteProject(ssh, target)) closeConnectDialog();
|
||||
if (await openRemoteProject(target, path.trim() || "~")) closeConnectDialog();
|
||||
else setBusy(false);
|
||||
} catch (e) {
|
||||
if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -86,7 +108,7 @@ function ConnectDialog() {
|
||||
};
|
||||
|
||||
const options = configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) }));
|
||||
const ready = !!ssh && !busy && !!path.trim();
|
||||
const ready = !!name && !busy && !!path.trim();
|
||||
const connecting = useRemoteProgress((s) => s.progress !== null);
|
||||
|
||||
return (
|
||||
@@ -115,21 +137,52 @@ function ConnectDialog() {
|
||||
<p className={styles.hint}>
|
||||
{tx("The project runs on the remote host; the IDE backend is installed there on first use.")}
|
||||
</p>
|
||||
<label className={styles.label}>{tx("SSH configuration:")}</label>
|
||||
<div className={styles.row}>
|
||||
<Select
|
||||
className={styles.grow}
|
||||
value={ssh || null}
|
||||
options={options}
|
||||
placeholder={tx("No SSH configurations")}
|
||||
onChange={(id) => {
|
||||
setSsh(id);
|
||||
setBrowse(null);
|
||||
}}
|
||||
aria-label={tx("SSH configuration")}
|
||||
/>
|
||||
<Button onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}>{tx("Configure…")}</Button>
|
||||
</div>
|
||||
{distros.length > 0 && (
|
||||
<>
|
||||
<label className={styles.label}>{tx("Connect through:")}</label>
|
||||
<Select
|
||||
value={kind}
|
||||
options={[
|
||||
{ value: "ssh", label: "SSH" },
|
||||
{ value: "wsl", label: "WSL" },
|
||||
]}
|
||||
onChange={(k) => {
|
||||
setKind(k as RemoteTarget["kind"]);
|
||||
setBrowse(null);
|
||||
}}
|
||||
aria-label={tx("Connect through")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{wsl ? (
|
||||
<>
|
||||
<label className={styles.label}>{tx("WSL distribution:")}</label>
|
||||
<Select
|
||||
value={distro || null}
|
||||
options={distros.map((d) => ({ value: d, label: d }))}
|
||||
onChange={setDistro}
|
||||
aria-label={tx("WSL distribution")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.label}>{tx("SSH configuration:")}</label>
|
||||
<div className={styles.row}>
|
||||
<Select
|
||||
className={styles.grow}
|
||||
value={ssh || null}
|
||||
options={options}
|
||||
placeholder={tx("No SSH configurations")}
|
||||
onChange={(id) => {
|
||||
setSsh(id);
|
||||
setBrowse(null);
|
||||
}}
|
||||
aria-label={tx("SSH configuration")}
|
||||
/>
|
||||
<Button onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}>{tx("Configure…")}</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.label} htmlFor="remote-dev-path">
|
||||
{tx("Project directory:")}
|
||||
</label>
|
||||
@@ -142,9 +195,12 @@ function ConnectDialog() {
|
||||
spellCheck={false}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<Button disabled={!ssh || busy} onClick={() => void list(path.trim() || "~")}>
|
||||
{tx("Browse")}
|
||||
</Button>
|
||||
{/* Browsing lists over SFTP; a WSL distribution has no such session before connecting. */}
|
||||
{!wsl && (
|
||||
<Button disabled={!ssh || busy} onClick={() => void list(path.trim() || "~")}>
|
||||
{tx("Browse")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{browse && (
|
||||
<ul className={styles.folders} aria-label={tx("Remote folders")}>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { RemoteSession } from "@/platform/ipc";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { BarButton } from "@/ui";
|
||||
|
||||
/** Status bar label of a remote window: where its project runs. */
|
||||
export function remoteBadge(session: Pick<RemoteSession, "target" | "host">): string {
|
||||
return `${session.target.kind === "wsl" ? "WSL" : "SSH"}: ${session.host}`;
|
||||
}
|
||||
|
||||
export function RemoteStatus() {
|
||||
const session = useRemote((s) => s.session);
|
||||
if (!session) return null;
|
||||
const label = remoteBadge(session);
|
||||
return (
|
||||
<BarButton
|
||||
tooltip={tx`Project runs on ${label}`}
|
||||
aria-label={label}
|
||||
onClick={() => void executeAction("remote.connect")}
|
||||
>
|
||||
{label}
|
||||
</BarButton>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* Remote development: a window whose project lives on an SSH host. The backend
|
||||
* Remote development: a window whose project lives on an SSH host or in a WSL distribution. The backend
|
||||
* (`openidea-server`) runs there and this window routes its commands to it
|
||||
* (platform/remote.ts).
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { disconnectRemote, useRemote } from "@/platform/remote";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { maybeEditorService, registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { maybeEditorService, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
|
||||
import { confirmLeaveProject } from "@/platform/project";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { reloadEnvironment } from "./environment";
|
||||
import { RemoteStatus } from "./RemoteStatus";
|
||||
import { closeConnectDialog, openConnectDialog } from "./model";
|
||||
import { restorePorts, usePorts } from "./ports";
|
||||
|
||||
/** The Ports tool window, while this window is connected to a remote host. */
|
||||
/** SSH tunnels are available while this window is connected to an SSH host. */
|
||||
function trackPortsWindow(): () => void {
|
||||
let off: (() => void) | null = null;
|
||||
const sync = (connected: boolean) => {
|
||||
@@ -34,8 +35,8 @@ function trackPortsWindow(): () => void {
|
||||
usePorts.setState({ ports: [] });
|
||||
}
|
||||
};
|
||||
sync(useRemote.getState().session !== null);
|
||||
const unsubscribe = useRemote.subscribe((s) => sync(s.session !== null));
|
||||
sync(useRemote.getState().session?.target.kind === "ssh");
|
||||
const unsubscribe = useRemote.subscribe((s) => sync(s.session?.target.kind === "ssh"));
|
||||
return () => {
|
||||
unsubscribe();
|
||||
off?.();
|
||||
@@ -46,6 +47,13 @@ export function activate(): () => void {
|
||||
const disposers = [
|
||||
trackPortsWindow(),
|
||||
registerOverlay({ id: "remote.connect", component: ConnectHost }),
|
||||
registerStatusItem({
|
||||
id: "remote.location",
|
||||
title: "Remote Host",
|
||||
align: "left",
|
||||
priority: 100,
|
||||
component: RemoteStatus,
|
||||
}),
|
||||
registerActions([
|
||||
{
|
||||
id: "remote.connect",
|
||||
|
||||
@@ -63,7 +63,9 @@ describe("restoring forwarded ports", () => {
|
||||
it("forwards the ports of this host and folder again after reconnecting", async () => {
|
||||
const { useRemote } = await import("@/platform/remote");
|
||||
const { forwardPort, restorePorts, savedPorts } = await import("./ports");
|
||||
useRemote.setState({ session: { sshConfig: "lh", root: "/srv/app", host: "h", commands: [] } });
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "lh" }, root: "/srv/app", host: "h", os: "linux", commands: [] },
|
||||
});
|
||||
await forwardPort(8080);
|
||||
expect(savedPorts()).toEqual([8080]);
|
||||
// Reconnected: the new connection forwards nothing yet.
|
||||
@@ -73,7 +75,13 @@ describe("restoring forwarded ports", () => {
|
||||
expect(ipc.remoteForwardPort).toHaveBeenCalledWith(8080);
|
||||
expect(usePorts.getState().ports).toEqual([{ remotePort: 8080, localPort: 8080 }]);
|
||||
// Another folder on the same host has its own list.
|
||||
useRemote.setState({ session: { sshConfig: "lh", root: "/srv/other", host: "h", commands: [] } });
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "lh" }, root: "/srv/other", host: "h", os: "linux", commands: [] },
|
||||
});
|
||||
expect(savedPorts()).toEqual([]);
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "wsl", distro: "lh" }, root: "/srv/app", host: "lh", os: "linux", commands: [] },
|
||||
});
|
||||
expect(savedPorts()).toEqual([]);
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ const SAVED_KEY = "openidea.remote.ports:";
|
||||
|
||||
function savedKey(): string | null {
|
||||
const session = useRemote.getState().session;
|
||||
return session ? `${SAVED_KEY}${session.sshConfig}:${session.root}` : null;
|
||||
return session?.target.kind === "ssh" ? `${SAVED_KEY}${session.target.config}:${session.root}` : null;
|
||||
}
|
||||
|
||||
export function savedPorts(): number[] {
|
||||
@@ -53,6 +53,14 @@ export function parsePort(text: string): number | null {
|
||||
}
|
||||
|
||||
export async function forwardPort(port: number): Promise<ForwardedPort | null> {
|
||||
if (useRemote.getState().session?.target.kind === "wsl") {
|
||||
notifications().notify({
|
||||
group: "remote",
|
||||
level: "error",
|
||||
title: tx("Forwarding ports is not supported in a WSL project yet"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const forwarded = await call(commands.remoteForwardPort(port));
|
||||
await refreshPorts(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
remoteLocation,
|
||||
} from "@/platform/remote";
|
||||
import { browsableFolders, listedDirectory, remoteParent } from "./model";
|
||||
import { remoteBadge } from "./RemoteStatus";
|
||||
|
||||
const entry = (path: string, isDir = true): RemoteEntry => ({
|
||||
name: path.slice(path.lastIndexOf("/") + 1),
|
||||
@@ -47,33 +48,79 @@ describe("remote folder browser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const ssh = (config: string) => ({ kind: "ssh", config }) as const;
|
||||
const wsl = (distro: string) => ({ kind: "wsl", distro }) as const;
|
||||
|
||||
describe("remote project locations", () => {
|
||||
it("round-trip the configuration id and the path", () => {
|
||||
const loc = remoteLocation("mac mini/1", "/Users/me/repo");
|
||||
const loc = remoteLocation(ssh("mac mini/1"), "/Users/me/repo");
|
||||
expect(loc).toBe("ssh://mac%20mini%2F1/Users/me/repo");
|
||||
expect(parseRemoteLocation(loc)).toEqual({ sshConfig: "mac mini/1", path: "/Users/me/repo" });
|
||||
expect(parseRemoteLocation(loc)).toEqual({ target: ssh("mac mini/1"), path: "/Users/me/repo" });
|
||||
expect(parseRemoteLocation("/Users/me/repo")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip a WSL distribution and the path", () => {
|
||||
const loc = remoteLocation(wsl("Ubuntu-24.04"), "/home/me/app");
|
||||
expect(loc).toBe("wsl://Ubuntu-24.04/home/me/app");
|
||||
expect(parseRemoteLocation(loc)).toEqual({ target: wsl("Ubuntu-24.04"), path: "/home/me/app" });
|
||||
expect(parseRemoteLocation("wsl://Ubuntu")).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers recent remote projects most recent first, once each", () => {
|
||||
localStorage.clear();
|
||||
rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
|
||||
rememberRemote({ sshConfig: "b", path: "/q", name: "q", host: "me@b", openedAtMs: 2 });
|
||||
rememberRemote({ sshConfig: "a", path: "/p", name: "p", host: "me@a", openedAtMs: 3 });
|
||||
rememberRemote({ target: ssh("a"), path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
|
||||
rememberRemote({ target: ssh("b"), path: "/q", name: "q", host: "me@b", openedAtMs: 2 });
|
||||
rememberRemote({ target: ssh("a"), path: "/p", name: "p", host: "me@a", openedAtMs: 3 });
|
||||
expect(recentRemotes().map((r) => r.path)).toEqual(["/p", "/q"]);
|
||||
forgetRemote("a", "/p");
|
||||
forgetRemote(ssh("a"), "/p");
|
||||
expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]);
|
||||
});
|
||||
|
||||
it("keeps an SSH host and a WSL distribution of the same name apart", () => {
|
||||
localStorage.clear();
|
||||
rememberRemote({ target: ssh("dev"), path: "/p", name: "p", host: "me@dev", openedAtMs: 1 });
|
||||
rememberRemote({ target: wsl("dev"), path: "/p", name: "p", host: "dev", openedAtMs: 2 });
|
||||
expect(recentRemotes()).toHaveLength(2);
|
||||
forgetRemote(wsl("dev"), "/p");
|
||||
expect(recentRemotes().map((r) => r.target)).toEqual([ssh("dev")]);
|
||||
});
|
||||
|
||||
it("reads recents saved before WSL support, which carried `sshConfig`", () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
"openidea.remote.recent",
|
||||
JSON.stringify([
|
||||
{ sshConfig: "old", path: "/p", name: "p", host: "me@old", openedAtMs: 1 },
|
||||
{ path: "/no-target" },
|
||||
]),
|
||||
);
|
||||
expect(recentRemotes().map((r) => r.target)).toEqual([ssh("old")]);
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates only the original remote recents once, scoped to the saved host", () => {
|
||||
localStorage.clear();
|
||||
rememberRemote({ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 });
|
||||
rememberRemote({ target: ssh("a"), path: "/old", host: "me@old", name: "old", openedAtMs: 1 });
|
||||
initializeRemoteTrustMigration();
|
||||
rememberRemote({ sshConfig: "a", path: "/new", host: "me@old", name: "new", openedAtMs: 2 });
|
||||
rememberRemote({ target: ssh("a"), path: "/new", host: "me@old", name: "new", openedAtMs: 2 });
|
||||
initializeRemoteTrustMigration();
|
||||
expect(takeLegacyRemoteTrust("a", "/new", "me@old")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust("a", "/old", "me@other")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(true);
|
||||
expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust(ssh("a"), "/new", "me@old")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@other")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@old")).toBe(true);
|
||||
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@old")).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates a snapshot taken before WSL support, and never trusts a WSL distribution", () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
"openidea.remote.trustMigration",
|
||||
JSON.stringify([{ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 }]),
|
||||
);
|
||||
expect(takeLegacyRemoteTrust(wsl("a"), "/old", "me@old")).toBe(false);
|
||||
expect(takeLegacyRemoteTrust(ssh("a"), "/old", "me@old")).toBe(true);
|
||||
});
|
||||
|
||||
it("labels the status bar with the transport and where the project runs", () => {
|
||||
expect(remoteBadge({ target: wsl("Ubuntu"), host: "Ubuntu" })).toBe("WSL: Ubuntu");
|
||||
expect(remoteBadge({ target: ssh("a"), host: "me@a:22" })).toBe("SSH: me@a:22");
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { usePlatform } = await import("@/platform/platformInfo");
|
||||
const { parseJavaArgs, joinJavaArgs } = await import("@/platform/javaLaunch");
|
||||
const { parseArgs } = await import("./configs");
|
||||
const { jdwpLaunch } = await import("./javaDebug");
|
||||
@@ -247,3 +248,34 @@ describe("Maven / Gradle: VM options of the forked JVM", () => {
|
||||
await expect(gradle().prepareLaunch!(remote)).rejects.toThrow(/run targets/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("build wrapper per execution host", () => {
|
||||
const wrapperCommands = () =>
|
||||
(["gradle", "maven"] as const).map((t) => typeFor(t)!.commandLine({ wrapper: "true", tasks: "test" })!.command);
|
||||
const WINDOWS = ["gradlew.bat", "mvnw.cmd"];
|
||||
const UNIX = ["./gradlew", "./mvnw"];
|
||||
|
||||
beforeEach(() => registerBuiltinTypes());
|
||||
afterEach(async () => {
|
||||
(await import("@/platform/remote")).disableRemote();
|
||||
usePlatform.setState({ os: "macos" });
|
||||
});
|
||||
|
||||
it("follows the desktop OS in a local window", () => {
|
||||
usePlatform.setState({ os: "windows" });
|
||||
expect(wrapperCommands()).toEqual(WINDOWS);
|
||||
usePlatform.setState({ os: "linux" });
|
||||
expect(wrapperCommands()).toEqual(UNIX);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ desktop: "windows", host: "linux", target: { kind: "wsl", distro: "Ubuntu" }, expected: UNIX },
|
||||
{ desktop: "windows", host: "macos", target: { kind: "ssh", config: "box" }, expected: UNIX },
|
||||
{ desktop: "macos", host: "windows", target: { kind: "ssh", config: "win" }, expected: WINDOWS },
|
||||
] as const)("follows the $host server of a remote window opened from $desktop", async (c) => {
|
||||
usePlatform.setState({ os: c.desktop });
|
||||
const { enableRemote } = await import("@/platform/remote");
|
||||
enableRemote({ target: c.target, root: "/app", host: "h", os: c.host, commands: [] });
|
||||
expect(wrapperCommands()).toEqual(c.expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { call, commands, type RunConfig } from "@/platform/ipc";
|
||||
import { CLASSPATH, parseJavaArgs, TEST_CLASSPATH } from "@/platform/javaLaunch";
|
||||
import { hostOs } from "@/platform/remote";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { useBuildPresentation } from "@/platform/projectPresentation";
|
||||
import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions";
|
||||
@@ -13,7 +14,8 @@ import { formatArgs, parseArgs } from "./configs";
|
||||
import { commandName } from "./commandName";
|
||||
import { updatePolicyDefaults, updatePolicyFields } from "./updatePolicies";
|
||||
|
||||
export const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
|
||||
/** Whether this window's commands run on Windows: the remote server's OS in a remote window, else the desktop's. */
|
||||
export const isWindows = () => hostOs() === "windows";
|
||||
|
||||
/** VM options / program arguments as typed; on Windows a backslash is a path separator. */
|
||||
export const splitJvmArgs = (text: string | undefined, windows = isWindows()) => parseArgs(text ?? "", windows);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Rust come from SDK discovery and everything else resolves at launch.
|
||||
*/
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
import type { LocatedTool, ToolEnv } from "./producers";
|
||||
|
||||
const TTL = 60_000;
|
||||
@@ -15,7 +16,6 @@ interface Entry {
|
||||
}
|
||||
|
||||
const cache = new Map<string, Entry>();
|
||||
const isWindows = typeof navigator !== "undefined" && /Win/i.test(navigator.platform || navigator.userAgent);
|
||||
let compileDir: string | null = null;
|
||||
let locateUnavailable = false;
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function toolEnv(
|
||||
return {
|
||||
tool: (n) => tools.get(n),
|
||||
compileDir: compileDir ?? "/tmp/openidea-run",
|
||||
windows: isWindows,
|
||||
windows: isWindows(),
|
||||
python: py,
|
||||
...markers,
|
||||
};
|
||||
|
||||
@@ -138,7 +138,17 @@ describe("service reordering integration", () => {
|
||||
render(<ServicesTreeView />);
|
||||
select("Portal");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move Up" }));
|
||||
act(() => useRemote.setState({ session: { root: "/services", sshConfig: "host", host: "host", commands: [] } }));
|
||||
act(() =>
|
||||
useRemote.setState({
|
||||
session: {
|
||||
root: "/services",
|
||||
target: { kind: "ssh", config: "host" },
|
||||
host: "host",
|
||||
os: "linux",
|
||||
commands: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(names()).toEqual(["Spring Boot", "Gateway", "Portal", "npm", "Web", "Worker", "Docker"]);
|
||||
act(() => useRemote.setState({ session: null }));
|
||||
expect(names()).toEqual(["Spring Boot", "Portal", "Gateway", "npm", "Web", "Worker", "Docker"]);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { splitCommandLine } from "@/platform/commandLine";
|
||||
import { call, commands, type RunConfig } from "@/platform/ipc";
|
||||
import { registerRunConfigType, type RunConfigTypeContribution } from "@/workbench/contributions";
|
||||
import { hostOs } from "@/platform/remote";
|
||||
import { imageTag, targetTitle, type DockerTarget } from "./compose";
|
||||
import { connections, endpointOf, loadSettings, useServices } from "./store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -19,7 +20,7 @@ export const DOCKER_COMPOSE = "docker-compose";
|
||||
|
||||
const GROUP = "Docker";
|
||||
const on = (v: string | undefined) => v === "true";
|
||||
const isWindows = () => typeof navigator !== "undefined" && /win/i.test(navigator.platform);
|
||||
const isWindows = () => hostOs() === "windows";
|
||||
|
||||
/** One word for the platform shell the run executes the line with (`/bin/sh -c` or `cmd /C`). */
|
||||
export function shellQuote(word: string, windows = isWindows()): string {
|
||||
|
||||
@@ -124,7 +124,7 @@ export function recentMenu(project: RecentProject, onOpened?: () => void): MenuI
|
||||
label: "Open in New Window",
|
||||
icon: "folder-open",
|
||||
run: () =>
|
||||
void call(commands.windowOpenRemote(remote.sshConfig, remote.path, true))
|
||||
void call(commands.windowOpenRemote(remote.target, remote.path, true))
|
||||
.then(() => onOpened?.())
|
||||
.catch(() => undefined),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const openProject = vi.fn();
|
||||
const openRemoteProject = vi.fn();
|
||||
const UNC = String.raw`\\wsl.localhost\Ubuntu\home\me\app`;
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: {
|
||||
...real.commands,
|
||||
wslResolve: async (p: string) =>
|
||||
p === UNC ? { target: { kind: "wsl", distro: "Ubuntu" }, path: "/home/me/app" } : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/platform/project", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/project")>();
|
||||
return { ...real, openProject: (p: string) => openProject(p) };
|
||||
});
|
||||
vi.mock("@/workbench/projectWindow", () => ({
|
||||
openRemoteProject: (...a: unknown[]) => openRemoteProject(...a),
|
||||
}));
|
||||
|
||||
const { openRecent } = await import("./recent");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
|
||||
beforeEach(() => {
|
||||
openProject.mockReset();
|
||||
openRemoteProject.mockReset().mockResolvedValue(true);
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
});
|
||||
|
||||
describe("openRecent", () => {
|
||||
it("opens a legacy WSL UNC recent through the distro, not as a local folder", async () => {
|
||||
expect(await openRecent({ path: UNC, name: "app" })).toBe(true);
|
||||
expect(openRemoteProject).toHaveBeenCalledWith(
|
||||
{ kind: "wsl", distro: "Ubuntu" },
|
||||
"/home/me/app",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still opens a plain local recent locally", async () => {
|
||||
await openRecent({ path: "/p/local", name: "local" });
|
||||
expect(openProject).toHaveBeenCalledWith("/p/local");
|
||||
expect(openRemoteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,26 @@
|
||||
/** Recent projects: loading, filtering, opening and removal. */
|
||||
import { create } from "zustand";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { commands, IpcError, isCancelled, isTauri, type RecentProject, type RecentProjectGroup } from "@/platform/ipc";
|
||||
import {
|
||||
commands,
|
||||
IpcError,
|
||||
isCancelled,
|
||||
isTauri,
|
||||
type RecentProject,
|
||||
type RecentProjectGroup,
|
||||
type RemoteTarget,
|
||||
} from "@/platform/ipc";
|
||||
import { openProject, useProject } from "@/platform/project";
|
||||
import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote";
|
||||
import {
|
||||
connectRemote,
|
||||
forgetRemote,
|
||||
parseRemoteLocation,
|
||||
recentRemotes,
|
||||
remoteLocation,
|
||||
resolveRemoteLocation,
|
||||
sameTarget,
|
||||
targetName,
|
||||
} from "@/platform/remote";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { fuzzyMatch } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -64,7 +81,7 @@ export async function loadRecent() {
|
||||
/** The recent remote projects (Remote Development) among the local ones, most recent first. */
|
||||
function withRemotes(local: RecentProject[]): RecentProject[] {
|
||||
const remote: RecentProject[] = recentRemotes().map((r) => ({
|
||||
path: remoteLocation(r.sshConfig, r.path),
|
||||
path: remoteLocation(r.target, r.path),
|
||||
name: r.name,
|
||||
openedAtMs: r.openedAtMs,
|
||||
branch: null,
|
||||
@@ -77,7 +94,8 @@ function withRemotes(local: RecentProject[]): RecentProject[] {
|
||||
export function remoteDisplay(location: string): string | null {
|
||||
const r = parseRemoteLocation(location);
|
||||
if (!r) return null;
|
||||
const host = recentRemotes().find((x) => x.sshConfig === r.sshConfig && x.path === r.path)?.host || r.sshConfig;
|
||||
const host =
|
||||
recentRemotes().find((x) => sameTarget(x.target, r.target) && x.path === r.path)?.host || targetName(r.target);
|
||||
return `${host}:${r.path}`;
|
||||
}
|
||||
|
||||
@@ -88,7 +106,7 @@ export async function removeRecent(path: string) {
|
||||
}));
|
||||
const remote = parseRemoteLocation(path);
|
||||
if (remote) {
|
||||
forgetRemote(remote.sshConfig, remote.path);
|
||||
forgetRemote(remote.target, remote.path);
|
||||
return;
|
||||
}
|
||||
if (!isTauri()) return;
|
||||
@@ -107,7 +125,7 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const remote = parseRemoteLocation(project.path);
|
||||
const remote = await resolveRemoteLocation(project.path);
|
||||
if (remote) return openRemote(project, remote);
|
||||
const current = useProject.getState().project;
|
||||
if (current?.root === project.path) return true;
|
||||
@@ -139,12 +157,12 @@ export async function openRecent(project: Pick<RecentProject, "path" | "name">):
|
||||
|
||||
async function openRemote(
|
||||
project: Pick<RecentProject, "path" | "name">,
|
||||
remote: { sshConfig: string; path: string },
|
||||
remote: { target: RemoteTarget; path: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await openRemoteProject(remote.sshConfig, remote.path, async () => {
|
||||
return await openRemoteProject(remote.target, remote.path, async () => {
|
||||
useRecent.setState({ opening: project.path });
|
||||
await connectRemote(remote.sshConfig, remote.path);
|
||||
await connectRemote(remote.target, remote.path);
|
||||
});
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return false;
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const openProject = vi.fn();
|
||||
const connectRemote = vi.fn();
|
||||
const UNC = String.raw`\\wsl.localhost\Ubuntu\home\me\app`;
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: { ...real.commands, settingsGet: async () => ({ general: { reopenLastProject: true } }) },
|
||||
commands: {
|
||||
...real.commands,
|
||||
settingsGet: async () => ({ general: { reopenLastProject: true } }),
|
||||
wslResolve: async (p: string) =>
|
||||
p === UNC ? { target: { kind: "wsl", distro: "Ubuntu" }, path: "/home/me/app" } : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@/platform/remote", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/remote")>();
|
||||
return { ...real, connectRemote: (t: unknown, p: string) => connectRemote(t, p) };
|
||||
});
|
||||
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) }));
|
||||
vi.mock("@/platform/project", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/project")>();
|
||||
@@ -21,6 +32,7 @@ const { provideStartupLaunch, useProject } = await import("@/platform/project");
|
||||
|
||||
beforeEach(() => {
|
||||
openProject.mockReset();
|
||||
connectRemote.mockReset();
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("openidea.session.openProject", "/p/last");
|
||||
@@ -67,4 +79,12 @@ describe("reopenLastProject", () => {
|
||||
await reopenLastProject();
|
||||
expect(openProject).toHaveBeenCalledWith("/p/last");
|
||||
});
|
||||
|
||||
it("restores a session saved as a WSL UNC folder through the distro", async () => {
|
||||
localStorage.setItem("openidea.session.openProject", UNC);
|
||||
provideStartupLaunch(Promise.resolve());
|
||||
await reopenLastProject();
|
||||
expect(connectRemote).toHaveBeenCalledWith({ kind: "wsl", distro: "Ubuntu" }, "/home/me/app");
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { create } from "zustand";
|
||||
import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc";
|
||||
import { openProject, startupLaunchHandled, useProject } from "@/platform/project";
|
||||
import { connectRemote, parseRemoteLocation, projectLocation } from "@/platform/remote";
|
||||
import { connectRemote, projectLocation, resolveRemoteLocation } from "@/platform/remote";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
const SESSION_KEY = "openidea.session.openProject";
|
||||
@@ -117,8 +117,8 @@ async function reopen(path: string) {
|
||||
const name = path.slice(path.lastIndexOf("/") + 1) || path;
|
||||
useSession.setState({ reopening: path });
|
||||
try {
|
||||
const remote = parseRemoteLocation(path);
|
||||
if (remote) await connectRemote(remote.sshConfig, remote.path);
|
||||
const remote = await resolveRemoteLocation(path);
|
||||
if (remote) await connectRemote(remote.target, remote.path);
|
||||
else await openProject(path);
|
||||
} catch (e) {
|
||||
write(SESSION_KEY, null);
|
||||
|
||||
@@ -248,7 +248,7 @@ describe("catalogs", () => {
|
||||
it("exist for exactly the supported locales", () => {
|
||||
expect(translated.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(translations).sort()).toEqual(translated);
|
||||
expect(Object.keys(texts).sort()).toEqual(translated);
|
||||
expect(Object.keys(texts).sort()).toEqual([...SUPPORTED_LOCALES].sort());
|
||||
});
|
||||
|
||||
it("translate source text into non-empty text", () => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Connect through:": "Connect through:",
|
||||
"Connect through": "Connect through",
|
||||
"WSL distribution:": "WSL distribution:",
|
||||
"WSL distribution": "WSL distribution",
|
||||
"Project runs on {label}": "Project runs on {label}",
|
||||
"Forwarding ports is not supported in a WSL project yet": "Forwarding ports is not supported in a WSL project yet"
|
||||
}
|
||||
@@ -4470,6 +4470,12 @@
|
||||
"Remote Development": "远程开发",
|
||||
"The project runs on the remote host; the IDE backend is installed there on first use.": "项目在远程主机上运行,首次使用时会在该主机上安装 IDE 后端。",
|
||||
"SSH configuration:": "SSH 配置:",
|
||||
"Connect through:": "连接方式:",
|
||||
"Connect through": "连接方式",
|
||||
"WSL distribution:": "WSL 发行版:",
|
||||
"WSL distribution": "WSL 发行版",
|
||||
"Project runs on {label}": "项目运行于 {label}",
|
||||
"Forwarding ports is not supported in a WSL project yet": "WSL 项目暂不支持端口转发",
|
||||
"No SSH configurations": "没有 SSH 配置",
|
||||
"Project directory:": "项目目录:",
|
||||
"Remote Development…": "远程开发…",
|
||||
|
||||
@@ -108,6 +108,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"loopvar,rangefunc",
|
||||
// SSH and run-target placeholders
|
||||
"SSH",
|
||||
"WSL",
|
||||
"example.com",
|
||||
"java",
|
||||
"node",
|
||||
|
||||
+81
-28
@@ -8,9 +8,18 @@ import { confirmProjectTrust } from "./projectTrust";
|
||||
import { Channel, type InvokeArgs, type InvokeOptions } from "@tauri-apps/api/core";
|
||||
import { invokeLocal, setInvokeRoute, type InvokeRoute } from "./tauriCore";
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri, type ChannelFrame, type RemoteProgress, type RemoteSession } from "./ipc";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
isTauri,
|
||||
type ChannelFrame,
|
||||
type RemoteProgress,
|
||||
type RemoteSession,
|
||||
type RemoteTarget,
|
||||
} from "./ipc";
|
||||
import { isClientSetting, overlayClientSettings } from "./clientSettings";
|
||||
import type { Settings } from "./ipc";
|
||||
import { usePlatform } from "./platformInfo";
|
||||
import { useProject } from "./project";
|
||||
|
||||
interface RemoteState {
|
||||
@@ -21,6 +30,7 @@ export const useRemote = create<RemoteState>(() => ({ session: null }));
|
||||
|
||||
let routed = new Set<string>();
|
||||
let nextChannel = 1;
|
||||
const BUILD_MODEL_COMMANDS = new Set(["build_model", "build_sync", "gradle_link_projects", "gradle_unlink_projects"]);
|
||||
|
||||
/**
|
||||
* The arguments with each `Channel` replaced by `{ $channel: id }`, and the
|
||||
@@ -48,7 +58,12 @@ async function remoteInvoke(cmd: string, args?: InvokeArgs, options?: InvokeOpti
|
||||
const onChannel = new Channel<ChannelFrame>();
|
||||
onChannel.onmessage = (frame) => channels.get(frame.id)?.onmessage(JSON.parse(frame.data));
|
||||
const result = await invokeLocal<string>("remote_invoke", { cmd, args: json, onChannel }, options);
|
||||
return JSON.parse(result);
|
||||
const value = JSON.parse(result);
|
||||
// Released servers predating independent Gradle builds return a single build.
|
||||
if (BUILD_MODEL_COMMANDS.has(cmd) && value?.gradle && !Array.isArray(value.gradle)) {
|
||||
value.gradle = [value.gradle];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +134,11 @@ export function isRemoteWindow(): boolean {
|
||||
return useRemote.getState().session !== null;
|
||||
}
|
||||
|
||||
/** The OS this window's commands run on: the remote server's, else this desktop's (`usePlatform`). */
|
||||
export function hostOs(): string {
|
||||
return useRemote.getState().session?.os ?? usePlatform.getState().os;
|
||||
}
|
||||
|
||||
/** Re-attaches after a reload (the backend keeps the connection per window). */
|
||||
export async function restoreRemote(): Promise<RemoteSession | null> {
|
||||
if (!isTauri()) return null;
|
||||
@@ -133,19 +153,19 @@ export const useRemoteProgress = create<{ progress: RemoteProgress | null; locat
|
||||
location: null,
|
||||
}));
|
||||
|
||||
/** Opens `path` on the host of SSH configuration `sshConfig` in this window. */
|
||||
export async function connectRemote(sshConfig: string, path: string) {
|
||||
/** Opens `path` of `target` (an SSH host or a WSL distribution) in this window. */
|
||||
export async function connectRemote(target: RemoteTarget, path: string) {
|
||||
useProject.setState({ opening: true });
|
||||
const onProgress = new Channel<RemoteProgress>();
|
||||
onProgress.onmessage = (progress) => useRemoteProgress.setState({ progress });
|
||||
useRemoteProgress.setState({
|
||||
progress: { stage: "connecting", fraction: null },
|
||||
location: remoteLocation(sshConfig, path),
|
||||
location: remoteLocation(target, path),
|
||||
});
|
||||
try {
|
||||
const r = await call(commands.remoteConnect(sshConfig, path, onProgress));
|
||||
enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands });
|
||||
if (takeLegacyRemoteTrust(r.sshConfig, path, r.host)) {
|
||||
const r = await call(commands.remoteConnect(target, path, onProgress));
|
||||
enableRemote({ target: r.target, root: r.root, host: r.host, os: r.os, commands: r.commands });
|
||||
if (takeLegacyRemoteTrust(r.target, path, r.host)) {
|
||||
const status = await call(commands.projectTrustStatus(r.root));
|
||||
if (!status.decided) await call(commands.projectTrustSet(r.root, true, false));
|
||||
}
|
||||
@@ -161,7 +181,7 @@ export async function connectRemote(sshConfig: string, path: string) {
|
||||
r.project.settings = await clientSettings(r.project.settings);
|
||||
useProject.setState({ project: r.project, settings: r.project.settings });
|
||||
rememberRemote({
|
||||
sshConfig: r.sshConfig,
|
||||
target: r.target,
|
||||
path: r.root,
|
||||
name: r.project.name,
|
||||
host: r.host,
|
||||
@@ -182,32 +202,54 @@ export async function disconnectRemote() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote project locations: `ssh://<configuration id>/<absolute path>`, what the
|
||||
// session and the recent projects remember for a remote window.
|
||||
// Remote project locations: `ssh://<configuration id>/<absolute path>` or
|
||||
// `wsl://<distribution>/<absolute path>`, what the session and the recent projects
|
||||
// remember for a remote window.
|
||||
|
||||
const SCHEME = "ssh://";
|
||||
const SCHEMES = { ssh: "ssh://", wsl: "wsl://" } as const;
|
||||
|
||||
export function remoteLocation(sshConfig: string, path: string): string {
|
||||
return `${SCHEME}${encodeURIComponent(sshConfig)}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
/** The SSH configuration id or the WSL distribution name. */
|
||||
export function targetName(target: RemoteTarget): string {
|
||||
return target.kind === "ssh" ? target.config : target.distro;
|
||||
}
|
||||
|
||||
export function parseRemoteLocation(location: string): { sshConfig: string; path: string } | null {
|
||||
if (!location.startsWith(SCHEME)) return null;
|
||||
const rest = location.slice(SCHEME.length);
|
||||
export function sameTarget(a: RemoteTarget, b: RemoteTarget): boolean {
|
||||
return a.kind === b.kind && targetName(a) === targetName(b);
|
||||
}
|
||||
|
||||
export function remoteLocation(target: RemoteTarget, path: string): string {
|
||||
return `${SCHEMES[target.kind]}${encodeURIComponent(targetName(target))}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
}
|
||||
|
||||
export function parseRemoteLocation(location: string): { target: RemoteTarget; path: string } | null {
|
||||
const kind = (Object.keys(SCHEMES) as (keyof typeof SCHEMES)[]).find((k) => location.startsWith(SCHEMES[k]));
|
||||
if (!kind) return null;
|
||||
const rest = location.slice(SCHEMES[kind].length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return { sshConfig: decodeURIComponent(rest.slice(0, slash)), path: rest.slice(slash) };
|
||||
const name = decodeURIComponent(rest.slice(0, slash));
|
||||
const target: RemoteTarget = kind === "ssh" ? { kind, config: name } : { kind, distro: name };
|
||||
return { target, path: rest.slice(slash) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote project a saved location stands for: a `ssh://` / `wsl://` location, or a
|
||||
* `\\wsl.localhost\<distro>\…` folder remembered before WSL projects existed (it is a WSL
|
||||
* project, not a network folder). `null` for a plain local path.
|
||||
*/
|
||||
export async function resolveRemoteLocation(location: string): Promise<{ target: RemoteTarget; path: string } | null> {
|
||||
return parseRemoteLocation(location) ?? (await commands.wslResolve(location).catch(() => null));
|
||||
}
|
||||
|
||||
/** The location of this window's project: remote, else its local root. */
|
||||
export function projectLocation(root: string | null | undefined): string | null {
|
||||
if (!root) return null;
|
||||
const session = useRemote.getState().session;
|
||||
return session && session.root === root ? remoteLocation(session.sshConfig, root) : root;
|
||||
return session && session.root === root ? remoteLocation(session.target, root) : root;
|
||||
}
|
||||
|
||||
export interface RecentRemote {
|
||||
sshConfig: string;
|
||||
target: RemoteTarget;
|
||||
path: string;
|
||||
name: string;
|
||||
/** `user@host:port` when it was opened. */
|
||||
@@ -230,12 +272,23 @@ export function initializeRemoteTrustMigration() {
|
||||
}
|
||||
}
|
||||
|
||||
export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: string): boolean {
|
||||
/** An entry as stored: before WSL it carried `sshConfig` instead of `target`. */
|
||||
function normalize(entry: RecentRemote & { sshConfig?: string }): RecentRemote | null {
|
||||
if (!entry || !entry.path) return null;
|
||||
const target = entry.target ?? (entry.sshConfig ? { kind: "ssh" as const, config: entry.sshConfig } : null);
|
||||
return target ? { ...entry, target } : null;
|
||||
}
|
||||
|
||||
/** Whether `path` on SSH `target` was remembered before the trust prompt existed (WSL never was). */
|
||||
export function takeLegacyRemoteTrust(target: RemoteTarget, path: string, host: string): boolean {
|
||||
if (target.kind !== "ssh") return false;
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(TRUST_MIGRATION_KEY) ?? "[]");
|
||||
if (!Array.isArray(value)) return false;
|
||||
const matches = (entry: RecentRemote) =>
|
||||
entry?.sshConfig === sshConfig && entry.path === path && entry.host === host;
|
||||
const matches = (stored: RecentRemote) => {
|
||||
const entry = normalize(stored);
|
||||
return entry !== null && sameTarget(entry.target, target) && entry.path === path && entry.host === host;
|
||||
};
|
||||
const found = value.some(matches);
|
||||
localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(value.filter((entry) => !matches(entry))));
|
||||
return found;
|
||||
@@ -247,7 +300,7 @@ export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: str
|
||||
export function recentRemotes(): RecentRemote[] {
|
||||
try {
|
||||
const list = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]") as unknown;
|
||||
return Array.isArray(list) ? (list as RecentRemote[]).filter((r) => r && r.sshConfig && r.path) : [];
|
||||
return Array.isArray(list) ? list.map(normalize).filter((r): r is RecentRemote => r !== null) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -261,15 +314,15 @@ function saveRemotes(list: RecentRemote[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const sameRemote = (a: Pick<RecentRemote, "sshConfig" | "path">, b: Pick<RecentRemote, "sshConfig" | "path">) =>
|
||||
a.sshConfig === b.sshConfig && a.path === b.path;
|
||||
const sameRemote = (a: Pick<RecentRemote, "target" | "path">, b: Pick<RecentRemote, "target" | "path">) =>
|
||||
sameTarget(a.target, b.target) && a.path === b.path;
|
||||
|
||||
export function rememberRemote(entry: RecentRemote) {
|
||||
saveRemotes([entry, ...recentRemotes().filter((r) => !sameRemote(r, entry))]);
|
||||
}
|
||||
|
||||
export function forgetRemote(sshConfig: string, path: string) {
|
||||
saveRemotes(recentRemotes().filter((r) => !sameRemote(r, { sshConfig, path })));
|
||||
export function forgetRemote(target: RemoteTarget, path: string) {
|
||||
saveRemotes(recentRemotes().filter((r) => !sameRemote(r, { target, path })));
|
||||
}
|
||||
|
||||
// Any way of leaving the remote project (Close Project, opening a local one) ends remote routing.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CLIENT_SETTINGS_KEYS } from "./clientSettings";
|
||||
import { commands, call, type Settings } from "./ipc";
|
||||
import { loadSettings, useProject } from "./project";
|
||||
import { connectRemote, disableRemote, enableRemote, isRemoteWindow, remoteRouting } from "./remote";
|
||||
import { invoke as routedInvoke } from "./tauriCore";
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, () => void>());
|
||||
vi.mock("./ipc", async (original) => ({
|
||||
@@ -41,7 +42,13 @@ beforeEach(() => {
|
||||
cmd === "remote_invoke" ? JSON.stringify(remote) : local,
|
||||
);
|
||||
Object.assign(window, { __TAURI_INTERNALS__: { invoke, transformCallback: () => 1 } });
|
||||
enableRemote({ sshConfig: "test", root: "/remote", host: "test", commands: [...served] });
|
||||
enableRemote({
|
||||
target: { kind: "ssh", config: "test" },
|
||||
root: "/remote",
|
||||
host: "test",
|
||||
os: "linux",
|
||||
commands: [...served],
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
disableRemote();
|
||||
@@ -49,6 +56,33 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("remote client settings", () => {
|
||||
it.each([
|
||||
{ gradle: { dir: "app", projects: [] }, expected: [{ dir: "app", projects: [] }] },
|
||||
{
|
||||
gradle: [
|
||||
{ dir: "one", projects: [] },
|
||||
{ dir: "two", projects: [] },
|
||||
],
|
||||
expected: [
|
||||
{ dir: "one", projects: [] },
|
||||
{ dir: "two", projects: [] },
|
||||
],
|
||||
},
|
||||
{ gradle: null, expected: null },
|
||||
])("accepts the released and current remote Gradle build models: $gradle", async ({ gradle, expected }) => {
|
||||
for (const command of ["build_model", "build_sync", "gradle_link_projects", "gradle_unlink_projects"]) {
|
||||
enableRemote({
|
||||
target: { kind: "wsl", distro: "Ubuntu" },
|
||||
root: "/remote",
|
||||
host: "Ubuntu",
|
||||
os: "linux",
|
||||
commands: [command],
|
||||
});
|
||||
invoke.mockResolvedValue(JSON.stringify({ gradle, maven: null }));
|
||||
expect(await routedInvoke(command)).toEqual({ gradle: expected, maven: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("ends remote routing before a successful local open is published, even at the same path", async () => {
|
||||
invoke.mockResolvedValueOnce({ root: "/remote", settings: local });
|
||||
await call(commands.openProject("/remote"));
|
||||
@@ -91,7 +125,7 @@ describe("remote client settings", () => {
|
||||
invoke.mockImplementation(async (cmd: string) =>
|
||||
cmd === "remote_connect"
|
||||
? {
|
||||
sshConfig: "test",
|
||||
target: { kind: "ssh", config: "test" },
|
||||
root: "/remote",
|
||||
host: "test",
|
||||
commands: [...served],
|
||||
@@ -101,7 +135,7 @@ describe("remote client settings", () => {
|
||||
? "null"
|
||||
: local,
|
||||
);
|
||||
await connectRemote("test", "/remote");
|
||||
await connectRemote({ kind: "ssh", config: "test" }, "/remote");
|
||||
expect(useProject.getState().settings).toMatchObject({ keymap: "my-map", editor: { tabSize: 2, fontSize: 19 } });
|
||||
});
|
||||
it("overlays the project snapshot used by the settings window", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Where a project opens when this window already has one: System Settings › Project › "Open project in". */
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { call, commands, type RemoteTarget } from "@/platform/ipc";
|
||||
import { confirmLeaveProject, useProject } from "@/platform/project";
|
||||
import { connectRemote, isRemoteWindow } from "@/platform/remote";
|
||||
import { ask } from "./messages";
|
||||
@@ -50,20 +50,20 @@ export async function chooseProjectWindow(name: string, allowAttach = true): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens folder `path` on the host of SSH configuration `sshConfig`: focuses the
|
||||
* Opens folder `path` of `target` (an SSH host or a WSL distribution): focuses the
|
||||
* window that has it, else opens it in a new window or (`here`) this one per
|
||||
* "Open project in". `false`: cancelled.
|
||||
*/
|
||||
export async function openRemoteProject(
|
||||
sshConfig: string,
|
||||
target: RemoteTarget,
|
||||
path: string,
|
||||
here: () => Promise<unknown> = () => connectRemote(sshConfig, path),
|
||||
here: () => Promise<unknown> = () => connectRemote(target, path),
|
||||
): Promise<boolean> {
|
||||
if ((await call(commands.windowOpenRemote(sshConfig, path, false))).kind === "focused") return true;
|
||||
if ((await call(commands.windowOpenRemote(target, path, false))).kind === "focused") return true;
|
||||
const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path, false);
|
||||
if (!mode) return false;
|
||||
if (mode === "new") {
|
||||
await call(commands.windowOpenRemote(sshConfig, path, true));
|
||||
await call(commands.windowOpenRemote(target, path, true));
|
||||
return true;
|
||||
}
|
||||
if (!(await confirmLeaveProject())) return false;
|
||||
|
||||
Reference in New Issue
Block a user