mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
feat(remote): reopen a local project in its dev container (#1843)
Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI. Co-authored-by: andreabasile <[email protected]>
This commit is contained in:
1 parent
7479b7ad9d
commit
60d7dcdca0
29 files changed
+908
-69
No files matched your search
@@ -443,7 +443,7 @@ pub fn compose_op(
|
||||
|
||||
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
|
||||
/// is this machine's folder. The CLI's default daemon and a context are looked
|
||||
/// up (`DOCKER_HOST`, else the context's endpoint).
|
||||
/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context).
|
||||
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let url = match ep {
|
||||
DockerEndpoint::Socket { path } => path.trim().to_owned(),
|
||||
@@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
let host = host.trim();
|
||||
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
|
||||
}
|
||||
DockerEndpoint::Default => {
|
||||
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
|
||||
Some(url) if !url.is_empty() => url,
|
||||
_ => context_host(ep, None)?,
|
||||
}
|
||||
}
|
||||
DockerEndpoint::Default => default_daemon_url(
|
||||
ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()),
|
||||
ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()),
|
||||
|name| context_host(&DockerEndpoint::Default, name),
|
||||
)?,
|
||||
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
|
||||
};
|
||||
Ok(daemon_url_is_local(&url))
|
||||
}
|
||||
|
||||
fn default_daemon_url(
|
||||
context: Option<&str>,
|
||||
host: Option<&str>,
|
||||
lookup: impl FnOnce(Option<&str>) -> IdeResult<String>,
|
||||
) -> IdeResult<String> {
|
||||
let context = context.filter(|v| !v.is_empty());
|
||||
if context.is_some() {
|
||||
return lookup(context);
|
||||
}
|
||||
match host.filter(|v| !v.is_empty()) {
|
||||
Some(host) => Ok(host.to_owned()),
|
||||
None => lookup(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon URL of context `name` (the current one with `None`).
|
||||
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
|
||||
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
|
||||
@@ -506,6 +520,34 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR
|
||||
mod local_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_selected_context_overrides_docker_host() {
|
||||
let local = "unix:///var/run/docker.sock";
|
||||
let remote = "ssh://me@build";
|
||||
for host in [None, Some(""), Some(local)] {
|
||||
let url = default_daemon_url(Some("build"), host, |name| {
|
||||
assert_eq!(name, Some("build"));
|
||||
Ok(remote.into())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!daemon_url_is_local(&url));
|
||||
}
|
||||
for context in [None, Some("")] {
|
||||
assert_eq!(
|
||||
default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(),
|
||||
local
|
||||
);
|
||||
assert_eq!(
|
||||
default_daemon_url(context, None, |name| {
|
||||
assert_eq!(name, None);
|
||||
Ok(remote.into())
|
||||
})
|
||||
.unwrap(),
|
||||
remote
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_daemon_urls() {
|
||||
for url in [
|
||||
|
||||
@@ -78,6 +78,11 @@ async fn run(base: PathBuf) -> i32 {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV)
|
||||
&& let Err(e) = host.trust.inherit(std::path::Path::new(&root))
|
||||
{
|
||||
tracing::warn!("cannot trust {}: {e}", root.to_string_lossy());
|
||||
}
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
let mut dispatcher = Dispatcher::standard();
|
||||
if let Some(plugins) = &plugins {
|
||||
|
||||
@@ -22,6 +22,10 @@ use std::time::Duration;
|
||||
|
||||
type Changes = Vec<(OsString, Option<OsString>)>;
|
||||
|
||||
/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands
|
||||
/// (`devcontainer up`): the server then doesn't run `postStartCommand` again.
|
||||
pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE";
|
||||
|
||||
/// What Reload Environment changed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -72,7 +76,7 @@ impl Environment {
|
||||
return None;
|
||||
}
|
||||
let config = self.load_project(root, sink);
|
||||
let post_start = config.and_then(|c| c.post_start);
|
||||
let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none());
|
||||
let first = self.state.lock().post_started.insert(root.to_path_buf());
|
||||
post_start.filter(|_| first)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,14 @@ pub struct TrustDecisions {
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
|
||||
inherited: Mutex<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
/// Set by the desktop when it starts the server in a dev container of a folder the user trusts
|
||||
/// there: the project folder in the container, trusted without asking again. Never persisted.
|
||||
pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT";
|
||||
|
||||
impl TrustStore {
|
||||
pub fn load(config: &Path, data: &Path) -> Self {
|
||||
let file = data.join("project-trust.json");
|
||||
@@ -34,7 +40,7 @@ impl TrustStore {
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
Self { file, state: Mutex::new(state), inherited: Mutex::default() }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
@@ -45,7 +51,19 @@ impl TrustStore {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let decisions = self.decisions()?;
|
||||
Ok(!decisions.restricted.contains(&root)
|
||||
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
|
||||
&& (decisions.projects.contains(&root)
|
||||
|| decisions.locations.iter().any(|p| root.starts_with(p))
|
||||
|| self.inherited.lock().contains(&root)))
|
||||
}
|
||||
|
||||
/// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]).
|
||||
pub fn inherit(&self, path: &Path) -> IdeResult<()> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let mut inherited = self.inherited.lock();
|
||||
if !inherited.contains(&root) {
|
||||
inherited.push(root);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
|
||||
@@ -211,6 +211,24 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inherited_folder_is_trusted_for_this_process_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (config, data) = (tmp.path().join("config"), tmp.path().join("data"));
|
||||
let root = tmp.path().join("workspaces/app");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&root).unwrap());
|
||||
trust.inherit(&root).unwrap();
|
||||
assert!(trust.trusted(&root).unwrap());
|
||||
assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder");
|
||||
assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again");
|
||||
assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded");
|
||||
assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted");
|
||||
trust.decide(&root, false, false).unwrap();
|
||||
assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn script(root: &Path, name: &str, marker: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Validated results of the Dev Containers CLI.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The container `devcontainer up` started, as it reported it.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Up {
|
||||
pub container: String,
|
||||
/// The user the IDE works as (`remoteUser`).
|
||||
pub user: String,
|
||||
/// The project folder in the container (`remoteWorkspaceFolder`).
|
||||
pub folder: String,
|
||||
}
|
||||
|
||||
/// The JSON line `devcontainer up` ends with.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpResult {
|
||||
outcome: String,
|
||||
container_id: Option<String>,
|
||||
remote_user: Option<String>,
|
||||
remote_workspace_folder: Option<String>,
|
||||
message: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
fn is_container_id(id: &str) -> bool {
|
||||
(12..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
/// A user name or `uid[:gid]`, never something `docker exec` could read as an option.
|
||||
fn is_user(user: &str) -> bool {
|
||||
!user.is_empty()
|
||||
&& !user.starts_with('-')
|
||||
&& user.len() <= 64
|
||||
&& user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':'))
|
||||
}
|
||||
|
||||
fn is_container_path(path: &str) -> bool {
|
||||
path.starts_with('/') && !path.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Reads the result of `devcontainer up` from its stdout (the last JSON line).
|
||||
pub fn parse_up(stdout: &str) -> IdeResult<Up> {
|
||||
let line = stdout.lines().rev().map(str::trim).find(|l| l.starts_with('{'));
|
||||
let result: UpResult = line
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
.ok_or_else(|| IdeError::unavailable("devcontainer up did not report a result"))?;
|
||||
if result.outcome != "success" {
|
||||
let why = [result.message, result.description].into_iter().flatten().collect::<Vec<_>>().join(": ");
|
||||
return Err(IdeError::unavailable(format!("devcontainer up failed: {why}")));
|
||||
}
|
||||
let container = result.container_id.filter(|id| is_container_id(id));
|
||||
let user = result.remote_user.filter(|u| is_user(u));
|
||||
let folder = result.remote_workspace_folder.filter(|f| is_container_path(f));
|
||||
match (container, user, folder) {
|
||||
(Some(container), Some(user), Some(folder)) => Ok(Up { container, user, folder }),
|
||||
_ => Err(IdeError::unavailable("devcontainer up reported an unexpected container, user or folder")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ID: &str = "3f1c2b9a7d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8";
|
||||
|
||||
fn result(fields: &str) -> String {
|
||||
format!("[1 ms] Start: Run: docker build\n{{\"outcome\":\"success\",{fields}}}\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_container_from_the_last_json_line() {
|
||||
let out =
|
||||
result(&format!(r#""containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/app""#));
|
||||
let up = parse_up(&out).unwrap();
|
||||
assert_eq!(up, Up { container: ID.into(), user: "vscode".into(), folder: "/workspaces/app".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_reports_the_cli_message() {
|
||||
let out = r#"{"outcome":"error","message":"Command failed: docker build","description":"An error occurred"}"#;
|
||||
let err = parse_up(out).unwrap_err();
|
||||
assert!(err.message.contains("Command failed: docker build: An error occurred"), "{}", err.message);
|
||||
assert!(parse_up("no json at all").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_values_never_reach_docker_exec() {
|
||||
for fields in [
|
||||
r#""containerId":"--privileged","remoteUser":"vscode","remoteWorkspaceFolder":"/w""#.to_owned(),
|
||||
format!(r#""containerId":"{ID}","remoteUser":"-u0","remoteWorkspaceFolder":"/w""#),
|
||||
format!(r#""containerId":"{ID}","remoteUser":"a b","remoteWorkspaceFolder":"/w""#),
|
||||
format!(r#""containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"relative""#),
|
||||
format!(r#""containerId":"{ID}","remoteUser":"vscode""#),
|
||||
] {
|
||||
assert!(parse_up(&result(&fields)).is_err(), "{fields}");
|
||||
}
|
||||
assert!(is_user("1000:1000") && is_user("node") && is_user("dev.user"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
|
||||
|
||||
pub mod deploy;
|
||||
pub mod devcontainer;
|
||||
pub mod docker;
|
||||
pub mod import;
|
||||
pub mod mapping;
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum ToolKind {
|
||||
/// Loaded into another tool's process, e.g. a jdtls agent.
|
||||
Plugin,
|
||||
Formatter,
|
||||
/// A command the IDE itself runs, e.g. the Dev Containers CLI.
|
||||
Cli,
|
||||
}
|
||||
|
||||
/// Where release archives come from.
|
||||
@@ -112,7 +114,7 @@ impl ToolSpec {
|
||||
|
||||
use Arch::{Arm64, X64};
|
||||
use Os::{Linux, Mac, Windows};
|
||||
use ToolKind::{Debugger, Formatter, LanguageServer, Plugin};
|
||||
use ToolKind::{Cli, Debugger, Formatter, LanguageServer, Plugin};
|
||||
|
||||
const V: Option<(&str, &[&str])> = None;
|
||||
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
|
||||
@@ -140,6 +142,17 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
needs: NODE,
|
||||
homepage: "https://prettier.io",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "devcontainer",
|
||||
display: "Dev Containers CLI",
|
||||
kind: Cli,
|
||||
commands: &["devcontainer"],
|
||||
method: Method::Npm { packages: &["@devcontainers/cli"] },
|
||||
fallback: None,
|
||||
version: ver("devcontainer"),
|
||||
needs: NODE,
|
||||
homepage: "https://github.com/devcontainers/cli",
|
||||
},
|
||||
ToolSpec {
|
||||
name: "oxfmt",
|
||||
display: "oxfmt",
|
||||
@@ -1044,8 +1057,8 @@ mod tests {
|
||||
known.insert((d.adapter.clone(), d.command.clone()));
|
||||
}
|
||||
}
|
||||
// The host selects the formatter of each file (Prettier or oxfmt).
|
||||
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) {
|
||||
// The host selects the formatter of each file (Prettier or oxfmt) and runs its own CLIs.
|
||||
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter | Cli)) {
|
||||
assert!(
|
||||
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
|
||||
"{} / {} is not referenced by languages/*.toml",
|
||||
|
||||
@@ -22,6 +22,7 @@ mod page_swipe;
|
||||
mod plugin_web;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod relaunch;
|
||||
mod remote_container;
|
||||
mod remote_dev;
|
||||
mod remote_wsl;
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("file.renameProject", "Rename Project…"),
|
||||
SEP,
|
||||
act("remote.connect", "Remote Development…"),
|
||||
act("remote.reopenInContainer", "Reopen in Container"),
|
||||
act("remote.disconnect", "Disconnect from Remote Host"),
|
||||
act("remote.reloadEnvironment", "Reload Environment"),
|
||||
SEP,
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Dev container transport of remote development: the Dev Containers CLI (`devcontainer up`)
|
||||
//! builds or starts the container of a local folder, then `openidea-server --stdio` runs in it
|
||||
//! through `docker exec`, installed there the way it is in a WSL distribution.
|
||||
//!
|
||||
//! The container id, user and folder come from the CLI's output, are checked, and only ever
|
||||
//! reach `docker exec` as separate arguments; only fixed scripts reach `sh -c`.
|
||||
|
||||
use crate::remote_dev::{Progress, RemoteStage, SERVER_BIN};
|
||||
use crate::remote_wsl::{Probe, Tail, install_script, parse_probe, probe_script, upload, version_dir};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use ide_remote::devcontainer::{Up, parse_up};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
/// Building an image (and its features) can take a while on a first open.
|
||||
const UP_TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The Dev Containers CLI: the IDE-managed copy (Settings › Tools) or one on `PATH`.
|
||||
fn cli(data_dir: &Path) -> IdeResult<PathBuf> {
|
||||
ide_tools::Tools::new(data_dir)
|
||||
.info("devcontainer", &[])
|
||||
.ok()
|
||||
.and_then(|t| t.path)
|
||||
.map(ide_core::IpcPath::into_path_buf)
|
||||
.ok_or_else(|| {
|
||||
IdeError::unavailable(
|
||||
"The Dev Containers CLI is not installed. Install it in Settings › Tools › Dev Containers CLI.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `devcontainer up` for `folder` (with configuration `config`, relative to it; empty = the
|
||||
/// default one). Its log goes to `tail` and the IDE log.
|
||||
async fn up(cli: &Path, docker: &Path, folder: &Path, config: &str, tail: &Tail) -> IdeResult<Up> {
|
||||
let mut cmd = tokio::process::Command::new(cli);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.arg("up").arg("--workspace-folder").arg(folder);
|
||||
if !config.trim().is_empty() {
|
||||
cmd.arg("--config").arg(folder.join(config.trim()));
|
||||
}
|
||||
cmd.arg("--docker-path")
|
||||
.arg(docker)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot start devcontainer: {e}")))?;
|
||||
let stderr = child.stderr.take();
|
||||
let log = tail.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
let Some(stderr) = stderr else { return };
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::info!(target: "devcontainer", "{line}");
|
||||
log.push(line);
|
||||
}
|
||||
});
|
||||
let out = tokio::time::timeout(UP_TIMEOUT, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("devcontainer up did not finish in time"))?
|
||||
.map_err(|e| IdeError::unavailable(format!("devcontainer up failed: {e}")))?;
|
||||
let _ = reader.await;
|
||||
let result = parse_up(&String::from_utf8_lossy(&out.stdout));
|
||||
if out.status.success() {
|
||||
return result;
|
||||
}
|
||||
let error = result
|
||||
.err()
|
||||
.unwrap_or_else(|| IdeError::unavailable(format!("devcontainer up exited with {status}", status = out.status)));
|
||||
Err(match tail.text() {
|
||||
t if t.is_empty() => error,
|
||||
t => IdeError::unavailable(format!("{}\n{t}", error.message)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `docker exec [-i] -u <user> <container> sh -c <script> sh <args>…`.
|
||||
fn sh(docker: &Path, up: &Up, script: &str, args: &[&str], input: bool) -> tokio::process::Command {
|
||||
let mut cmd = tokio::process::Command::new(docker);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.arg("exec");
|
||||
if input {
|
||||
cmd.arg("-i");
|
||||
}
|
||||
cmd.args(["-u", &up.user, &up.container, "sh", "-c", script, "sh"])
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
cmd
|
||||
}
|
||||
|
||||
fn failure(out: &std::process::Output) -> IdeError {
|
||||
let text = String::from_utf8_lossy(if out.stderr.is_empty() { &out.stdout } else { &out.stderr });
|
||||
let text: String = text.trim().chars().take(400).collect();
|
||||
IdeError::unavailable(format!("cannot run a command in the dev container: {text}"))
|
||||
}
|
||||
|
||||
async fn probe(docker: &Path, up: &Up, version: &str) -> IdeResult<Probe> {
|
||||
let out = tokio::time::timeout(PROBE_TIMEOUT, sh(docker, up, &probe_script(), &[version], false).output())
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("the dev container did not answer in time"))?
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot start docker: {e}")))?;
|
||||
if !out.status.success() {
|
||||
return Err(failure(&out));
|
||||
}
|
||||
parse_probe(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Makes sure this version of the server is installed in the container; returns its path there.
|
||||
async fn ensure_server(
|
||||
state: &crate::state::AppState,
|
||||
docker: &Path,
|
||||
up: &Up,
|
||||
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(docker, up, version).await?;
|
||||
let (os, arch) = platform(&probe.uname);
|
||||
if os != "linux" {
|
||||
return Err(IdeError::unavailable(format!("the dev container 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());
|
||||
}
|
||||
install(docker, up, &version_dir(&probe.home, version), &build, &local, progress).await
|
||||
}
|
||||
|
||||
/// Copies the server at `local` into `<version_dir>/<build>/openidea-server` in the container and
|
||||
/// removes the other builds of the version. Returns the installed path.
|
||||
async fn install(
|
||||
docker: &Path,
|
||||
up: &Up,
|
||||
version_dir: &str,
|
||||
build: &str,
|
||||
local: &Path,
|
||||
progress: &Arc<Progress>,
|
||||
) -> IdeResult<String> {
|
||||
progress(RemoteStage::Uploading, Some(0.0));
|
||||
let data = tokio::fs::read(local).await?;
|
||||
let build_dir = format!("{version_dir}/{build}");
|
||||
let cmd = sh(docker, up, &install_script(), &[&build_dir, version_dir, build], true);
|
||||
let out = upload(cmd, "docker", &data, progress)
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in the dev container timed out")))??;
|
||||
if out.status.success() { Ok(format!("{build_dir}/{SERVER_BIN}")) } else { Err(failure(&out)) }
|
||||
}
|
||||
|
||||
/// The arguments of `docker` that start the server in the container: in the project folder, told
|
||||
/// that the folder is trusted and that the CLI already ran the lifecycle commands.
|
||||
fn server_args(up: &Up, bin: &str) -> Vec<String> {
|
||||
let mut args: Vec<String> = ["exec", "-i", "-u", &up.user, "-w", &up.folder].map(str::to_owned).into();
|
||||
for var in [
|
||||
format!("{}={}", ide_host::trust::TRUSTED_ROOT_ENV, up.folder),
|
||||
format!("{}=1", ide_host::environment::LIFECYCLE_DONE_ENV),
|
||||
] {
|
||||
args.extend(["-e".to_owned(), var]);
|
||||
}
|
||||
args.extend([up.container.clone(), bin.to_owned(), "--stdio".to_owned()]);
|
||||
args
|
||||
}
|
||||
|
||||
/// Opens the dev container of local `folder`: starts it with the CLI, installs and starts the
|
||||
/// server in it. Returns the connection and the project folder in the container.
|
||||
pub(crate) async fn connect(
|
||||
state: &crate::state::AppState,
|
||||
folder: &str,
|
||||
config: &str,
|
||||
handler: Arc<dyn Handler>,
|
||||
tail: &Tail,
|
||||
progress: &Arc<Progress>,
|
||||
) -> IdeResult<(Peer, String)> {
|
||||
let local = PathBuf::from(folder);
|
||||
// `devcontainer up` runs the project's code on this machine (`initializeCommand`, the build).
|
||||
if !state.host.trust.trusted(&local).unwrap_or(false) {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"Trust {} before reopening it in a container: building it runs the project's commands.",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
if ide_workspace::devcontainer::find(&local).is_none() && config.trim().is_empty() {
|
||||
return Err(IdeError::not_found(format!("{} has no devcontainer.json", local.display())));
|
||||
}
|
||||
let docker = ide_docker::docker_executable().ok_or_else(|| IdeError::unavailable("Docker is not installed"))?;
|
||||
progress(RemoteStage::Container, None);
|
||||
// The folder is bind-mounted: the daemon has to see this machine's files.
|
||||
let is_local = tokio::task::spawn_blocking(|| ide_docker::endpoint_is_local(&ide_docker::DockerEndpoint::Default))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if !is_local {
|
||||
return Err(IdeError::unavailable(
|
||||
"The Docker daemon of the current context is not on this machine; dev containers need a local one.",
|
||||
));
|
||||
}
|
||||
let data = state.host.local_data_dir.clone();
|
||||
let cli =
|
||||
tokio::task::spawn_blocking(move || cli(&data)).await.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
let up = up(&cli, &docker, &local, config, tail).await?;
|
||||
let bin = ensure_server(state, &docker, &up, progress).await?;
|
||||
progress(RemoteStage::Starting, None);
|
||||
let peer = spawn(&docker, &up, &bin, handler, tail)?;
|
||||
Ok((peer, up.folder))
|
||||
}
|
||||
|
||||
fn spawn(docker: &Path, up: &Up, bin: &str, handler: Arc<dyn Handler>, tail: &Tail) -> IdeResult<Peer> {
|
||||
let tail = tail.clone();
|
||||
let program = docker.to_string_lossy();
|
||||
Peer::spawn(&program, &server_args(up, bin), &std::env::temp_dir(), &[], handler, Box::new(move |l| tail.push(l)))
|
||||
.map_err(|e| IdeError::unavailable(format!("cannot start docker: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ID: &str = "3f1c2b9a7d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8";
|
||||
|
||||
#[test]
|
||||
fn the_server_starts_in_the_folder_trusted_and_without_the_lifecycle() {
|
||||
let up = Up { container: ID.into(), user: "vscode".into(), folder: "/workspaces/my app".into() };
|
||||
let args = server_args(&up, "/home/vscode/.openidea/server/1/ab/openidea-server");
|
||||
assert_eq!(
|
||||
args,
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
"-u",
|
||||
"vscode",
|
||||
"-w",
|
||||
"/workspaces/my app",
|
||||
"-e",
|
||||
"OPENIDEA_TRUSTED_ROOT=/workspaces/my app",
|
||||
"-e",
|
||||
"OPENIDEA_LIFECYCLE_DONE=1",
|
||||
ID,
|
||||
"/home/vscode/.openidea/server/1/ab/openidea-server",
|
||||
"--stdio",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A stand-in for the Dev Containers CLI that checks its arguments and answers like the real one.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn runs_up_for_the_folder_and_reads_its_result() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let folder = dir.path().join("my app");
|
||||
let cli = dir.path().join("devcontainer");
|
||||
let script = format!(
|
||||
r#"#!/bin/sh
|
||||
[ "$1 $2 $3" = "up --workspace-folder {folder}" ] || {{ echo "unexpected arguments: $*" >&2; exit 2; }}
|
||||
[ "$4 $5" = "--config {folder}/.devcontainer/python/devcontainer.json" ] || {{ echo "no config: $*" >&2; exit 2; }}
|
||||
[ "$6 $7" = "--docker-path /usr/bin/docker" ] || {{ echo "no docker path: $*" >&2; exit 2; }}
|
||||
echo "[1 ms] building" >&2
|
||||
echo '{{"outcome":"success","containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/my app"}}'
|
||||
"#,
|
||||
folder = folder.display()
|
||||
);
|
||||
std::fs::write(&cli, script).unwrap();
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let tail = Tail::default();
|
||||
let config = ".devcontainer/python/devcontainer.json";
|
||||
let up = up(&cli, Path::new("/usr/bin/docker"), &folder, config, &tail).await.unwrap_or_else(|e| {
|
||||
panic!("{e:?}; the CLI said: {}", tail.text());
|
||||
});
|
||||
assert_eq!(up.folder, "/workspaces/my app");
|
||||
assert_eq!(tail.text(), "[1 ms] building");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_failed_up_shows_the_cli_log() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cli = dir.path().join("devcontainer");
|
||||
std::fs::write(&cli, "#!/bin/sh\necho 'Error: Dockerfile not found' >&2\nexit 1\n").unwrap();
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let err = up(&cli, Path::new("docker"), dir.path(), "", &Tail::default()).await.unwrap_err();
|
||||
assert!(err.message.contains("Dockerfile not found"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_failed_exit_never_connects_even_with_a_success_result() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cli = dir.path().join("devcontainer");
|
||||
let output =
|
||||
format!(r#"{{"outcome":"success","containerId":"{ID}","remoteUser":"root","remoteWorkspaceFolder":"/w"}}"#);
|
||||
std::fs::write(&cli, format!("#!/bin/sh\ncat <<'RESULT'\n{output}\nRESULT\nexit 1\n")).unwrap();
|
||||
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let err = up(&cli, Path::new("docker"), dir.path(), "", &Tail::default()).await.unwrap_err();
|
||||
assert!(err.message.contains("exited with"), "{}", err.message);
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_sh(docker: &Path, container: &str, script: &str) -> String {
|
||||
let out = std::process::Command::new(docker).args(["exec", container, "sh", "-c", script]).output().unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
}
|
||||
|
||||
/// Against a real daemon and the real Dev Containers CLI (`devcontainer` on `PATH`).
|
||||
/// `OPENIDEA_DOCKER_TEST_DIR` names a folder the daemon can bind-mount at the same path, and
|
||||
/// `OPENIDEA_SERVER_BIN` an `openidea-server` built for the container's platform.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Docker, the Dev Containers CLI, OPENIDEA_DOCKER_TEST_DIR and OPENIDEA_SERVER_BIN"]
|
||||
async fn opens_a_real_dev_container() {
|
||||
let base = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
|
||||
let server = PathBuf::from(std::env::var("OPENIDEA_SERVER_BIN").expect("OPENIDEA_SERVER_BIN"));
|
||||
let root = base.join("devcontainer");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join(".devcontainer")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".devcontainer/devcontainer.json"),
|
||||
r#"{
|
||||
// The CLI runs it once; the server must not run it again.
|
||||
"image": "debian:bookworm-slim",
|
||||
"postStartCommand": "date +%s%N >> /tmp/post-start",
|
||||
"remoteEnv": { "FROM_DEVCONTAINER": "yes" }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("main.py"), "print('hello')\n").unwrap();
|
||||
let docker = ide_docker::docker_executable().expect("docker");
|
||||
let tail = Tail::default();
|
||||
let up = up(Path::new("devcontainer"), &docker, &root, "", &tail)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{e:?}; the CLI said: {}", tail.text()));
|
||||
assert_eq!(up.folder, "/workspaces/devcontainer");
|
||||
assert_eq!(up.user, "root");
|
||||
let found = probe(&docker, &up, "0.0.0-test").await.unwrap();
|
||||
assert_eq!(platform_os(&found.uname), "linux");
|
||||
assert!(found.installed.is_empty());
|
||||
let progress: Arc<Progress> = Arc::new(|_, _| {});
|
||||
let dir = version_dir(&found.home, "0.0.0-test");
|
||||
let bin = install(&docker, &up, &dir, "b1", &server, &progress).await.unwrap();
|
||||
assert_eq!(probe(&docker, &up, "0.0.0-test").await.unwrap().installed, std::slice::from_ref(&bin));
|
||||
let peer = spawn(&docker, &up, &bin, Arc::new(Silent), &tail).unwrap();
|
||||
let info: ide_host::rpc::ServerInfo = peer
|
||||
.request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{e:?}; the server said: {}", tail.text()));
|
||||
assert_eq!(info.os, "linux");
|
||||
let invoke =
|
||||
|cmd: &str, args: serde_json::Value| ide_host::rpc::Invoke { window: "main".into(), cmd: cmd.into(), args };
|
||||
let project: serde_json::Value = peer
|
||||
.request("invoke", invoke("open_project", serde_json::json!({ "path": up.folder })), None)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{e:?}; the server said: {}", tail.text()));
|
||||
assert_eq!(project["root"], "/workspaces/devcontainer");
|
||||
assert_eq!(project["trusted"], true, "the trust of the local folder is inherited");
|
||||
let files: serde_json::Value = peer
|
||||
.request("invoke", invoke("fs_read_dir", serde_json::json!({ "path": up.folder })), None)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("{e:?}; the server said: {}", tail.text()));
|
||||
assert!(files.to_string().contains("main.py"), "the project is the local folder: {files}");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert_eq!(docker_sh(&docker, &up.container, "wc -l < /tmp/post-start"), "1", "postStartCommand ran once");
|
||||
drop(peer);
|
||||
let removed = std::process::Command::new(&docker).args(["rm", "-f", &up.container]).output().unwrap();
|
||||
assert!(removed.status.success());
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
fn platform_os(uname: &str) -> String {
|
||||
crate::remote_dev::platform(uname).0
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ pub enum RemoteTarget {
|
||||
Ssh { config: String },
|
||||
/// A WSL distribution on this machine.
|
||||
Wsl { distro: String },
|
||||
/// The dev container of local folder `folder`, built and started with the Dev Containers CLI;
|
||||
/// `config` is its `devcontainer.json`, relative to the folder (empty = the default one).
|
||||
DevContainer { folder: String, config: String },
|
||||
}
|
||||
|
||||
/// Where the server is installed on the remote host (per version).
|
||||
@@ -66,7 +69,7 @@ pub struct Connection {
|
||||
/// connection with its parent).
|
||||
channels: Channels,
|
||||
next_channel: std::sync::atomic::AtomicU64,
|
||||
/// Keeps the SSH connection alive; `None` for WSL.
|
||||
/// Keeps the SSH connection alive; `None` for WSL and dev containers.
|
||||
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)>>,
|
||||
@@ -110,7 +113,11 @@ impl Connections {
|
||||
self.0
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|(_, c)| c.target == *target && c.root.trim_end_matches('/') == root)
|
||||
// A dev container is one project: its folder there is only known once connected.
|
||||
.find(|(_, c)| {
|
||||
c.target == *target
|
||||
&& (matches!(target, RemoteTarget::DevContainer { .. }) || c.root.trim_end_matches('/') == root)
|
||||
})
|
||||
.map(|(label, _)| label.clone())
|
||||
}
|
||||
|
||||
@@ -131,6 +138,8 @@ impl Connections {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RemoteStage {
|
||||
Connecting,
|
||||
/// `devcontainer up`: building or starting the container.
|
||||
Container,
|
||||
Checking,
|
||||
Downloading,
|
||||
Uploading,
|
||||
@@ -403,15 +412,16 @@ fn build_id(path: &std::path::Path) -> IdeResult<String> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// File › Remote Development: opens `path` on `target` (an SSH host or a WSL distribution)
|
||||
/// in this window (installs / starts the server as needed).
|
||||
/// File › Remote Development: opens `path` on `target` (an SSH host, a WSL distribution, or a
|
||||
/// dev container, whose project folder is the container's own) in this window (installs /
|
||||
/// starts the server as needed).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_connect(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
target: RemoteTarget,
|
||||
path: String,
|
||||
mut path: String,
|
||||
on_progress: Channel<RemoteProgress>,
|
||||
) -> IdeResult<RemoteProject> {
|
||||
let progress: Arc<Progress> = Arc::new(move |stage, fraction| {
|
||||
@@ -419,8 +429,6 @@ pub async fn remote_connect(
|
||||
});
|
||||
progress(RemoteStage::Connecting, None);
|
||||
let label = AppState::owner_window(window.label()).to_owned();
|
||||
state.remote_dev.disconnect(&label);
|
||||
state.host.close_project(&label);
|
||||
let channels: Channels = Arc::default();
|
||||
let handler =
|
||||
Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() });
|
||||
@@ -444,6 +452,15 @@ pub async fn remote_connect(
|
||||
let peer = crate::remote_wsl::spawn(distro, &bin, handler, &tail)?;
|
||||
(peer, None, distro.clone(), format!("WSL: {distro}"))
|
||||
}
|
||||
RemoteTarget::DevContainer { folder, config } => {
|
||||
let (peer, root) =
|
||||
crate::remote_container::connect(&state, folder, config, handler, &tail, &progress).await?;
|
||||
path = root;
|
||||
let name = std::path::Path::new(folder)
|
||||
.file_name()
|
||||
.map_or_else(|| folder.clone(), |n| n.to_string_lossy().into_owned());
|
||||
(peer, None, name.clone(), format!("Dev Container: {name}"))
|
||||
}
|
||||
};
|
||||
// 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() {
|
||||
@@ -490,6 +507,8 @@ pub async fn remote_connect(
|
||||
session,
|
||||
forwards: Mutex::new(Vec::new()),
|
||||
});
|
||||
state.remote_dev.disconnect(&label);
|
||||
state.host.close_project(&label);
|
||||
state.remote_dev.0.lock().insert(label.clone(), connection.clone());
|
||||
let plugins = state.plugins.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -767,4 +786,12 @@ mod tests {
|
||||
assert_eq!(platform("Darwin arm64"), ("macos".into(), "aarch64".into()));
|
||||
assert_eq!(platform("Linux aarch64"), ("linux".into(), "aarch64".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dev_container_target_is_its_local_folder_and_configuration() {
|
||||
let target = RemoteTarget::DevContainer { folder: "/Users/me/app".into(), config: String::new() };
|
||||
let json = serde_json::to_value(&target).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "kind": "devContainer", "folder": "/Users/me/app", "config": "" }));
|
||||
assert_eq!(serde_json::from_value::<RemoteTarget>(json).unwrap(), target);
|
||||
}
|
||||
}
|
||||
+46
-20
@@ -71,8 +71,15 @@ async fn output(distro: &str, mut cmd: tokio::process::Command, limit: Duration)
|
||||
if out.status.success() { Ok(out) } else { Err(failure(distro, &out)) }
|
||||
}
|
||||
|
||||
/// Prints [`PROBE_MARK`], `$HOME`, `uname -sm` and the installed builds of version `$1`.
|
||||
pub(crate) fn probe_script() -> String {
|
||||
format!(
|
||||
r#"echo {PROBE_MARK}; echo "$HOME"; uname -sm; ls -d "$HOME/{SERVER_DIR}/$1"/*/{SERVER_BIN} 2>/dev/null; true"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Splits the probe output (after its marker) into [`Probe`].
|
||||
fn parse_probe(text: &str) -> IdeResult<Probe> {
|
||||
pub(crate) 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());
|
||||
@@ -87,10 +94,7 @@ fn parse_probe(text: &str) -> IdeResult<Probe> {
|
||||
|
||||
/// 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?;
|
||||
let out = output(distro, sh(distro, &probe_script(), &[version])?, PROBE_TIMEOUT).await?;
|
||||
parse_probe(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
@@ -109,14 +113,37 @@ pub(crate) async fn install(
|
||||
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!(
|
||||
let cmd = sh(distro, &install_script(), &[&build_dir, version_dir, build])?;
|
||||
let out = upload(cmd, "wsl.exe", data, progress)
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in '{distro}' timed out")))??;
|
||||
if out.status.success() { Ok(format!("{build_dir}/{SERVER_BIN}")) } else { Err(failure(distro, &out)) }
|
||||
}
|
||||
|
||||
/// Reads the server from stdin into `$1/openidea-server` and removes the builds of version folder
|
||||
/// `$2` other than `$3`. Per install file: another window may be installing the same build.
|
||||
pub(crate) fn install_script() -> String {
|
||||
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])?;
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs `cmd` (an [`install_script`]) with `data` on its stdin, reporting the upload. `Err` when it
|
||||
/// timed out; else the finished command's output (or why it couldn't run), whatever its status.
|
||||
pub(crate) async fn upload(
|
||||
mut cmd: tokio::process::Command,
|
||||
program: &str,
|
||||
data: &[u8],
|
||||
progress: &Arc<Progress>,
|
||||
) -> Result<IdeResult<std::process::Output>, tokio::time::error::Elapsed> {
|
||||
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"))?;
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) => return Ok(Err(IdeError::unavailable(format!("cannot start {program}: {e}")))),
|
||||
};
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
return Ok(Err(IdeError::internal(format!("{program} 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() {
|
||||
@@ -125,17 +152,16 @@ pub(crate) async fn install(
|
||||
}
|
||||
stdin.flush().await
|
||||
};
|
||||
let result = tokio::time::timeout(INSTALL_TIMEOUT, async {
|
||||
tokio::time::timeout(INSTALL_TIMEOUT, async {
|
||||
let uploaded = upload.await;
|
||||
(uploaded, child.wait_with_output().await)
|
||||
let failed = |e: std::io::Error| IdeError::unavailable(format!("installing {SERVER_BIN} failed: {e}"));
|
||||
match (uploaded, child.wait_with_output().await) {
|
||||
// Never a success without the whole binary.
|
||||
(Err(e), Ok(out)) if out.status.success() => Err(failed(e)),
|
||||
(_, out) => out.map_err(failed),
|
||||
}
|
||||
})
|
||||
.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.
|
||||
@@ -143,7 +169,7 @@ pub(crate) async fn install(
|
||||
pub(crate) struct Tail(Arc<Mutex<VecDeque<String>>>);
|
||||
|
||||
impl Tail {
|
||||
fn push(&self, line: String) {
|
||||
pub fn push(&self, line: String) {
|
||||
let mut lines = self.0.lock();
|
||||
if lines.len() == KEPT_LINES {
|
||||
lines.pop_front();
|
||||
|
||||
@@ -112,7 +112,7 @@ pub struct FilePosition {
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// A project folder on an SSH host or in a WSL distribution.
|
||||
/// A project folder on an SSH host, in a WSL distribution or in a dev container.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteLaunch {
|
||||
@@ -819,7 +819,7 @@ pub enum Holds {
|
||||
distro: String,
|
||||
path: String,
|
||||
},
|
||||
/// An SSH remote project.
|
||||
/// An SSH or dev container remote project.
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -827,7 +827,7 @@ 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,
|
||||
RemoteTarget::Ssh { .. } | RemoteTarget::DevContainer { .. } => Holds::Other,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-6
@@ -1328,8 +1328,9 @@ 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 `target` (an SSH host or a WSL distribution)
|
||||
* in this window (installs / starts the server as needed).
|
||||
* File › Remote Development: opens `path` on `target` (an SSH host, a WSL distribution, or a
|
||||
* dev container, whose project folder is the container's own) in this window (installs /
|
||||
* starts the server as needed).
|
||||
*/
|
||||
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. */
|
||||
@@ -6663,7 +6664,7 @@ export type RemoteEntry = {
|
||||
permissions: string,
|
||||
};
|
||||
|
||||
/** A project folder on an SSH host or in a WSL distribution. */
|
||||
/** A project folder on an SSH host, in a WSL distribution or in a dev container. */
|
||||
export type RemoteLaunch = {
|
||||
target: RemoteTarget,
|
||||
path: string,
|
||||
@@ -6738,14 +6739,21 @@ export type RemoteSession = {
|
||||
};
|
||||
|
||||
/** Where [`remote_connect`] is (`fraction`: 0..1 of a download / upload). */
|
||||
export type RemoteStage = "connecting" | "checking" | "downloading" | "uploading" | "starting" | "opening";
|
||||
export type RemoteStage = "connecting" |
|
||||
/** `devcontainer up`: building or starting the container. */
|
||||
"container" | "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 };
|
||||
{ kind: "wsl"; distro: string } |
|
||||
/**
|
||||
* The dev container of local folder `folder`, built and started with the Dev Containers CLI;
|
||||
* `config` is its `devcontainer.json`, relative to the folder (empty = the default one).
|
||||
*/
|
||||
{ kind: "devContainer"; folder: string; config: string };
|
||||
|
||||
export type Renamed = {
|
||||
edit: WorkspaceEdit,
|
||||
@@ -7947,7 +7955,9 @@ export type ToolInfo = {
|
||||
|
||||
export type ToolKind = "languageServer" | "debugger" |
|
||||
/** Loaded into another tool's process, e.g. a jdtls agent. */
|
||||
"plugin" | "formatter";
|
||||
"plugin" | "formatter" |
|
||||
/** A command the IDE itself runs, e.g. the Dev Containers CLI. */
|
||||
"cli";
|
||||
|
||||
export type ToolSettings = {
|
||||
/** Executable to launch instead of the managed / `PATH` one; empty = auto-detect. */
|
||||
|
||||
@@ -4,9 +4,15 @@ import type { RemoteSession } from "@/platform/ipc";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { BarButton } from "@/ui";
|
||||
|
||||
const BADGE: Record<RemoteSession["target"]["kind"], string> = {
|
||||
ssh: "SSH",
|
||||
wsl: "WSL",
|
||||
devContainer: "Dev Container",
|
||||
};
|
||||
|
||||
/** 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}`;
|
||||
return `${BADGE[session.target.kind]}: ${session.host}`;
|
||||
}
|
||||
|
||||
export function RemoteStatus() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ open: vi.fn(), notify: vi.fn() }));
|
||||
vi.mock("@/workbench/projectWindow", () => ({ openRemoteProject: mocks.open }));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: mocks.notify }) }));
|
||||
|
||||
import { reopenInContainer } from "./container";
|
||||
|
||||
const project = (root: string) => ({ root, name: "app", trusted: true }) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useRemote.setState({ session: null });
|
||||
useProject.setState({ project: project("/Users/me/app") });
|
||||
});
|
||||
|
||||
it("opens the local project's dev container", async () => {
|
||||
mocks.open.mockResolvedValue(true);
|
||||
await reopenInContainer();
|
||||
expect(mocks.open).toHaveBeenCalledWith(
|
||||
{ kind: "devContainer", folder: "/Users/me/app", config: "" },
|
||||
"/Users/me/app",
|
||||
);
|
||||
expect(mocks.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing in a remote window or without a project", async () => {
|
||||
useRemote.setState({
|
||||
session: { target: { kind: "ssh", config: "a" }, root: "/srv/app", host: "a", os: "linux", commands: [] },
|
||||
});
|
||||
await reopenInContainer();
|
||||
useRemote.setState({ session: null });
|
||||
useProject.setState({ project: null });
|
||||
await reopenInContainer();
|
||||
expect(mocks.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports why the container could not be opened, but not a cancel", async () => {
|
||||
mocks.open.mockRejectedValueOnce(new Error("The Dev Containers CLI is not installed."));
|
||||
await reopenInContainer();
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", body: "The Dev Containers CLI is not installed." }),
|
||||
);
|
||||
mocks.notify.mockClear();
|
||||
mocks.open.mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Project opening cancelled" }));
|
||||
await reopenInContainer();
|
||||
expect(mocks.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Reopen in Container: the local project's dev container, opened as a remote project. */
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { isCancelled } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isRemoteWindow } from "@/platform/remote";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { openRemoteProject } from "@/workbench/projectWindow";
|
||||
|
||||
/**
|
||||
* Builds or starts the dev container of this window's local project (its `devcontainer.json`)
|
||||
* and opens the project there. The folder must be trusted: building it runs the project's commands.
|
||||
*/
|
||||
export async function reopenInContainer(): Promise<void> {
|
||||
const project = useProject.getState().project;
|
||||
if (!project || isRemoteWindow()) return;
|
||||
try {
|
||||
await openRemoteProject({ kind: "devContainer", folder: project.root, config: "" }, project.root);
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) return;
|
||||
notifications().notify({
|
||||
group: "remote",
|
||||
level: "error",
|
||||
title: tx("Could not reopen the project in a container"),
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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).
|
||||
* Remote development: a window whose project lives on an SSH host, in a WSL distribution or in a
|
||||
* dev container. 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";
|
||||
@@ -9,6 +9,7 @@ import { lazyComponent } from "@/ui";
|
||||
import { maybeEditorService, registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
|
||||
import { confirmLeaveProject } from "@/platform/project";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { reopenInContainer } from "./container";
|
||||
import { reloadEnvironment } from "./environment";
|
||||
import { RemoteStatus } from "./RemoteStatus";
|
||||
import { closeConnectDialog, openConnectDialog } from "./model";
|
||||
@@ -80,6 +81,17 @@ export function activate(): () => void {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote.reopenInContainer",
|
||||
title: "Reopen in Container",
|
||||
description:
|
||||
"Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.",
|
||||
category: "File",
|
||||
icon: "folder",
|
||||
keywords: ["devcontainer", "dev container", "docker", "container", "remote"],
|
||||
when: "projectOpen",
|
||||
run: () => reopenInContainer(),
|
||||
},
|
||||
{
|
||||
id: "remote.reloadEnvironment",
|
||||
title: "Reload Environment",
|
||||
|
||||
@@ -39,6 +39,8 @@ export function stageLabel(stage: RemoteStage): string {
|
||||
switch (stage) {
|
||||
case "connecting":
|
||||
return "Connecting to the host…";
|
||||
case "container":
|
||||
return "Starting the dev container…";
|
||||
case "checking":
|
||||
return "Checking the IDE backend on the host…";
|
||||
case "downloading":
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import { PortsView } from "./PortsView";
|
||||
import { parsePort, usePorts } from "./ports";
|
||||
import { forwardPort, parsePort, usePorts } from "./ports";
|
||||
|
||||
ipc.remoteForwardedPorts.mockImplementation(async () => [...forwarded]);
|
||||
ipc.remoteForwardPort.mockImplementation(async (remotePort: number) => {
|
||||
@@ -86,3 +86,20 @@ describe("restoring forwarded ports", () => {
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't forward ports of a dev container yet", async () => {
|
||||
const { useRemote } = await import("@/platform/remote");
|
||||
ipc.remoteForwardPort.mockClear();
|
||||
useRemote.setState({
|
||||
session: {
|
||||
target: { kind: "devContainer", folder: "/w/app", config: "" },
|
||||
root: "/workspaces/app",
|
||||
host: "app",
|
||||
os: "linux",
|
||||
commands: [],
|
||||
},
|
||||
});
|
||||
expect(await forwardPort(8080)).toBeNull();
|
||||
expect(ipc.remoteForwardPort).not.toHaveBeenCalled();
|
||||
useRemote.setState({ session: null });
|
||||
});
|
||||
@@ -53,11 +53,15 @@ export function parsePort(text: string): number | null {
|
||||
}
|
||||
|
||||
export async function forwardPort(port: number): Promise<ForwardedPort | null> {
|
||||
if (useRemote.getState().session?.target.kind === "wsl") {
|
||||
const kind = useRemote.getState().session?.target.kind;
|
||||
if (kind === "wsl" || kind === "devContainer") {
|
||||
notifications().notify({
|
||||
group: "remote",
|
||||
level: "error",
|
||||
title: tx("Forwarding ports is not supported in a WSL project yet"),
|
||||
title:
|
||||
kind === "wsl"
|
||||
? tx("Forwarding ports is not supported in a WSL project yet")
|
||||
: tx("Forwarding ports is not supported in a dev container yet"),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("remote folder browser", () => {
|
||||
|
||||
const ssh = (config: string) => ({ kind: "ssh", config }) as const;
|
||||
const wsl = (distro: string) => ({ kind: "wsl", distro }) as const;
|
||||
const container = (folder: string) => ({ kind: "devContainer", folder, config: "" }) as const;
|
||||
|
||||
describe("remote project locations", () => {
|
||||
it("round-trip the configuration id and the path", () => {
|
||||
@@ -66,6 +67,20 @@ describe("remote project locations", () => {
|
||||
expect(parseRemoteLocation("wsl://Ubuntu")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip a dev container's local folder and the path in the container", () => {
|
||||
const loc = remoteLocation(container("/Users/me/my app"), "/workspaces/my app");
|
||||
expect(loc).toBe("devcontainer://%2FUsers%2Fme%2Fmy%20app/workspaces/my app");
|
||||
expect(parseRemoteLocation(loc)).toEqual({ target: container("/Users/me/my app"), path: "/workspaces/my app" });
|
||||
});
|
||||
|
||||
it("round-trips container configurations and Windows folders with reserved characters", () => {
|
||||
const target = { ...container("C:\\work\\my app"), config: ".devcontainer/a:b?c/devcontainer.json" };
|
||||
expect(parseRemoteLocation(remoteLocation(target, "/workspaces/app"))).toEqual({
|
||||
target,
|
||||
path: "/workspaces/app",
|
||||
});
|
||||
});
|
||||
|
||||
it("remembers recent remote projects most recent first, once each", () => {
|
||||
localStorage.clear();
|
||||
rememberRemote({ target: ssh("a"), path: "/p", name: "p", host: "me@a", openedAtMs: 1 });
|
||||
@@ -85,6 +100,19 @@ describe("remote project locations", () => {
|
||||
expect(recentRemotes().map((r) => r.target)).toEqual([ssh("dev")]);
|
||||
});
|
||||
|
||||
it("keeps the dev containers of two configurations of a folder apart", () => {
|
||||
localStorage.clear();
|
||||
const python = { ...container("/w/app"), config: ".devcontainer/python/devcontainer.json" };
|
||||
rememberRemote({ target: container("/w/app"), path: "/workspaces/app", name: "app", host: "app", openedAtMs: 1 });
|
||||
rememberRemote({ target: python, path: "/workspaces/app", name: "app", host: "app", openedAtMs: 2 });
|
||||
expect(recentRemotes()).toHaveLength(2);
|
||||
const saved = remoteLocation(python, "/workspaces/app");
|
||||
expect(saved).not.toBe(remoteLocation(container("/w/app"), "/workspaces/app"));
|
||||
expect(parseRemoteLocation(saved)).toEqual({ target: python, path: "/workspaces/app" });
|
||||
forgetRemote(parseRemoteLocation(saved)!.target, "/workspaces/app");
|
||||
expect(recentRemotes().map((r) => r.target)).toEqual([container("/w/app")]);
|
||||
});
|
||||
|
||||
it("reads recents saved before WSL support, which carried `sshConfig`", () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(
|
||||
@@ -123,4 +151,5 @@ it("migrates a snapshot taken before WSL support, and never trusts a WSL distrib
|
||||
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");
|
||||
expect(remoteBadge({ target: container("/w/app"), host: "app" })).toBe("Dev Container: app");
|
||||
});
|
||||
@@ -40,6 +40,7 @@ const KIND_LABEL: Record<ToolInfo["kind"], string> = {
|
||||
debugger: "Debugger",
|
||||
plugin: "Plugin",
|
||||
formatter: "Formatter",
|
||||
cli: "Command-line tool",
|
||||
};
|
||||
|
||||
export function filterTools(tools: readonly ToolInfo[], query: string, filter: Filter): ToolInfo[] {
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
"menu.file.newProjectsSettings": "Settings for New Projects…",
|
||||
"menu.file.toggleReadOnly": "Make File Read-only / Writable",
|
||||
"menu.file.closeProject": "Close Project",
|
||||
"menu.remote.reopenInContainer": "Reopen in Container",
|
||||
"menu.remote.reloadEnvironment": "Reload Environment",
|
||||
"menu.file.new": "New…",
|
||||
"menu.file.newScratchFile": "New Scratch File…",
|
||||
|
||||
@@ -201,5 +201,12 @@
|
||||
"Only Python can be debugged in containers": "Only Python can be debugged in containers",
|
||||
"Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.": "Docker connections come from the Services tool window. Each run gets a new container, removed when it ends. Python configurations can be debugged there; coverage and profiling stay on this machine.",
|
||||
"Use Project JDK": "Use Project JDK",
|
||||
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence."
|
||||
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.",
|
||||
"Starting the dev container…": "Starting the dev container…",
|
||||
"Command-line tool": "Command-line tool",
|
||||
"Forwarding ports is not supported in a dev container yet": "Forwarding ports is not supported in a dev container yet",
|
||||
"Could not reopen the project in a container": "Could not reopen the project in a container",
|
||||
"Reopen in Container": "Reopen in Container",
|
||||
"Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.": "Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.",
|
||||
"devcontainer up exited with {status}": "devcontainer up exited with {status}"
|
||||
}
|
||||
@@ -6541,6 +6541,12 @@
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。",
|
||||
"CNB (China mirror)": "CNB(国内镜像)",
|
||||
"Remote connection cancelled": "远程连接已取消",
|
||||
"Starting the dev container…": "正在启动开发容器…",
|
||||
"Command-line tool": "命令行工具",
|
||||
"Forwarding ports is not supported in a dev container yet": "开发容器暂不支持端口转发",
|
||||
"Could not reopen the project in a container": "无法在容器中重新打开项目",
|
||||
"Reopen in Container": "在容器中重新打开",
|
||||
"Builds or starts the project's dev container (devcontainer.json) with the Dev Containers CLI and opens the project in it.": "使用 Dev Containers CLI 构建或启动项目的开发容器(devcontainer.json),并在其中打开项目。",
|
||||
"Xdebug is not loaded in PHP {version}": "PHP {version} 未加载 Xdebug",
|
||||
"{php} runs without Xdebug, so breakpoints are skipped. Install Xdebug 3 for this PHP or choose another PHP interpreter.": "{php} 运行时没有 Xdebug,断点会被跳过。请为此 PHP 安装 Xdebug 3,或选择其他 PHP 解释器。",
|
||||
"Open PHP Settings": "打开 PHP 设置",
|
||||
@@ -6579,5 +6585,6 @@
|
||||
"The container exited before debugpy was ready. Check the Run console for errors.": "容器在 debugpy 就绪前已退出。请检查运行控制台中的错误。",
|
||||
"debugpy did not become ready within 120 seconds. Check the Run console and the container's Python interpreter.": "debugpy 未在 120 秒内就绪。请检查运行控制台和容器的 Python 解释器。",
|
||||
"Use Project JDK": "使用项目 SDK",
|
||||
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "用于 Maven 同步、构建、目标和测试的 JDK。运行配置中显式设置的 JAVA_HOME 优先。"
|
||||
"JDK used for Maven sync, builds, goals and tests. An explicit JAVA_HOME in a run configuration takes precedence.": "用于 Maven 同步、构建、目标和测试的 JDK。运行配置中显式设置的 JAVA_HOME 优先。",
|
||||
"devcontainer up exited with {status}": "devcontainer up 已退出,状态为 {status}"
|
||||
}
|
||||
@@ -172,6 +172,7 @@
|
||||
"menu.file.newProjectsSettings": "新项目的设置…",
|
||||
"menu.file.toggleReadOnly": "将文件设为只读/可写",
|
||||
"menu.file.closeProject": "关闭项目",
|
||||
"menu.remote.reopenInContainer": "在容器中重新打开",
|
||||
"menu.remote.reloadEnvironment": "重新加载环境",
|
||||
"menu.file.new": "新建…",
|
||||
"menu.file.newScratchFile": "新建临时文件…",
|
||||
|
||||
+30
-11
@@ -154,7 +154,10 @@ export const useRemoteProgress = create<{ progress: RemoteProgress | null; locat
|
||||
location: null,
|
||||
}));
|
||||
|
||||
/** Opens `path` of `target` (an SSH host or a WSL distribution) in this window. */
|
||||
/**
|
||||
* Opens `path` of `target` (an SSH host or a WSL distribution) in this window. A dev container's
|
||||
* project folder is the one in the container: `path` is then only a label.
|
||||
*/
|
||||
export async function connectRemote(target: RemoteTarget, path: string) {
|
||||
useProject.setState({ opening: true });
|
||||
const location = remoteLocation(target, path);
|
||||
@@ -214,23 +217,35 @@ export async function disconnectRemote() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Remote project locations: `ssh://<configuration id>/<absolute path>`,
|
||||
// `wsl://<distribution>/<absolute path>` or `devcontainer://<local folder>[:<config>]/<container path>`,
|
||||
// what the session and the recent projects remember for a remote window.
|
||||
|
||||
const SCHEMES = { ssh: "ssh://", wsl: "wsl://" } as const;
|
||||
const SCHEMES = { ssh: "ssh://", wsl: "wsl://", devContainer: "devcontainer://" } as const;
|
||||
|
||||
/** The SSH configuration id or the WSL distribution name. */
|
||||
/** The SSH configuration id, the WSL distribution name or the dev container's local folder. */
|
||||
export function targetName(target: RemoteTarget): string {
|
||||
return target.kind === "ssh" ? target.config : target.distro;
|
||||
switch (target.kind) {
|
||||
case "ssh":
|
||||
return target.config;
|
||||
case "wsl":
|
||||
return target.distro;
|
||||
case "devContainer":
|
||||
return target.folder;
|
||||
}
|
||||
}
|
||||
|
||||
export function sameTarget(a: RemoteTarget, b: RemoteTarget): boolean {
|
||||
return a.kind === b.kind && targetName(a) === targetName(b);
|
||||
return (
|
||||
a.kind === b.kind &&
|
||||
targetName(a) === targetName(b) &&
|
||||
(a.kind !== "devContainer" || b.kind !== "devContainer" || a.config === b.config)
|
||||
);
|
||||
}
|
||||
|
||||
export function remoteLocation(target: RemoteTarget, path: string): string {
|
||||
return `${SCHEMES[target.kind]}${encodeURIComponent(targetName(target))}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
const config = target.kind === "devContainer" && target.config ? `:${encodeURIComponent(target.config)}` : "";
|
||||
return `${SCHEMES[target.kind]}${encodeURIComponent(targetName(target))}${config}${path.startsWith("/") ? "" : "/"}${path}`;
|
||||
}
|
||||
|
||||
export function parseRemoteLocation(location: string): { target: RemoteTarget; path: string } | null {
|
||||
@@ -239,8 +254,12 @@ export function parseRemoteLocation(location: string): { target: RemoteTarget; p
|
||||
const rest = location.slice(SCHEMES[kind].length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
const name = decodeURIComponent(rest.slice(0, slash));
|
||||
const target: RemoteTarget = kind === "ssh" ? { kind, config: name } : { kind, distro: name };
|
||||
const authority = rest.slice(0, slash);
|
||||
const colon = kind === "devContainer" ? authority.indexOf(":") : -1;
|
||||
const name = decodeURIComponent(colon < 0 ? authority : authority.slice(0, colon));
|
||||
const config = colon < 0 ? "" : decodeURIComponent(authority.slice(colon + 1));
|
||||
const target: RemoteTarget =
|
||||
kind === "ssh" ? { kind, config: name } : kind === "wsl" ? { kind, distro: name } : { kind, folder: name, config };
|
||||
return { target, path: rest.slice(slash) };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user