From 6dfd82de4ca6c00ba16d9808a067bfb16bec7225 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:21:42 +0800 Subject: [PATCH 01/27] fix(vue): Run Vue Language Server 3 in hybrid mode with a usable TypeScript (#46) Root cause: the tools dir installed typescript@latest, now TypeScript 7 (native port, no typescript.js/tsserver.js/ts.server), so @vue/language-server 3 crashed on ts.server.protocol and restarted forever; Volar 3 hybrid mode also needs tsserver/request forwarding, which we never answered. Fix: pin TS-as-library servers to typescript@<7 and keep shared deps on uninstall; pick a usable tsdk (setting / bundled / project, --tsdk); bridge tsserver/request to vtsls with @vue/typescript-plugin (null when vtsls is absent); answer workspace/configuration for section ""; stop restarting after 3 consecutive early crashes (IDEA); crash summary starts at the exception line. Siblings covered: typescript-language-server and svelte-language-server (same TS 7 break), vtsls config, install hints in language TOMLs. Verified: scoped cargo tests, real-server tests (vue-language-server 3.3.13 + vtsls), in-app on macOS (server running, no crash loop). Not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-lsp/src/handler.rs | 4 +- crates/ide-lsp/src/lib.rs | 1 + crates/ide-lsp/src/server.rs | 127 +++++++++++++++++++++++--- crates/ide-lsp/src/tsserver_bridge.rs | 93 +++++++++++++++++++ crates/ide-lsp/tests/real_servers.rs | 72 +++++++++++++++ crates/ide-sdk/src/apply.rs | 34 ++++++- crates/ide-sdk/src/lib.rs | 1 + crates/ide-sdk/src/tests.rs | 52 +++++++++++ crates/ide-sdk/src/vue.rs | 121 ++++++++++++++++++++++++ crates/ide-tools/src/lib.rs | 34 ++++++- crates/ide-tools/src/registry.rs | 10 +- crates/ide-tools/src/tests.rs | 48 ++++++++++ languages/javascript.toml | 2 +- languages/javascriptreact.toml | 2 +- languages/typescript.toml | 2 +- languages/typescriptreact.toml | 2 +- languages/vue.toml | 12 ++- 17 files changed, 590 insertions(+), 27 deletions(-) create mode 100644 crates/ide-lsp/src/tsserver_bridge.rs create mode 100644 crates/ide-sdk/src/vue.rs diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 7dbf5a90a..3e79608d4 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -128,7 +128,8 @@ impl ClientHandler { Value::Array( items .iter() - .map(|item| match item.get("section").and_then(Value::as_str) { + // vtsls asks for the whole configuration as section `""`. + .map(|item| match item.get("section").and_then(Value::as_str).filter(|s| !s.is_empty()) { Some(section) if section == self.server.name => options.clone(), // Servers that take settings under `initializationOptions.settings` // (jdtls) ask for the same sections later. @@ -193,6 +194,7 @@ impl Handler for ClientHandler { _ => tracing::debug!("[{}] {text}", self.server.name), } } + "tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params), "telemetry/event" | "$/logTrace" | "$/cancelRequest" => {} other => tracing::trace!("[{}] unhandled notification {other}", self.server.name), } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index ee171c363..bb46369d4 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -15,6 +15,7 @@ pub mod jdtls; mod launch; mod server; mod text; +mod tsserver_bridge; pub mod types; mod uri; mod virtual_doc; diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 4568ca442..43d5ba0b1 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -13,7 +13,8 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Notify, watch}; -const CRASH_WINDOW: Duration = Duration::from_secs(180); +/// An instance that stayed up this long ran fine: earlier crashes no longer count. +const STABLE_UPTIME: Duration = Duration::from_secs(180); const MAX_RESTARTS: usize = 3; const STDERR_LINES: usize = 50; @@ -66,7 +67,10 @@ pub struct Server { state: Mutex, phase: watch::Sender, status: Mutex<(ServerStatus, Option)>, - crashes: Mutex>, + /// Consecutive crashes of short-lived instances. + crashes: Mutex, + /// When the current instance was launched. + started: Mutex>, stderr: Mutex>, pub progress: Mutex>, diagnostics: Mutex>>, @@ -88,7 +92,8 @@ impl Server { state: Mutex::new(State { generation: 0, conn: Conn::Idle }), phase: watch::channel(Phase::Down).0, status: Mutex::new((ServerStatus::Stopped, None)), - crashes: Mutex::new(VecDeque::new()), + crashes: Mutex::new(0), + started: Mutex::new(None), stderr: Mutex::new(VecDeque::new()), progress: Mutex::new(HashMap::new()), diagnostics: Mutex::new(HashMap::new()), @@ -127,6 +132,7 @@ impl Server { } st.generation += 1; st.conn = Conn::Starting; + *self.started.lock() = Some(Instant::now()); self.phase.send_replace(Phase::Starting); Some(st.generation) } @@ -237,21 +243,22 @@ impl Server { } } - /// Records a crash; returns the backoff before restarting, or `None` when - /// the restart budget for the window is exhausted. + /// Records a crash; returns the backoff before restarting, or `None` once + /// [`MAX_RESTARTS`] instances in a row died before running [`STABLE_UPTIME`] + /// (a server that crashes on every start, however slowly, is not restarted forever). pub fn record_crash(&self) -> Option { + let uptime = self.started.lock().map(|t| t.elapsed()).unwrap_or_default(); let mut crashes = self.crashes.lock(); - let now = Instant::now(); - crashes.push_back(now); - while crashes.front().is_some_and(|t| now.duration_since(*t) > CRASH_WINDOW) { - crashes.pop_front(); + if uptime >= STABLE_UPTIME { + *crashes = 0; } - let n = crashes.len(); + *crashes += 1; + let n = *crashes; (n <= MAX_RESTARTS).then(|| Duration::from_millis(500) * (1 << (n - 1)) as u32) } pub fn reset_for_restart(&self) { - self.crashes.lock().clear(); + *self.crashes.lock() = 0; let mut st = self.state.lock(); if matches!(st.conn, Conn::Missing | Conn::Failed) { st.conn = Conn::Idle; @@ -300,8 +307,8 @@ impl Server { pub fn stderr_tail(&self, n: usize) -> String { let buf = self.stderr.lock(); - let skip = buf.len().saturating_sub(n); - buf.iter().skip(skip).cloned().collect::>().join("\n") + let lines: Vec<&str> = buf.iter().map(String::as_str).collect(); + crash_excerpt(&lines, n) } pub fn take_progress(&self) -> Vec<(TaskId, String)> { @@ -341,3 +348,97 @@ impl Server { self.diagnostics.lock().drain().map(|(p, _)| p).collect() } } + +/// The last `n` stderr lines, led by the exception headline: Node.js / Python / +/// JVM crashes print the error first and a stack after it, so a plain tail +/// shows frames without saying what went wrong. +pub(crate) fn crash_excerpt(lines: &[&str], n: usize) -> String { + const MAX_LINES: usize = 12; + let start = lines.len().saturating_sub(n); + let excerpt: Vec<&str> = match lines.iter().rposition(|l| is_error_headline(l)) { + Some(i) if i >= start => lines[start..].to_vec(), + Some(i) if lines.len() - i <= MAX_LINES => lines[i..].to_vec(), + Some(i) => [lines[i], "…"].into_iter().chain(lines[start..].iter().copied()).collect(), + None => lines[start..].to_vec(), + }; + excerpt.join("\n") +} + +fn is_error_headline(line: &str) -> bool { + let l = line.trim_start(); + let head = l.split(':').next().unwrap_or_default(); + let named = (head.ends_with("Error") || head.ends_with("Exception")) && !head.contains(' ') && head.len() < l.len(); + named || l.starts_with("Exception in thread") || l.starts_with("thread '") && l.contains("panicked") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn server() -> Server { + let cfg: LanguageServerConfig = toml::from_str("name = \"s\"\ncommand = \"s\"").unwrap(); + Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")) + } + + #[test] + fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() { + let s = server(); + for _ in 0..MAX_RESTARTS { + // Even crashes spread minutes apart count while each instance dies young. + *s.started.lock() = Some(Instant::now()); + assert!(s.record_crash().is_some()); + } + *s.started.lock() = Some(Instant::now()); + assert_eq!(s.record_crash(), None); + s.reset_for_restart(); + assert!(s.record_crash().is_some()); + } + + #[test] + fn a_stable_run_resets_the_budget() { + let s = server(); + for _ in 0..MAX_RESTARTS { + assert!(s.record_crash().is_some()); + } + *s.started.lock() = Instant::now().checked_sub(STABLE_UPTIME); + assert_eq!(s.record_crash(), Some(Duration::from_millis(500))); + } + + #[test] + fn crash_excerpt_keeps_the_exception_line() { + // Node.js 24 crash of vue-language-server (issue #25), verbatim shape. + let node = [ + "C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40", + " projectInfoPromise = sendTsServerRequest('_vue:' + ts.server.protocol.CommandTypes.ProjectInfo, {", + " ^", + "", + "TypeError: Cannot read properties of undefined (reading 'protocol')", + " at Object.getLanguageService (C:\\tools\\node_modules\\@vue\\language-server\\lib\\server.js:40:86)", + " at Immediate. (C:\\tools\\node_modules\\@volar\\language-server\\lib\\features\\languageFeatures.js:744:62)", + " at process.processImmediate (node:internal/timers:504:21)", + "", + "Node.js v24.12.0", + ]; + let out = crash_excerpt(&node, 5); + assert!(out.starts_with("TypeError: Cannot read properties of undefined (reading 'protocol')"), "{out}"); + assert!(out.ends_with("Node.js v24.12.0")); + + let python = [ + "Traceback (most recent call last):", + " File \"x.py\", line 1", + "ModuleNotFoundError: No module named 'jedi'", + ]; + assert_eq!(crash_excerpt(&python, 5), python.join("\n")); + + let mut long_stack = vec!["Exception in thread \"main\" java.lang.IllegalStateException: boom"]; + long_stack.extend(std::iter::repeat_n("\tat a.b.C.d(C.java:1)", 30)); + let out = crash_excerpt(&long_stack, 5); + assert!(out.starts_with("Exception in thread \"main\""), "{out}"); + assert_eq!(out.lines().count(), 7); + + let plain = ["starting", "listening", "bye"]; + assert_eq!(crash_excerpt(&plain, 2), "listening\nbye"); + // "Error" in prose is not a headline. + assert_eq!(crash_excerpt(&["No Error here: fine", "a", "b"], 2), "a\nb"); + } +} diff --git a/crates/ide-lsp/src/tsserver_bridge.rs b/crates/ide-lsp/src/tsserver_bridge.rs new file mode 100644 index 000000000..5997fb36d --- /dev/null +++ b/crates/ide-lsp/src/tsserver_bridge.rs @@ -0,0 +1,93 @@ +//! Vue Language Server (Volar 3) runs in "hybrid mode": TypeScript work goes to +//! tsserver with `@vue/typescript-plugin`, through the client. It sends +//! `tsserver/request` `[id, command, args]` and waits for `tsserver/response` +//! `[id, body]`; the client forwards the command to vtsls, whose tsserver has the +//! plugin loaded (`typescript.tsserverRequest`). Without an answer the server +//! waits forever, so every request is answered — `null` when vtsls can't. + +use crate::LspManager; +use crate::server::Server; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; + +/// The server that hosts tsserver for the bridge. +pub(crate) const TSSERVER_HOST: &str = "vtsls"; +const HOST_START_WAIT: Duration = Duration::from_secs(60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// `[id, command, args]` items of a `tsserver/request` notification +/// (vscode-jsonrpc wraps the single array argument: `[[id, command, args]]`). +pub(crate) fn parse_requests(params: &Value) -> Vec<(Value, String, Value)> { + let one = |v: &Value| { + let a = v.as_array()?; + let command = a.get(1)?.as_str()?.to_owned(); + Some((a.first()?.clone(), command, a.get(2).cloned().unwrap_or(Value::Null))) + }; + match params.as_array() { + Some(items) if items.first().is_some_and(Value::is_array) => items.iter().filter_map(one).collect(), + Some(_) => one(params).into_iter().collect(), + None => Vec::new(), + } +} + +impl LspManager { + /// Answers `tsserver/request`s of `from` (instance `generation`) via [`TSSERVER_HOST`]. + pub(crate) fn forward_tsserver_requests(&self, from: Arc, generation: u64, params: Value) { + let Some(me) = self.me.upgrade() else { return }; + for (id, command, args) in parse_requests(¶ms) { + let me = me.clone(); + let from = from.clone(); + self.runtime.spawn(async move { + let body = me.tsserver_request(&from, &command, args).await.unwrap_or(Value::Null); + if let Some(run) = from.running_gen(generation) + && let Err(e) = run.peer.notify("tsserver/response", json!([[id, body]])) + { + tracing::debug!("{}: tsserver/response: {e}", from.name); + } + }); + } + } + + async fn tsserver_request(&self, from: &Server, command: &str, args: Value) -> Option { + let host = self + .servers + .lock() + .iter() + .filter(|s| s.name == TSSERVER_HOST) + .min_by_key(|s| s.root != from.root) + .cloned()?; + let run = host.wait_ready(HOST_START_WAIT).await?; + let params = json!({ + "command": "typescript.tsserverRequest", + "arguments": [command, args, { "isAsync": true, "lowPriority": true }], + }); + let request = run.peer.request::<_, Value>("workspace/executeCommand", params, None); + match tokio::time::timeout(REQUEST_TIMEOUT, request).await { + Ok(Ok(result)) => result.get("body").cloned(), + Ok(Err(e)) => { + tracing::debug!("{TSSERVER_HOST}: {command}: {e}"); + None + } + Err(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_wrapped_and_bare_requests() { + let wrapped = json!([[1, "_vue:projectInfo", { "file": "/p/App.vue" }]]); + assert_eq!( + parse_requests(&wrapped), + vec![(json!(1), "_vue:projectInfo".to_owned(), json!({ "file": "/p/App.vue" }))] + ); + let bare = json!([2, "_vue:getComponentNames", ["/p/App.vue"]]); + assert_eq!(parse_requests(&bare), vec![(json!(2), "_vue:getComponentNames".to_owned(), json!(["/p/App.vue"]))]); + assert!(parse_requests(&json!({ "id": 1 })).is_empty()); + assert!(parse_requests(&json!([[3]])).is_empty()); + } +} diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 42f23597a..5c42f7fae 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -227,3 +227,75 @@ async fn typescript_language_server_end_to_end() { .await; tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); } + +const APP_VUE: &str = "\n\n\n"; + +fn npm_server(tools: &Path, name: &str, bin: &str, init: Option) -> LanguageServerConfig { + LanguageServerConfig { + name: name.into(), + command: tools.join("node_modules/.bin").join(bin).to_string_lossy().into_owned(), + args: vec!["--stdio".into()], + env: Default::default(), + root_markers: vec!["package.json".into()], + initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()), + install_hint: None, + fallback: false, + launch_error: None, + } +} + +/// Vue Language Server 3 in hybrid mode: its `tsserver/request`s go through the +/// client to vtsls with `@vue/typescript-plugin` (issue #25). Set +/// `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i @vue/language-server typescript@"<7" @vtsls/language-server`. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires @vue/language-server and @vtsls/language-server (OPENIDEA_TEST_NPM_TOOLS)"] +async fn vue_hybrid_mode_end_to_end() { + let tools = std::path::PathBuf::from(std::env::var("OPENIDEA_TEST_NPM_TOOLS").expect("OPENIDEA_TEST_NPM_TOOLS")); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::write(root.join("package.json"), "{\"name\":\"app\",\"private\":true}").unwrap(); + std::fs::write(root.join("tsconfig.json"), "{\"compilerOptions\":{\"strict\":true},\"include\":[\"src/**/*\"]}") + .unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + let file = root.join("src/App.vue"); + std::fs::write(&file, APP_VUE).unwrap(); + + let plugin = serde_json::json!({ "vtsls": { "tsserver": { "globalPlugins": [{ + "name": "@vue/typescript-plugin", + "location": tools.join("node_modules/@vue/language-server").to_string_lossy(), + "languages": ["vue"], + "configNamespace": "typescript", + "enableForWorkspaceTypeScriptVersions": true, + }] } } }); + for with_vtsls in [false, true] { + let mut vue = lang("vue", "vue", npm_server(&tools, "vue-language-server", "vue-language-server", None)); + if with_vtsls { + vue.language_servers.push(npm_server(&tools, "vtsls", "vtsls", Some(plugin.clone()))); + } + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![vue])); + let doc = DocId::next(); + mgr.did_open(doc, &file, &LanguageId::new("vue"), 1, APP_VUE); + // `count` in `{{ count }}`. + let pos = Pos::new(5, 12); + if with_vtsls { + let hover = retry("hover", Duration::from_secs(90), || { + let mgr = mgr.clone(); + async move { mgr.hover(doc, pos, Default::default()).await.ok().flatten() } + }) + .await; + assert!(hover.contents.value.contains("count: number"), "{}", hover.contents.value); + } + retry("vue-language-server ready", Duration::from_secs(30), || { + let s = last_status(&events, "vue-language-server"); + async move { matches!(s, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + // Every request of vue-language-server waits for a tsserver answer (the + // project info); without vtsls it is `null` and the server falls back. + let symbols = tokio::time::timeout(Duration::from_secs(20), mgr.document_symbols(doc)).await; + assert!(matches!(symbols, Ok(Ok(_))), "vue-language-server did not answer: {symbols:?}"); + let crashed = matches!(last_status(&events, "vue-language-server"), Some((ServerStatus::Crashed, _))); + assert!(!crashed, "{:?}", last_status(&events, "vue-language-server")); + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); + } +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 490defe04..93e0ce644 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -2,7 +2,8 @@ //! language servers, run configurations and terminals. use crate::discover::{project_venv_python, workspace_tsdk}; -use crate::exec::path_with; +use crate::exec::{path_with, search_path}; +use crate::vue; use crate::{bin_dir, exe, venv_of}; use ide_core::settings::Settings; use ide_language::LanguageServerConfig; @@ -258,6 +259,17 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root _ => {} } } + let vue_command = s.tool_path(vue::SERVER).and_then(|p| setting_path(root, p)); + let vue_command = vue_command.as_deref().map_or(vue::SERVER.into(), Path::to_string_lossy); + if name == "vtsls" + && let Some(vue) = vue::server_package(&vue_command, &search_path()) + { + let plugin = vue::tsserver_plugin(&vue); + merge_init_options(cfg, json!({ "vtsls": { "tsserver": { "globalPlugins": [plugin] } } })); + } + if name == vue::SERVER { + configure_vue_typescript(cfg, root, server_root, s); + } // Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js. if let Some(dir) = node_dir(root, s) { @@ -270,6 +282,26 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root } } +/// vue-language-server loads TypeScript as a library (`--tsdk`, else its own +/// `require('typescript')`): the selected version, else its own, else the +/// project's — whichever still has the language-service API. +fn configure_vue_typescript(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { + let selected = tsdk(server_root, s).or_else(|| tsdk(root, s)).filter(|l| vue::has_ts_api(l)); + let package = vue::server_package(&cfg.command, &search_path()); + let own = package.as_deref().and_then(vue::own_typescript); + let workspace = || workspace_tsdk(server_root).or_else(|| workspace_tsdk(root)).filter(|l| vue::has_ts_api(l)); + let lib = match selected { + Some(lib) => Some(lib), + None if own.is_some() => None, + None => workspace(), + }; + match lib { + Some(lib) => cfg.args.push(format!("--tsdk={}", lib.to_string_lossy())), + None if own.is_none() && package.is_some() => cfg.launch_error = Some(vue::missing_typescript_message()), + None => {} + } +} + fn is_python_command(cmd: &str) -> bool { let name = Path::new(cmd).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = name.strip_suffix(".exe").unwrap_or(&name); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 1068f19e5..95651dd42 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -13,6 +13,7 @@ pub mod libraries; pub mod locate; pub mod lombok; pub mod python; +mod vue; use serde::{Deserialize, Serialize}; use specta::Type; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 86088a935..5bcc70e49 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -461,3 +461,55 @@ fn no_ecosystem_libraries_without_manifests() { std::fs::create_dir_all(tmp.path().join("node_modules/react")).unwrap(); assert!(crate::libraries::project_libraries(tmp.path(), &Settings::default(), &[]).is_empty()); } + +/// Tools dir with vue-language-server and the given TypeScript files, plus a +/// project; returns (tools, project). +fn vue_setup(tmp: &Path, tools_ts: &str, project_ts: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let tools = tmp.join("tools/node_modules"); + let project = tmp.join("app"); + for (p, content) in [ + (tools.join(".bin/vue-language-server"), ""), + (tools.join("@vue/language-server/package.json"), "{}"), + (tools.join("typescript/lib").join(tools_ts), ""), + (project.join("node_modules/typescript/lib").join(project_ts), ""), + (project.join("node_modules/typescript/lib/tsserver.js"), ""), + ] { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, content).unwrap(); + } + (tools, project) +} + +#[test] +fn vue_language_server_gets_a_typescript_with_the_language_service_api() { + // Issue #25: `typescript@latest` (7.x) in the tools dir has no `ts.server`. + let s = Settings::default(); + let cases = [ + // (tools TypeScript, project TypeScript, expected --tsdk = project lib, launch error) + ("typescript.js", "typescript.js", false, false), // own TypeScript 5/6 works + ("tsc.js", "typescript.js", true, false), // own TS 7: the project's + ("tsc.js", "tsc.js", false, true), // nothing usable: clear error + ]; + for (tools_ts, project_ts, uses_project, error) in cases { + let tmp = tempfile::tempdir().unwrap(); + let (tools, project) = vue_setup(tmp.path(), tools_ts, project_ts); + let mut cfg = server("vue-language-server"); + cfg.command = tools.join(".bin/vue-language-server").to_string_lossy().into_owned(); + configure_server(&mut cfg, &project, &project, &s); + let tsdk = cfg.args.iter().find_map(|a| a.strip_prefix("--tsdk=")).map(std::path::PathBuf::from); + let project_lib = project.join("node_modules/typescript/lib"); + assert_eq!(tsdk, uses_project.then_some(project_lib), "{tools_ts} / {project_ts}"); + assert_eq!(cfg.launch_error.is_some(), error, "{tools_ts} / {project_ts}"); + } + + // A selected version wins over the server's own. + let tmp = tempfile::tempdir().unwrap(); + let (tools, project) = vue_setup(tmp.path(), "typescript.js", "typescript.js"); + let mut s = Settings::default(); + s.languages.typescript.version = "workspace".into(); + let mut cfg = server("vue-language-server"); + cfg.command = tools.join(".bin/vue-language-server").to_string_lossy().into_owned(); + configure_server(&mut cfg, &project, &project, &s); + let lib = project.join("node_modules/typescript/lib"); + assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]); +} diff --git a/crates/ide-sdk/src/vue.rs b/crates/ide-sdk/src/vue.rs new file mode 100644 index 000000000..6809485b7 --- /dev/null +++ b/crates/ide-sdk/src/vue.rs @@ -0,0 +1,121 @@ +//! Vue Language Server (Volar 3): the TypeScript it loads as a library, and the +//! `@vue/typescript-plugin` that vtsls' tsserver hosts for it (hybrid mode). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub(crate) const SERVER: &str = "vue-language-server"; +const PACKAGE: &str = "@vue/language-server"; +const PLUGIN: &str = "@vue/typescript-plugin"; + +/// The TypeScript `lib` directory has the language-service API (`typescript.js`). +/// TypeScript 7, the native port, ships only `tsc`. +pub(crate) fn has_ts_api(lib: &Path) -> bool { + lib.join("typescript.js").is_file() +} + +/// `command` as given (a path) or found on `path`, npm shims included. +fn find_command(command: &str, path: &OsStr) -> Option { + let p = Path::new(command); + if p.is_absolute() { + return p.is_file().then(|| p.to_path_buf()); + } + let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; + std::env::split_paths(path) + .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) + .find(|c| c.is_file()) +} + +/// The npm package an executable comes from: next to a `node_modules/.bin` shim +/// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a +/// Unix global `/bin` or a symlink into the package. +fn package_dir(bin: &Path, package: &str) -> Option { + let dir = bin.parent()?; + let mut candidates = vec![dir.join("node_modules").join(package)]; + if dir.file_name() == Some(OsStr::new(".bin")) + && let Some(node_modules) = dir.parent() + { + candidates.push(node_modules.join(package)); + } + if let Ok(real) = dunce::canonicalize(bin) { + candidates.extend(real.ancestors().filter(|a| a.ends_with(package)).map(Path::to_path_buf)); + } + if let Some(prefix) = dir.parent() { + candidates.push(prefix.join("lib").join("node_modules").join(package)); + } + candidates.into_iter().find(|c| c.join("package.json").is_file()) +} + +/// `@vue/language-server`'s directory for the `vue-language-server` command. +pub(crate) fn server_package(command: &str, path: &OsStr) -> Option { + package_dir(&find_command(command, path)?, PACKAGE) +} + +/// The TypeScript `require('typescript')` resolves to from `package`, when usable. +pub(crate) fn own_typescript(package: &Path) -> Option { + package.ancestors().map(|a| a.join("node_modules").join("typescript").join("lib")).find(|l| has_ts_api(l)) +} + +/// vtsls `globalPlugins` entry loading the Vue plugin shipped with `package`. +pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value { + serde_json::json!({ + "name": PLUGIN, + "location": package.to_string_lossy(), + "languages": ["vue"], + "configNamespace": "typescript", + "enableForWorkspaceTypeScriptVersions": true, + }) +} + +pub(crate) fn missing_typescript_message() -> String { + "Vue Language Server needs TypeScript 5 or 6 (TypeScript 7 has no language service API). \ + Reinstall it in Settings › Languages & Frameworks › Language Servers, add typescript@\"<7\" \ + to the project, or pick a TypeScript version in Settings › Languages & Frameworks › TypeScript." + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn touch(p: &Path) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, "{}").unwrap(); + } + + #[test] + fn finds_the_package_of_every_npm_install_layout() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path(); + // (executable, package.json) per layout. + let layouts = [ + // The IDE's tools dir / a project: node_modules/.bin shim. + ("tools/node_modules/.bin/vue-language-server", "tools/node_modules/@vue/language-server/package.json"), + // Windows global: %APPDATA%\npm\vue-language-server(.cmd) + %APPDATA%\npm\node_modules. + ("npm/vue-language-server", "npm/node_modules/@vue/language-server/package.json"), + // Unix global prefix: /bin + /lib/node_modules. + ("prefix/bin/vue-language-server", "prefix/lib/node_modules/@vue/language-server/package.json"), + ]; + for (bin, pkg) in layouts { + touch(&t.join(bin)); + touch(&t.join(pkg)); + let found = server_package(&t.join(bin).to_string_lossy(), OsStr::new("")); + assert_eq!(found, Some(t.join(pkg).parent().unwrap().to_path_buf()), "{bin}"); + } + let path = std::env::join_paths([t.join("tools/node_modules/.bin")]).unwrap(); + assert_eq!(server_package(SERVER, &path), Some(t.join("tools/node_modules/@vue/language-server"))); + assert_eq!(server_package("not-installed", &path), None); + } + + #[test] + fn own_typescript_skips_typescript_7() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("node_modules/@vue/language-server"); + touch(&pkg.join("package.json")); + // TypeScript 7 ships only `tsc`. + touch(&tmp.path().join("node_modules/typescript/lib/tsc.js")); + assert_eq!(own_typescript(&pkg), None); + touch(&tmp.path().join("node_modules/typescript/lib/typescript.js")); + assert_eq!(own_typescript(&pkg), Some(tmp.path().join("node_modules/typescript/lib"))); + } +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 1b1ec4a57..f11db8bf1 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -90,6 +90,14 @@ struct Manifest { const MANIFEST: &str = ".openidea-tool.json"; +/// `typescript@<7` → `typescript`, `@vue/language-server` unchanged. +fn npm_name(spec: &str) -> &str { + match spec[1..].find('@') { + Some(i) => &spec[..i + 1], + None => spec, + } +} + pub fn tools_dir(app_data: &Path) -> PathBuf { app_data.join("tools") } @@ -349,6 +357,17 @@ impl Tools { json.get("version")?.as_str().map(String::from) } + /// Whether an installed npm tool other than `tool` also installs `package`. + fn npm_dependency_in_use(&self, tool: &str, package: &str) -> bool { + TOOLS.iter().any(|t| match t.method { + Method::Npm { packages } if t.name != tool => { + packages.iter().skip(1).any(|p| npm_name(p) == package) + && self.npm_package_version(npm_name(packages[0])).is_some() + } + _ => false, + }) + } + fn query_version(&self, spec: &ToolSpec, found: &Path) -> Option { let (cmd, args) = spec.version?; let sibling = found.parent().and_then(|d| self.exe_in(d, cmd)); @@ -518,7 +537,7 @@ impl Tools { let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main)); if let Some(path) = managed { let version = match spec.method { - Method::Npm { packages } => self.npm_package_version(packages[0]), + Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])), _ => self.manifest(spec.name).and_then(|m| m.version), } .or_else(|| self.query_version(spec, &path)); @@ -644,7 +663,7 @@ impl Tools { let (manager, pnpm) = self.npm_manager()?; self.ensure_package_json()?; let mut c = self.command(manager); - let specs = packages.iter().map(|p| format!("{p}@latest")); + let specs = packages.iter().map(|p| if npm_name(p) == *p { format!("{p}@latest") } else { p.to_string() }); if pnpm { c.arg("add").arg("--dir").arg(&self.root).args(specs); } else { @@ -1038,12 +1057,19 @@ impl Tools { } Method::Npm { packages } => { if self.root.join("node_modules").exists() { + // Shared dependencies (`typescript`) stay while another installed tool needs them. + let packages: Vec<&str> = packages + .iter() + .enumerate() + .filter(|(i, p)| *i == 0 || !self.npm_dependency_in_use(spec.name, npm_name(p))) + .map(|(_, p)| npm_name(p)) + .collect(); let (manager, pnpm) = self.npm_manager()?; let mut c = self.command(manager); if pnpm { - c.arg("remove").arg("--dir").arg(&self.root).args(packages); + c.arg("remove").arg("--dir").arg(&self.root).args(&packages); } else { - c.args(["uninstall", "--no-fund", "--no-audit", "--prefix"]).arg(&self.root).args(packages); + c.args(["uninstall", "--no-fund", "--no-audit", "--prefix"]).arg(&self.root).args(&packages); } c.current_dir(&self.root); run_streamed(c, &cancel, &quiet)?; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index bd25e806f..7ca612b8b 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -112,6 +112,10 @@ const fn ver(cmd: &'static str) -> Option<(&'static str, &'static [&'static str] Some((cmd, &["--version"])) } +/// The TypeScript these npm servers load as a library (`tsserver.js`, `ts.server`): +/// TypeScript 7 (the native port) ships neither, and `@latest` resolves to it. +pub const TYPESCRIPT: &str = "typescript@<7"; + pub static TOOLS: &[ToolSpec] = &[ ToolSpec { name: "rust-analyzer", @@ -138,7 +142,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "typescript-language-server", kind: LanguageServer, commands: &["typescript-language-server"], - method: Method::Npm { packages: &["typescript-language-server", "typescript"] }, + method: Method::Npm { packages: &["typescript-language-server", TYPESCRIPT] }, version: ver("typescript-language-server"), homepage: "https://github.com/typescript-language-server/typescript-language-server", needs: &[], @@ -228,7 +232,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Svelte Language Server", kind: LanguageServer, commands: &["svelteserver"], - method: Method::Npm { packages: &["svelte-language-server"] }, + method: Method::Npm { packages: &["svelte-language-server", TYPESCRIPT] }, version: V, homepage: "https://github.com/sveltejs/language-tools", needs: &[], @@ -238,7 +242,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Vue Language Server", kind: LanguageServer, commands: &["vue-language-server"], - method: Method::Npm { packages: &["@vue/language-server"] }, + method: Method::Npm { packages: &["@vue/language-server", TYPESCRIPT] }, version: V, homepage: "https://github.com/vuejs/language-tools", needs: &[], diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 2edf2507e..b6b9b8a67 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -272,6 +272,54 @@ echo "added 1 package""#, assert!(env.root.join("package.json").exists()); } +/// Servers that load TypeScript as a library get one with its JS API: `@latest` +/// is TypeScript 7 (native, no `ts.server`), which crashed vue-language-server (#25). +/// Uninstalling one keeps the TypeScript another installed server still uses. +#[cfg(unix)] +#[test] +fn npm_typescript_is_pinned_and_shared() { + let env = Env::new(); + let log = env.root.parent().unwrap().join("npm-args.txt"); + env.script( + &env.sys, + "npm", + &format!( + r#"echo "$@" >> '{log}' +prefix=""; for a in "$@"; do [ "$prev" = "--prefix" ] && prefix="$a"; prev="$a"; done +[ "$1" = "install" ] || exit 0 +/bin/mkdir -p "$prefix/node_modules/.bin" +for a in "$@"; do case "$a" in + typescript-language-server@latest) n=typescript-language-server;; + @vue/language-server@latest) n=vue-language-server; d=@vue/language-server;; + *) continue;; +esac +d="${{d:-$n}}"; /bin/mkdir -p "$prefix/node_modules/$d" +echo '{{"version":"1.0.0"}}' > "$prefix/node_modules/$d/package.json" +printf '#!/bin/sh\necho 1.0.0\n' > "$prefix/node_modules/.bin/$n"; /bin/chmod +x "$prefix/node_modules/.bin/$n"; d="" +done"#, + log = log.display() + ), + ); + let tools = env.tools(); + for name in ["vue-language-server", "typescript-language-server"] { + assert_eq!( + tools.install(name, &langs(), &CancellationToken::new(), &|_| {}).unwrap().status, + ToolStatus::Installed + ); + } + let installs = fs::read_to_string(&log).unwrap(); + for line in installs.lines() { + assert!(line.contains("typescript@<7") && !line.contains("typescript@latest"), "{line}"); + } + assert!(installs.contains("@vue/language-server@latest"), "{installs}"); + + fs::write(&log, "").unwrap(); + tools.uninstall("vue-language-server", &langs()).unwrap(); + let uninstall = fs::read_to_string(&log).unwrap(); + assert!(uninstall.contains("uninstall") && uninstall.contains("@vue/language-server"), "{uninstall}"); + assert!(!uninstall.contains("typescript"), "typescript-language-server still needs it: {uninstall}"); +} + #[test] fn installed_bundle_is_detected_and_resolvable() { let env = Env::new(); diff --git a/languages/javascript.toml b/languages/javascript.toml index 9eca84ee5..d16c1ad57 100644 --- a/languages/javascript.toml +++ b/languages/javascript.toml @@ -22,7 +22,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/javascriptreact.toml b/languages/javascriptreact.toml index ddca8d529..540722ee3 100644 --- a/languages/javascriptreact.toml +++ b/languages/javascriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["jsconfig.json", "tsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescript.toml b/languages/typescript.toml index 60432e162..c8dc5779d 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/typescriptreact.toml b/languages/typescriptreact.toml index ab3c27c8c..8c5a439d9 100644 --- a/languages/typescriptreact.toml +++ b/languages/typescriptreact.toml @@ -21,7 +21,7 @@ fallback = true command = "typescript-language-server" args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] -install-hint = "npm i -g typescript typescript-language-server" +install-hint = "npm i -g typescript@\"<7\" typescript-language-server" [debugger] adapter = "js-debug" diff --git a/languages/vue.toml b/languages/vue.toml index aab4e5e0a..1176b0ecb 100644 --- a/languages/vue.toml +++ b/languages/vue.toml @@ -10,4 +10,14 @@ name = "vue-language-server" command = "vue-language-server" args = ["--stdio"] root-markers = ["package.json", "vite.config.ts", "vite.config.js", "nuxt.config.ts", "tsconfig.json"] -install-hint = "npm i -g @vue/language-server (TypeScript support also needs @vue/typescript-plugin in vtsls)" +install-hint = "npm i -g @vue/language-server typescript@\"<7\"" + +# Hybrid mode: TypeScript in `.vue` files comes from tsserver with +# `@vue/typescript-plugin` (configured by the settings hook); vue-language-server +# reaches it through `tsserver/request`, which the client forwards to vtsls. +[[language-servers]] +name = "vtsls" +command = "vtsls" +args = ["--stdio"] +root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] +install-hint = "npm i -g @vtsls/language-server" From de84a643aaf042e78bdf93ba86ad4121053e1da1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:56:13 +0800 Subject: [PATCH 02/27] fix(ui): Make read-only content selectable with a Copy context menu (#48) Root cause: body is user-select: none and each feature had to opt back in; the Markdown preview (and other read-only views) never did, and the app-wide contextmenu guard left no Copy item. Fix: shared selectableText class (src/ui) for read-only content, and a fallback Copy context menu from browserGuards when right-clicking a selection or a selectable area (component menus still win). Siblings covered: Quick Documentation / hover / completion docs, hover errors, Quick Definition, debugger value tooltip, inspection messages, message dialogs (no drag on body text), notification titles, About dialog; existing selectable consoles now get Copy too. Verified in-app on macOS: select in Markdown preview, right-click Copy puts the text on the clipboard; tab context menu unchanged. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/lsp/Popups.tsx | 16 +++- src/editor/lsp/completion.ts | 87 +++++++++++++---- src/editor/lsp/hover.ts | 49 ++++++++-- src/editor/lsp/markdown.ts | 20 +++- src/features/analysis/InspectionView.tsx | 3 +- src/features/app/AboutDialog.tsx | 13 +-- src/features/debugger/editor/valueTooltip.ts | 6 +- .../viewers/markdown/MarkdownEditor.tsx | 4 +- src/ui/Dialog.tsx | 14 ++- src/ui/Feedback.tsx | 5 +- src/ui/SelectableText.module.css | 6 ++ src/ui/index.ts | 1 + src/ui/selectableSurfaces.test.tsx | 60 ++++++++++++ src/ui/selectableText.test.ts | 93 +++++++++++++++++++ src/ui/selectableText.ts | 54 +++++++++++ src/workbench/browserGuards.test.ts | 59 ++++++++++++ src/workbench/browserGuards.ts | 18 +++- 17 files changed, 453 insertions(+), 55 deletions(-) create mode 100644 src/ui/SelectableText.module.css create mode 100644 src/ui/selectableSurfaces.test.tsx create mode 100644 src/ui/selectableText.test.ts create mode 100644 src/ui/selectableText.ts create mode 100644 src/workbench/browserGuards.test.ts diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index bc6aa24af..3db73a6ce 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -14,6 +14,7 @@ import { SymbolBadge, fileIconName, fuzzyMatch, + selectableText, T, } from "@/ui"; import { basename, relativePath, useProject } from "@/platform/project"; @@ -329,7 +330,7 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH {entry && ( renderMarkup(entry.markup, entry.language, docLinkHandler(host))} /> )} @@ -352,7 +353,13 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH /> {entry?.source && ( - void editDocSource(host)} /> + void editDocSource(host)} + /> )} {host === "popup" && ( }) { const scope = useRef(null); const opened = useRef(focusSeq); useEffect(() => { - if (focusSeq !== opened.current) scope.current?.querySelector("[data-lsp-doc]")?.focus({ preventScroll: true }); + if (focusSeq !== opened.current) + scope.current?.querySelector("[data-lsp-doc]")?.focus({ preventScroll: true }); }, [focusSeq]); return ( renderCode(target.text, target.language, "cm-lsp-code")} /> diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 325a138d0..8b0213f7c 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -25,13 +25,22 @@ import { } from "@codemirror/autocomplete"; import { EditorSelection, Prec, type Text } from "@codemirror/state"; import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view"; -import { call, commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc"; +import { + call, + commands, + isTauri, + nextRequestId, + type CompletionItem, + type Markup, + type RequestId, +} from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems"; import { lspSnippetToCm, snippetVariables } from "./snippet"; import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, posToOffset, textEditsToChanges } from "./positions"; import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown"; +import { selectableText } from "@/ui/selectableText"; import { iconNode } from "./iconMarkup"; import { triggerSignatureHelp } from "./signature"; import { serverBackoff } from "./availability"; @@ -101,7 +110,7 @@ function buildInfo(c: LspCompletion, delay: number): Promise { const hasDoc = !isEmptyMarkup(item.documentation); if (!hasDoc && !item.detail) return null; const dom = document.createElement("div"); - dom.className = "cm-lsp-completion-doc"; + dom.className = `cm-lsp-completion-doc ${selectableText}`; if (item.detail) dom.appendChild(renderCode(item.detail, c.req.language, "cm-lsp-code cm-lsp-doc-signature")); if (hasDoc) dom.appendChild(renderMarkup(item.documentation!, c.req.language)); return dom; @@ -160,7 +169,8 @@ export function applyLspCompletion( const item = completion.resolved ?? completion.lsp; const state = view.state; const delta = to - req.pos; - const mapOld = (offset: number) => Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta)); + const mapOld = (offset: number) => + Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta)); let start = from; let end = to; @@ -190,7 +200,9 @@ export function applyLspCompletion( } snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end); } else { - const parens = editorPrefs().completion.insertParentheses ? callParens(item, state.doc.lineAt(start), start, end) : null; + const parens = editorPrefs().completion.insertParentheses + ? callParens(item, state.doc.lineAt(start), start, end) + : null; insertedParens = parens; const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText + (parens ? "()" : "") }]); const caret = changes.mapPos(end, 1) - (parens === "inside" ? 1 : 0); @@ -203,7 +215,8 @@ export function applyLspCompletion( }); } - if (!completion.resolved && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start); + if (!completion.resolved && !item.additionalEdits.length && item.data != null) + void applyLateEdits(view, completion, start); if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0); runCommand(view, item); } @@ -240,7 +253,9 @@ export function callParens( async function applyLateEdits(view: EditorView, c: LspCompletion, insertedAt: number) { const item = await resolveItem(c.req.doc, c.lsp); if (!item.additionalEdits.length || !view.dom.isConnected) return; - const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter((e) => e.to <= Math.min(insertedAt, c.req.pos)); + const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter( + (e) => e.to <= Math.min(insertedAt, c.req.pos), + ); if (!edits.length) return; const limit = Math.max(...edits.map((e) => e.to)); if (view.state.doc.sliceString(0, limit) !== c.req.text.sliceString(0, limit)) return; @@ -259,7 +274,10 @@ function runCommand(view: EditorView, item: CompletionItem) { else if (!CLIENT_COMMANDS.has(cmd) && !cmd.startsWith("editor.")) { const doc = docOf(view); // Server commands (auto-import, postfix…); resulting edits come back as workspace/applyEdit. - if (doc) void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) => reportLspError(view, e, false)); + if (doc) + void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) => + reportLspError(view, e, false), + ); } } @@ -278,7 +296,10 @@ function cancelInflight() { /** Language-data completion sources (keywords etc.), used when no server answers. */ async function fallback(ctx: CompletionContext): Promise { - const sources = ctx.state.languageDataAt("autocomplete", ctx.pos); + const sources = ctx.state.languageDataAt( + "autocomplete", + ctx.pos, + ); for (const s of sources) { const source = typeof s === "function" ? s : completeFromList(s); const r = await source(ctx); @@ -317,7 +338,9 @@ export function mergeResults(results: readonly CompletionResult[]): CompletionRe const [first] = results; if (!first || results.length === 1) return first ?? null; const same = results.filter((r) => r.from === first.from); - const validFor = same.every((r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source) + const validFor = same.every( + (r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source, + ) ? first.validFor : undefined; return { from: first.from, to: first.to, options: same.flatMap((r) => r.options), validFor }; @@ -360,7 +383,9 @@ async function lspCompletionSource(ctx: CompletionContext): Promise matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) : list.items; + const items = prefs.matchCase + ? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) + : list.items; return { from: word ? word.from : ctx.pos, options: items.map((item) => makeCompletion(item, req)), @@ -396,7 +421,10 @@ export function sortAlphabetically(result: CompletionResult, typed: string): Com update: validFor ? (_cur, from, to, ctx) => { const text = ctx.state.sliceDoc(from, to); - const ok = typeof validFor === "function" ? validFor(text, from, to, ctx.state) : new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text); + const ok = + typeof validFor === "function" + ? validFor(text, from, to, ctx.state) + : new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text); return ok ? sortAlphabetically({ ...result, from }, text) : null; } : undefined, @@ -455,7 +483,9 @@ function gated(source: CompletionSource): CompletionSource { if (only) return source === contributedSource ? only(ctx) : null; if (!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state))) return null; if (!editorPrefs().completion.sortAlphabetically) return source(ctx); - return Promise.resolve(source(ctx)).then((r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos))); + return Promise.resolve(source(ctx)).then( + (r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)), + ); }; } @@ -478,7 +508,12 @@ const explicitPending = new WeakSet(); const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"]); /** Whether typing `ch` finishes the open lookup with its selected item. */ -export function commitsLookup(ch: string, focused: boolean, insertOnSpecialKeys: boolean, itemChars: readonly string[] = []): boolean { +export function commitsLookup( + ch: string, + focused: boolean, + insertOnSpecialKeys: boolean, + itemChars: readonly string[] = [], +): boolean { if (!focused && !insertOnSpecialKeys) return false; return COMMIT_CHARS.includes(ch) || itemChars.includes(ch); } @@ -495,11 +530,15 @@ const lookupKeys = Prec.highest( const selected = selectedCompletion(view.state); if (!selected) return false; const itemChars = isLsp(selected) ? selected.lsp.commitCharacters : (selected.commitCharacters ?? []); - if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) return false; + if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) + return false; if (!acceptCompletion(view)) return false; // A call snippet or inserted parentheses already opened the call: the typed one is consumed. const head = view.state.selection.main.head; - if (e.key === "(" && (view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")) { + if ( + e.key === "(" && + (view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()") + ) { e.preventDefault(); return true; } @@ -526,7 +565,8 @@ const lookupTracker = ViewPlugin.fromClass( resetLookupDocs(); if (explicitPending.has(view)) { explicitPending.delete(view); - if (prev === "pending" && !u.docChanged) queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 })); + if (prev === "pending" && !u.docChanged) + queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 })); } } else if (status === "active" && explicitPending.has(view)) { explicitPending.delete(view); @@ -535,7 +575,12 @@ const lookupTracker = ViewPlugin.fromClass( const doc = u.state.doc; setTimeout(() => { const now = currentCompletions(view.state); - if (view.state.doc === doc && completionStatus(view.state) === "active" && now.length === 1 && now[0] === options[0]) + if ( + view.state.doc === doc && + completionStatus(view.state) === "active" && + now.length === 1 && + now[0] === options[0] + ) acceptCompletion(view); }, AUTO_INSERT_DELAY); } @@ -583,7 +628,9 @@ function typeColumn(c: Completion): Node | null { async function toggleSortByName(view: EditorView) { const on = !editorPrefs().completion.sortAlphabetically; if (isTauri()) { - const settings = await call(commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on))); + const settings = await call( + commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)), + ); useProject.setState({ settings }); } if (!view.dom.isConnected) return; @@ -647,7 +694,9 @@ export function completionExtension() { optionClass: (c) => { // IDEA shows keywords in bold. const keyword = (isLsp(c) ? c.lsp.kind : c.type) === "keyword" ? " cm-lsp-keyword-option" : ""; - return (isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword; + return ( + (isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword + ); }, tooltipClass: () => "cm-lsp-completion", }), diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 227ad85ae..e9706558a 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -5,11 +5,21 @@ */ import { EditorSelection } from "@codemirror/state"; import { hoverTooltip, type EditorView } from "@codemirror/view"; -import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc"; +import { + commands, + isTauri, + nextRequestId, + type Hover, + type Location, + type Pos, + type Range, + type RequestId, +} from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics"; import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown"; +import { selectableText } from "@/ui/selectableText"; import { docOf, lspRequest, reportLspError } from "./lsp"; import { offsetToPos, rangeToOffsets } from "./positions"; import { showHint } from "./hint"; @@ -51,7 +61,13 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle for (const { diagnostic: d, from } of diags) { const row = document.createElement("div"); row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`; - row.appendChild(iconNode(d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info", 16, "cm-lsp-diag-icon")); + row.appendChild( + iconNode( + d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info", + 16, + "cm-lsp-diag-icon", + ), + ); const msg = document.createElement("div"); msg.className = "cm-lsp-diag-message"; msg.textContent = d.message; @@ -110,7 +126,7 @@ export function hoverExtension() { above: true, create: () => { const dom = document.createElement("div"); - dom.className = "cm-lsp-hover"; + dom.className = `cm-lsp-hover ${selectableText}`; if (diags.length) dom.appendChild(renderDiagnostics(view, diags)); if (hover) dom.appendChild(renderMarkup(hover.contents, lang)); return { dom }; @@ -141,7 +157,13 @@ function showDoc(view: EditorView, entry: DocEntry, at: number) { showInDocWindow(entry, view.themeClasses); return; } - openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses }); + openPopup({ + kind: "doc", + anchor: caretAnchor(view, at), + entries: [entry], + index: 0, + themeClasses: view.themeClasses, + }); } /** @@ -210,7 +232,10 @@ export function definitionSpan( async function declarationRange(loc: Location): Promise { try { - return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null); + return await withDocument( + loc.path, + async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null, + ); } catch { return null; } @@ -249,14 +274,20 @@ export async function quickDefinition(view: EditorView, kind: "definition" | "ty showHint(view, kind === "typeDefinition" ? "No type definition found" : "No definition found"); return; } - const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter( - (t): t is DefinitionTarget => !!t, - ); + const targets = ( + await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode))) + ).filter((t): t is DefinitionTarget => !!t); if (!targets.length) { showHint(view, "Cannot read the definition's file"); return; } - openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses }); + openPopup({ + kind: "definition", + anchor: caretAnchor(view, pos), + targets, + index: 0, + themeClasses: view.themeClasses, + }); } catch (e) { reportLspError(view, e, true); } diff --git a/src/editor/lsp/markdown.ts b/src/editor/lsp/markdown.ts index fbae9e538..f280a27bf 100644 --- a/src/editor/lsp/markdown.ts +++ b/src/editor/lsp/markdown.ts @@ -14,6 +14,7 @@ import type { Markup } from "@/bindings"; import { maybeEditorService } from "@/workbench/contributions"; import { isTauri } from "@/platform/ipc"; import { isVirtualUri } from "@/platform/virtualDocs"; +import { selectableText } from "@/ui/selectableText"; type Parser = typeof import("./markdownParser"); @@ -71,7 +72,10 @@ export function isEmptyMarkup(m: Markup | null | undefined): boolean { /** * The declaration line(s) of hover documentation, for the brief ⌘-hover info: the first fenced code block, else the first paragraph. */ -export function declarationSummary(m: Markup, maxLines = 6): { code: string; language: string | null } | { text: string } | null { +export function declarationSummary( + m: Markup, + maxLines = 6, +): { code: string; language: string | null } | { text: string } | null { if (m.kind === "markdown") { const fence = /^\s*(```|~~~)[ \t]*([\w+#.-]*)[^\n]*\n([\s\S]*?)\n\s*\1/m.exec(m.value); if (fence && fence[3]!.trim()) { @@ -80,7 +84,10 @@ export function declarationSummary(m: Markup, maxLines = 6): { code: string; lan return { code, language: fence[2] || null }; } } - const para = m.value.trim().split(/\n\s*\n/)[0]?.trim(); + const para = m.value + .trim() + .split(/\n\s*\n/)[0] + ?.trim(); if (!para) return null; return { text: para.length > 200 ? `${para.slice(0, 199)}…` : para }; } @@ -187,7 +194,10 @@ export function sourceLink(href: string): { path: string; line: number; col: num export function openDocLink(href: string) { const target = sourceLink(href); if (target) { - void maybeEditorService()?.openFile(target.path, { selection: { line: target.line, col: target.col }, focus: true }); + void maybeEditorService()?.openFile(target.path, { + selection: { line: target.line, col: target.col }, + focus: true, + }); } else if (/^https?:\/\//.test(href) && isTauri()) { void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {}); } @@ -210,7 +220,7 @@ function linkClickHandler(onLink?: DocLinkHandler) { /** Renders markup into a `.cm-lsp-markdown` element. */ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, onLink?: DocLinkHandler): HTMLElement { const dom = document.createElement("div"); - dom.className = "cm-lsp-markdown"; + dom.className = `cm-lsp-markdown ${selectableText}`; dom.innerHTML = markupToHtml(m); highlightBlocks(dom, fallbackLanguage); if (m.kind === "markdown" && !parser) { @@ -228,7 +238,7 @@ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, /** Renders a code snippet (e.g. a signature or definition) as highlighted mono text. */ export function renderCode(text: string, language: string | null, className = "cm-lsp-code"): HTMLElement { const pre = document.createElement("pre"); - pre.className = className; + pre.className = `${className} ${selectableText}`; const code = document.createElement("code"); code.textContent = text; pre.appendChild(code); diff --git a/src/features/analysis/InspectionView.tsx b/src/features/analysis/InspectionView.tsx index 88673f499..4b35509e3 100644 --- a/src/features/analysis/InspectionView.tsx +++ b/src/features/analysis/InspectionView.tsx @@ -25,6 +25,7 @@ import { type TreeHandle, type TreeRowState, T, + selectableText, } from "@/ui"; import { cancelInspection, @@ -438,7 +439,7 @@ function ProblemDetails({ node, root }: { node: Node | null; root: string }) { {p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : Language server} -

{p.d.message}

+

{p.d.message}

{relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""} {basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1} diff --git a/src/features/app/AboutDialog.tsx b/src/features/app/AboutDialog.tsx index 742ca7499..9de805747 100644 --- a/src/features/app/AboutDialog.tsx +++ b/src/features/app/AboutDialog.tsx @@ -3,7 +3,8 @@ import { create } from "zustand"; import { executeAction } from "@/platform/actions"; import { t as translate, useT } from "@/platform/i18n"; import { commands, isTauri, type AppInfo, type BuildInfo } from "@/platform/ipc"; -import { Dialog, IconButton } from "@/ui"; +import clsx from "clsx"; +import { Dialog, IconButton, selectableText } from "@/ui"; import logo from "../../../src-tauri/icons/128x128.png"; import { buildInfo, guessOs } from "./host"; import styles from "./app.module.css"; @@ -82,13 +83,13 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
-
OpenIDEA {build?.version ?? ""}
+
OpenIDEA {build?.version ?? ""}
{build && ( -
+
{t("app.about.build")} #{build.build} ({build.profile})
)} -
{t("app.about.tagline")}
+
{t("app.about.tagline")}
{rows .filter(([k]) => k !== t("app.about.version") && k !== t("app.about.build")) @@ -116,12 +117,12 @@ function AboutDialog({ onClose }: { onClose: () => void }) { )}
-
+
{t("app.about.copyright")}
{t("app.about.license")}
-
+
{t("app.about.thirdParty")} {t("app.about.disclaimer")}
diff --git a/src/features/debugger/editor/valueTooltip.ts b/src/features/debugger/editor/valueTooltip.ts index a8af94adc..6bca28473 100644 --- a/src/features/debugger/editor/valueTooltip.ts +++ b/src/features/debugger/editor/valueTooltip.ts @@ -9,7 +9,7 @@ import { workflowSettings } from "@/platform/workflowSettings"; import { activeSession, evaluate } from "../state"; import { inlineValue } from "../values"; import { tx } from "@/platform/i18n"; -import { iconMarkup } from "@/ui"; +import { iconMarkup, selectableText } from "@/ui"; import { mountValueTree } from "../ui/ValueHintTree"; import styles from "./editor.module.css"; @@ -59,7 +59,7 @@ export function valueTooltip(): Extension { above: true, create: () => { const dom = document.createElement("div"); - dom.className = styles.valueTooltip!; + dom.className = `${styles.valueTooltip} ${selectableText}`; let tree: { unmount(): void } | null = null; if (value.variablesReference > 0) { // IDEA's value hint: the "+" expands the object into a variables tree in place. @@ -73,7 +73,7 @@ export function valueTooltip(): Extension { expand.addEventListener("click", () => { if (tree) return; expand.remove(); - dom.classList.add(styles.valueTooltipTree!); + dom.classList.replace(selectableText, styles.valueTooltipTree!); dom.replaceChildren(); tree = mountValueTree(dom, expr.text, value, session.id); }); diff --git a/src/features/viewers/markdown/MarkdownEditor.tsx b/src/features/viewers/markdown/MarkdownEditor.tsx index 699c0f2f2..5876f9192 100644 --- a/src/features/viewers/markdown/MarkdownEditor.tsx +++ b/src/features/viewers/markdown/MarkdownEditor.tsx @@ -8,7 +8,7 @@ import clsx from "clsx"; import { call, commands, isTauri } from "@/platform/ipc"; import { maybeEditorService } from "@/workbench/contributions"; import { highlightInto } from "@/editor/lsp/markdown"; -import { IconButton } from "@/ui"; +import { IconButton, selectableText } from "@/ui"; import { useLiveMarkdown } from "./live"; import { modeOf, useMarkdownModes } from "./modes"; import { ANCHOR_PREFIX, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render"; @@ -140,7 +140,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) { return (
) => { - if (popup || e.button !== 0 || (e.target as Element).closest("button, input, label, a, select, textarea")) return; + if ( + popup || + e.button !== 0 || + (e.target as Element).closest(`button, input, label, a, select, textarea, .${selectableText}`) + ) + return; const el = ref.current; if (!el) return; e.preventDefault(); @@ -355,8 +361,10 @@ function DialogInner({
- {title != null &&
{title}
} -
{typeof children === "string" ? tx(children) : children}
+ {title != null &&
{title}
} +
+ {typeof children === "string" ? tx(children) : children} +
{footerStart &&
{footerStart}
}
diff --git a/src/ui/Feedback.tsx b/src/ui/Feedback.tsx index a1d4ec31f..05add2b25 100644 --- a/src/ui/Feedback.tsx +++ b/src/ui/Feedback.tsx @@ -3,6 +3,7 @@ import clsx from "clsx"; import { Icon, type IconName } from "./icons"; import { IconButton } from "./Button"; import { T } from "./T"; +import { selectableText } from "./selectableText"; import styles from "./Feedback.module.css"; export interface ProgressBarProps { @@ -135,7 +136,9 @@ export function Toast({
- {typeof title === "string" ? {title} : title} + + {typeof title === "string" ? {title} : title} + {meta && {meta}}
{body &&
{typeof body === "string" ? {body} : body}
} diff --git a/src/ui/SelectableText.module.css b/src/ui/SelectableText.module.css new file mode 100644 index 000000000..9beea1502 --- /dev/null +++ b/src/ui/SelectableText.module.css @@ -0,0 +1,6 @@ +/* Read-only content the user reads and copies (docs, previews, messages), as opposed to chrome. */ +.selectable { + -webkit-user-select: text; + user-select: text; + cursor: auto; +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 4604e292e..c17fd51e9 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -22,3 +22,4 @@ export * from "./BarButton"; export * from "./AppLogo"; export { lazyComponent, prefetchLazyComponents } from "./lazy"; export { T } from "./T"; +export { selectableText, copyMenuFor, copyText } from "./selectableText"; diff --git a/src/ui/selectableSurfaces.test.tsx b/src/ui/selectableSurfaces.test.tsx new file mode 100644 index 000000000..4b9658d94 --- /dev/null +++ b/src/ui/selectableSurfaces.test.tsx @@ -0,0 +1,60 @@ +/** + * Read-only content surfaces stay selectable/copyable (issue #36): IDEA lets + * the user select and copy text in the Markdown preview, documentation popups, + * message dialogs and notifications, while chrome stays unselectable. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { renderCode, renderMarkup } from "@/editor/lsp/markdown"; +import { MarkdownEditor } from "@/features/viewers/markdown/MarkdownEditor"; +import { useLiveMarkdown } from "@/features/viewers/markdown/live"; +import { useMarkdownModes } from "@/features/viewers/markdown/modes"; +import { Dialog } from "./Dialog"; +import { Toast } from "./Feedback"; +import { selectableText } from "./selectableText"; + +afterEach(() => { + cleanup(); + useLiveMarkdown.setState({}, true); +}); + +describe("selectable read-only surfaces", () => { + it("Markdown preview (the reported case)", () => { + act(() => + useLiveMarkdown.setState({ "/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0 } }), + ); + act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, "/p/README.md": "preview" } }))); + render( + +
+ , + ); + const preview = screen.getByRole("document", { name: "Markdown preview" }); + expect(preview).toHaveClass(selectableText); + expect(screen.getByRole("toolbar", { name: "Markdown editor layout" }).closest(`.${selectableText}`)).toBeNull(); + }); + + it("documentation markup and code (Quick Documentation, hover, completion docs)", () => { + expect(renderMarkup({ kind: "plaintext", value: "docs" })).toHaveClass(selectableText); + expect(renderCode("fn f()", null)).toHaveClass(selectableText); + }); + + it("message dialog title and body; dragging does not start on them", () => { + render( + {}} icon="warning" title="Delete?" buttons={[{ label: "OK", role: "default" }]}> + Message text + , + ); + const body = screen.getByText("Message text"); + expect(body).toHaveClass(selectableText); + expect(screen.getByText("Delete?")).toHaveClass(selectableText); + expect(screen.getByRole("button", { name: "OK" }).closest(`.${selectableText}`)).toBeNull(); + // Not prevented: the press starts a text selection instead of moving the dialog. + expect(fireEvent.pointerDown(body, { button: 0 })).toBe(true); + }); + + it("notification title (the body opts in through its own style)", () => { + render( {}} />); + expect(screen.getByText("Build failed").closest(`.${selectableText}`)).not.toBeNull(); + }); +}); diff --git a/src/ui/selectableText.test.ts b/src/ui/selectableText.test.ts new file mode 100644 index 000000000..8e50f5667 --- /dev/null +++ b/src/ui/selectableText.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { copyMenuFor, copyText, selectableText } from "./selectableText"; + +function mount(html: string) { + document.body.innerHTML = html; + return document.body; +} + +function select(node: Node) { + const range = document.createRange(); + range.selectNodeContents(node); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); +} + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +describe("selectableText", () => { + it("is a real class name", () => { + expect(selectableText).toBeTruthy(); + }); +}); + +describe("copyMenuFor", () => { + it("offers Copy of the selection in a selectable pane (issue #36: Markdown preview)", () => { + mount(`

Hello world

`); + select(document.getElementById("p")!); + const items = copyMenuFor(document.getElementById("b")); + expect(items).toHaveLength(1); + expect(items![0]).toMatchObject({ label: "Copy", disabled: false }); + }); + + it("offers a disabled Copy in a selectable pane with nothing selected", () => { + mount(`

Hello

`); + expect(copyMenuFor(document.getElementById("p"))![0]).toMatchObject({ label: "Copy", disabled: true }); + }); + + it("resolves text-node targets", () => { + mount(`

Hello

`); + expect(copyMenuFor(document.getElementById("p")!.firstChild)).not.toBeNull(); + }); + + it("offers Copy on a selection in any pane that opted in with its own CSS", () => { + mount(`
line 1
`); + select(document.getElementById("console")!); + expect(copyMenuFor(document.getElementById("console"))![0]).toMatchObject({ disabled: false }); + }); + + it("stays out of chrome without a selection", () => { + mount(`
tree row
`); + expect(copyMenuFor(document.getElementById("b"))).toBeNull(); + expect(copyMenuFor(document.getElementById("row"))).toBeNull(); + }); + + it("ignores a selection elsewhere when right-clicking chrome", () => { + mount(`

Hello

tree row
`); + select(document.getElementById("p")!); + expect(copyMenuFor(document.getElementById("row"))).toBeNull(); + }); + + it.each(["", "", "
x
"])( + "leaves editable targets alone: %s", + (html) => { + mount(`
${html}
`); + expect(copyMenuFor(document.getElementById("t"))).toBeNull(); + }, + ); + + it("copies the text captured when the menu opened", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + mount(`

Hello

`); + select(document.getElementById("p")!); + const items = copyMenuFor(document.getElementById("p"))!; + window.getSelection()!.removeAllRanges(); + (items[0] as { run: () => void }).run(); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith("Hello"); + }); + + it("falls back to execCommand when the clipboard API is missing", async () => { + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + const exec = vi.fn().mockReturnValue(true); + document.execCommand = exec; + await copyText("x"); + expect(exec).toHaveBeenCalledWith("copy"); + }); +}); diff --git a/src/ui/selectableText.ts b/src/ui/selectableText.ts new file mode 100644 index 000000000..3d8c53ad3 --- /dev/null +++ b/src/ui/selectableText.ts @@ -0,0 +1,54 @@ +/** + * Read-only text the user can select and copy, like IDEA's Markdown preview, + * Quick Documentation and message panes. The workbench body is unselectable + * (chrome); content opts back in with `selectableText`. A right-click on a + * selection, or anywhere in such content, offers Copy (`copyMenuFor`). + */ +import { formatBinding } from "@/platform/keymap"; +import type { MenuItem } from "./Menu"; +import styles from "./SelectableText.module.css"; + +export const selectableText = styles.selectable!; + +const EDITABLE = "input, textarea, [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']"; + +function selectionCovers(sel: Selection | null, target: Node): boolean { + if (!sel || sel.isCollapsed || !sel.toString()) return false; + for (let i = 0; i < sel.rangeCount; i++) { + const range = sel.getRangeAt(i); + if (range.intersectsNode(target) || target.contains(range.commonAncestorContainer)) return true; + } + return false; +} + +/** The Copy menu for a right-click on `target`, or null when it is not on readable text. */ +export function copyMenuFor(target: EventTarget | null): MenuItem[] | null { + const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; + if (!el || el.closest(EDITABLE)) return null; + const sel = window.getSelection(); + const text = selectionCovers(sel, el) ? sel!.toString() : ""; + if (!text && !el.closest(`.${selectableText}`)) return null; + return [ + { + id: "copy", + label: "Copy", + icon: "copy", + shortcut: formatBinding("Meta+C"), + disabled: !text, + run: () => void copyText(text), + }, + ]; +} + +/** Writes `text` to the clipboard; falls back to copying the live selection. */ +export async function copyText(text: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + } catch { + // Clipboard API refused (permissions): the selection is still there. + } + document.execCommand("copy"); +} diff --git a/src/workbench/browserGuards.test.ts b/src/workbench/browserGuards.test.ts new file mode 100644 index 000000000..d76c38d80 --- /dev/null +++ b/src/workbench/browserGuards.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hideContextMenu, selectableText, showContextMenu, useContextMenuStore } from "@/ui"; +import { installBrowserGuards } from "./browserGuards"; + +let dispose: () => void; + +beforeEach(() => { + dispose = installBrowserGuards({ blockReload: false }); +}); + +afterEach(() => { + dispose(); + hideContextMenu(); + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ""; +}); + +function rightClick(el: Element) { + const e = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 6 }); + el.dispatchEvent(e); + return e; +} + +describe("browser guards: context menu", () => { + it("never shows the native menu, and nothing on chrome", () => { + document.body.innerHTML = ``; + const e = rightClick(document.getElementById("b")!); + expect(e.defaultPrevented).toBe(true); + expect(useContextMenuStore.getState().menu).toBeNull(); + }); + + it("shows Copy on selectable read-only text (issue #36)", () => { + document.body.innerHTML = `

Hello

`; + const range = document.createRange(); + range.selectNodeContents(document.getElementById("p")!); + window.getSelection()!.addRange(range); + const e = rightClick(document.getElementById("p")!); + expect(e.defaultPrevented).toBe(true); + const menu = useContextMenuStore.getState().menu; + expect(menu).toMatchObject({ x: 5, y: 6 }); + expect(menu!.items[0]).toMatchObject({ label: "Copy", disabled: false }); + }); + + it("keeps a component's own context menu", () => { + document.body.innerHTML = `

Hello

`; + const p = document.getElementById("p")!; + p.addEventListener("contextmenu", (e) => showContextMenu(e, [{ label: "Own" }])); + rightClick(p); + expect(useContextMenuStore.getState().menu!.items[0]).toMatchObject({ label: "Own" }); + }); + + it("keeps out when a handler already prevented the default", () => { + document.body.innerHTML = `

Hello

`; + const p = document.getElementById("p")!; + p.addEventListener("contextmenu", (e) => e.preventDefault()); + rightClick(p); + expect(useContextMenuStore.getState().menu).toBeNull(); + }); +}); diff --git a/src/workbench/browserGuards.ts b/src/workbench/browserGuards.ts index 43d4f0469..167cb1c8d 100644 --- a/src/workbench/browserGuards.ts +++ b/src/workbench/browserGuards.ts @@ -2,8 +2,10 @@ * Makes the webview behave like a desktop app: never the browser context menu * (its Reload/AutoFill items would act on the webview), no reload/print/ * save-page/history shortcuts, no navigation on file drop. Copy/paste/undo - * shortcuts stay untouched. + * shortcuts stay untouched. A right-click no component handled on selected or + * selectable read-only text gets IDEA's Copy menu instead of nothing. */ +import { copyMenuFor, showContextMenu, useContextMenuStore } from "@/ui"; const BLOCKED = new Set([ "Meta+P", @@ -35,7 +37,17 @@ function keyId(e: KeyboardEvent) { export function installBrowserGuards(opts: { blockReload?: boolean } = {}): () => void { const blockReload = opts.blockReload ?? import.meta.env.PROD; - const onContextMenu = (e: MouseEvent) => e.preventDefault(); + let menuBefore: unknown = null; + const beforeContextMenu = () => { + menuBefore = useContextMenuStore.getState().menu; + }; + const onContextMenu = (e: MouseEvent) => { + const handled = e.defaultPrevented || useContextMenuStore.getState().menu !== menuBefore; + e.preventDefault(); + if (handled) return; + const items = copyMenuFor(e.target); + if (items) showContextMenu(e, items); + }; const onKeyDown = (e: KeyboardEvent) => { const id = keyId(e); if (BLOCKED.has(id) || (blockReload && RELOAD.has(id))) e.preventDefault(); @@ -43,11 +55,13 @@ export function installBrowserGuards(opts: { blockReload?: boolean } = {}): () = const onDrag = (e: DragEvent) => { if (!(e.target as Element | null)?.closest?.("[data-drop-target]")) e.preventDefault(); }; + window.addEventListener("contextmenu", beforeContextMenu, true); window.addEventListener("contextmenu", onContextMenu); window.addEventListener("keydown", onKeyDown); window.addEventListener("dragover", onDrag); window.addEventListener("drop", onDrag); return () => { + window.removeEventListener("contextmenu", beforeContextMenu, true); window.removeEventListener("contextmenu", onContextMenu); window.removeEventListener("keydown", onKeyDown); window.removeEventListener("dragover", onDrag); From 1127263ea8b12f0a3bca60cab7ca5c54da5c64f0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:13:31 +0800 Subject: [PATCH 03/27] fix(run): Shorten java command lines too long for the OS (#28) (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: every java launch inlined the whole classpath into one -cp argument with no "Shorten command line", so long classpaths exceeded CreateProcessW's 32767-char limit (os error 206) before the JVM started. Fix (IDEA's auto "Shorten command line"): ide-run rewrites a too-long java command line at spawn time — @argfile on JDK 9+ with ASCII paths, otherwise a MANIFEST pathing jar (percent-encoded file: URLs, 72-byte lines); temp files are removed when the process exits. Debug passes the same choice to java-debug's shortenCommandLine. Siblings covered: Application/Spring Boot, test classpath, Kotlin main, explicit java -cp configs, profiler, PTY launches, Debug. Verified: unit tests, pathing jar loaded by real JDK 8/11/25, ProcessManager integration test with 3000 entries on JDK 8/17/25. Not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- Cargo.lock | 1 + crates/ide-lsp/src/jdtls.rs | 4 + crates/ide-run/Cargo.toml | 1 + crates/ide-run/src/java_cmdline.rs | 537 +++++++++++++++++++ crates/ide-run/src/lib.rs | 1 + crates/ide-run/src/process.rs | 46 +- crates/ide-run/tests/process.rs | 35 ++ src-tauri/src/commands/java.rs | 3 + src/bindings.ts | 5 + src/features/debugger/__tests__/java.test.ts | 12 + src/features/debugger/java.ts | 2 + src/features/run/java.test.ts | 2 + 12 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 crates/ide-run/src/java_cmdline.rs diff --git a/Cargo.lock b/Cargo.lock index 5ded311bf..d3d549064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4258,6 +4258,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.17+spec-1.1.0", "tracing", + "zip 2.4.2", ] [[package]] diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 03c30b330..9f93618fa 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -74,6 +74,9 @@ pub struct JavaLaunchInfo { pub java_home: Option, /// The `java` launcher to use (filled in by the caller from the installed JDKs). pub java_exec: Option, + /// How a launcher that builds the command line itself must shorten it (java-debug's + /// `shortenCommandLine`: `none` | `jarmanifest` | `argfile`; filled in by the caller). + pub shorten_command_line: Option, /// First compile errors of the workspace when the build had errors. pub errors: Vec, /// All compile errors (`errors` is capped). @@ -273,6 +276,7 @@ impl LspManager { module_paths: cp.module_paths, java_home: java_home.map(Into::into), java_exec: None, + shorten_command_line: None, errors, error_count, hints: Vec::new(), diff --git a/crates/ide-run/Cargo.toml b/crates/ide-run/Cargo.toml index c64c17738..955fd355f 100644 --- a/crates/ide-run/Cargo.toml +++ b/crates/ide-run/Cargo.toml @@ -17,6 +17,7 @@ tokio.workspace = true portable-pty.workspace = true parking_lot.workspace = true tracing.workspace = true +zip = { version = "=2.4.2", default-features = false, features = ["deflate"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/ide-run/src/java_cmdline.rs b/crates/ide-run/src/java_cmdline.rs new file mode 100644 index 000000000..a6e3f374f --- /dev/null +++ b/crates/ide-run/src/java_cmdline.rs @@ -0,0 +1,537 @@ +//! IDEA's "Shorten command line" for `java` launches: a classpath too long for +//! the platform's command-line limit (Windows `CreateProcessW`: 32767 chars, +//! `os error 206`) moves into an `@argfile` (JDK 9+) or a pathing jar whose +//! manifest `Class-Path` lists the entries (any JDK, the only option on JDK 8). + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// `CreateProcessW` takes at most 32767 chars including the NUL; leave slack for quoting. +pub const WINDOWS_LIMIT: usize = 32_000; +/// Linux caps one argument at 128 KiB (`MAX_ARG_STRLEN`); macOS the whole `ARG_MAX` (1 MiB, env included). +pub const UNIX_LIMIT: usize = 120_000; + +/// How a long `java` command line is shortened (java-debug's `shortenCommandLine` values). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shortening { + None, + /// A temporary jar whose manifest `Class-Path` lists the classpath. + JarManifest, + /// A temporary `@argfile` holding `-cp` (and `--module-path`), JDK 9+. + ArgFile, +} + +impl Shortening { + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::JarManifest => "jarmanifest", + Self::ArgFile => "argfile", + } + } +} + +pub fn command_line_limit(windows: bool) -> usize { + if windows { WINDOWS_LIMIT } else { UNIX_LIMIT } +} + +/// Length of the command line as the OS receives it (quotes and separators included). +pub fn command_line_length(program: &str, args: &[String]) -> usize { + std::iter::once(program).chain(args.iter().map(String::as_str)).map(|a| a.len() + 3).sum() +} + +/// Whether the longest single argument or the whole line exceeds the platform limit. +fn too_long(program: &str, args: &[String], windows: bool) -> bool { + let limit = command_line_limit(windows); + command_line_length(program, args) > limit || (!windows && args.iter().any(|a| a.len() > limit)) +} + +/// IDEA's automatic choice: nothing when the line fits; else an `@argfile` on JDK 9+ +/// when every path is ASCII (the launcher reads argfiles in the platform charset), +/// else a pathing jar (works on every JDK; manifest URLs are percent-encoded UTF-8). +pub fn choose(too_long: bool, java_major: Option, classpath: &str) -> Shortening { + if !too_long { + Shortening::None + } else if java_major.is_some_and(|v| v >= 9) && classpath.is_ascii() { + Shortening::ArgFile + } else { + Shortening::JarManifest + } +} + +/// Room left for VM options and program arguments when only the paths are known. +const OTHER_ARGS_RESERVE: usize = 4_096; + +/// The shortening a launcher that builds the command line itself (java-debug's +/// `shortenCommandLine`) should use for `java -cp [--module-path …] …`. +pub fn shortening_for(java: &Path, class_paths: &[String], module_paths: &[String], windows: bool) -> Shortening { + let sep = if windows { ";" } else { ":" }; + let (cp, mp) = (class_paths.join(sep), module_paths.join(sep)); + let len = java.as_os_str().len() + cp.len() + mp.len() + 64 + OTHER_ARGS_RESERVE; + let limit = command_line_limit(windows); + choose(len > limit || cp.len() > limit || mp.len() > limit, java_feature_version(java), &format!("{cp}{mp}")) +} + +/// `JAVA_VERSION="21.0.3"` (or `"1.8.0_372"`) from a JDK's `release` file. +pub fn parse_release(release: &str) -> Option { + let line = release.lines().find_map(|l| l.trim().strip_prefix("JAVA_VERSION="))?; + let mut parts = line.trim().trim_matches('"').split(['.', '_', '-', '+']); + let first: u32 = parts.next()?.parse().ok()?; + if first == 1 { parts.next()?.parse().ok() } else { Some(first) } +} + +/// Feature version of the JDK a `java` launcher belongs to (`/bin/java`, or +/// JDK 8's `/jre/bin/java`), following symlinks; `None` when unknown. +pub fn java_feature_version(java: &Path) -> Option { + let java = std::fs::canonicalize(java).unwrap_or_else(|_| java.to_path_buf()); + java.ancestors() + .skip(2) + .take(2) + .find_map(|home| std::fs::read_to_string(home.join("release")).ok()) + .and_then(|r| parse_release(&r)) +} + +/// `java`, `java.exe`, `javaw.exe`. +pub fn is_java_launcher(program: &Path) -> bool { + let name = crate::wrap::program_name(&program.to_string_lossy()).to_ascii_lowercase(); + name == "java" || name == "javaw" +} + +/// `java` options whose value is the next argument. +const WITH_VALUE: &[&str] = &[ + "-cp", + "-classpath", + "--class-path", + "-p", + "--module-path", + "--upgrade-module-path", + "--add-modules", + "--add-opens", + "--add-exports", + "--add-reads", + "--patch-module", + "--limit-modules", + "--enable-native-access", +]; + +/// Indices of the `-cp` and `--module-path` values among the VM options (before the main class). +fn path_options(args: &[String]) -> (Option, Option) { + let (mut cp, mut mp) = (None, None); + let mut i = 0; + while i < args.len() { + let a = args[i].as_str(); + if matches!(a, "-jar" | "-m" | "--module") || !a.starts_with('-') { + break; + } + if WITH_VALUE.contains(&a) { + if i + 1 < args.len() { + match a { + "-cp" | "-classpath" | "--class-path" => cp = Some(i + 1), + "-p" | "--module-path" => mp = Some(i + 1), + _ => {} + } + } + i += 2; + } else { + i += 1; + } + } + (cp, mp) +} + +/// A shortened command line and the temporary file it refers to (delete it once the process exits). +#[derive(Debug)] +pub struct Shortened { + pub args: Vec, + pub file: PathBuf, + pub how: Shortening, +} + +/// Shortens `java [options] -cp Main …` when it is too long for the platform: +/// `None` for other programs, lines that fit, or no classpath option. +pub fn shorten( + program: &Path, + args: &[String], + cwd: &Path, + windows: bool, + temp_dir: &Path, +) -> std::io::Result> { + if !is_java_launcher(program) || !too_long(&program.to_string_lossy(), args, windows) { + return Ok(None); + } + let (cp, mp) = path_options(args); + let Some(cp) = cp else { return Ok(None) }; + let how = choose(true, java_feature_version(program), &args[cp]); + std::fs::create_dir_all(temp_dir)?; + let mut out = args.to_vec(); + let file = match how { + Shortening::ArgFile => { + let file = temp_file(temp_dir, "argfile"); + let mut moved = vec![cp - 1, cp]; + if let Some(mp) = mp { + moved.extend([mp - 1, mp]); + } + moved.sort_unstable(); + let text = argfile_text(&moved.iter().map(|&i| args[i].clone()).collect::>()); + std::fs::write(&file, text)?; + for &i in moved.iter().rev() { + out.remove(i); + } + out.insert(moved[0], format!("@{}", file.display())); + file + } + _ => { + let file = temp_file(temp_dir, "jar"); + let entries = classpath_entries(&args[cp], cwd, windows); + write_pathing_jar(&entries, &file)?; + out[cp] = file.to_string_lossy().into_owned(); + file + } + }; + Ok(Some(Shortened { args: out, file, how })) +} + +/// Where launches put their pathing jars / argfiles. +pub fn default_temp_dir() -> PathBuf { + std::env::temp_dir().join("openidea-classpath") +} + +fn temp_file(dir: &Path, ext: &str) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + dir.join(format!("classpath-{}-{nanos}-{n}.{ext}", std::process::id())) +} + +/// Argument file lines: every argument quoted, `\` and `"` escaped (the launcher's argfile syntax). +pub fn argfile_text(args: &[String]) -> String { + let mut out = String::new(); + for a in args { + out.push('"'); + for c in a.chars() { + if c == '\\' || c == '"' { + out.push('\\'); + } + out.push(c); + } + out.push_str("\"\n"); + } + out +} + +/// Classpath entries as absolute paths: relative ones against `cwd`, `dir/*` expanded to its jars. +pub fn classpath_entries(classpath: &str, cwd: &Path, windows: bool) -> Vec { + let mut out = Vec::new(); + for e in classpath.split(if windows { ';' } else { ':' }).filter(|e| !e.is_empty()) { + let wildcard = e.strip_suffix("/*").or_else(|| e.strip_suffix("\\*")); + let path = cwd.join(wildcard.unwrap_or(e)); + if wildcard.is_some() { + let mut jars: Vec = std::fs::read_dir(&path) + .map(|rd| { + rd.flatten() + .map(|d| d.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("jar"))) + .collect() + }) + .unwrap_or_default(); + jars.sort(); + out.extend(jars); + } else { + out.push(path); + } + } + out +} + +/// `file:` URL of a classpath entry: percent-encoded UTF-8, `/` separators, a trailing `/` on directories. +pub fn file_url(path: &str, dir: bool) -> String { + let mut p = path.replace('\\', "/"); + if !p.starts_with('/') { + p.insert(0, '/'); // `C:/x` → `/C:/x` + } else if p.starts_with("//") { + p.insert_str(0, "//"); // UNC `//server/share` → `file:////server/share`, as `File.toURI` does + } + if dir && !p.ends_with('/') { + p.push('/'); + } + let mut url = String::from("file:"); + for b in p.bytes() { + if b.is_ascii_alphanumeric() || b"-._~/:".contains(&b) { + url.push(b as char); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url +} + +/// `META-INF/MANIFEST.MF` listing `entries`, lines wrapped at 72 bytes (continuations start with a space). +pub fn manifest(entries: &[PathBuf]) -> String { + let urls: Vec = entries.iter().map(|e| file_url(&e.to_string_lossy(), e.is_dir())).collect(); + let mut out = String::from("Manifest-Version: 1.0\r\nCreated-By: OpenIDEA\r\n"); + out.push_str(&wrap_header(&format!("Class-Path: {}", urls.join(" ")))); + out.push_str("\r\n"); + out +} + +/// One manifest header in lines of at most 72 bytes; `line` is ASCII (URLs are percent-encoded). +fn wrap_header(line: &str) -> String { + let mut out = String::new(); + let mut rest = line; + let mut width = 72; + while !rest.is_empty() { + let (head, tail) = rest.split_at(width.min(rest.len())); + if width == 71 { + out.push(' '); + } + out.push_str(head); + out.push_str("\r\n"); + rest = tail; + width = 71; + } + out +} + +/// A jar holding only a manifest whose `Class-Path` is `entries` (IDEA's "JAR manifest"). +pub fn write_pathing_jar(entries: &[PathBuf], out: &Path) -> std::io::Result<()> { + let mut zip = zip::ZipWriter::new(std::fs::File::create(out)?); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("META-INF/MANIFEST.MF", opts).map_err(std::io::Error::other)?; + zip.write_all(manifest(entries).as_bytes())?; + zip.finish().map_err(std::io::Error::other)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(a: &[&str]) -> Vec { + a.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn chooses_like_idea() { + assert_eq!(choose(false, Some(8), "a.jar"), Shortening::None); + assert_eq!(choose(true, Some(8), "a.jar"), Shortening::JarManifest); + assert_eq!(choose(true, None, "a.jar"), Shortening::JarManifest); + assert_eq!(choose(true, Some(9), "a.jar"), Shortening::ArgFile); + assert_eq!(choose(true, Some(21), "a.jar"), Shortening::ArgFile); + assert_eq!(choose(true, Some(21), "C:\\用户\\a.jar"), Shortening::JarManifest); + } + + #[test] + fn debugger_launches_get_the_same_choice() { + let dir = tempfile::tempdir().unwrap(); + let jdk = dir.path().join("jdk11"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"11.0.18\"").unwrap(); + let java = jdk.join("bin/java"); + let few = vec!["/r/a.jar".to_owned(); 10]; + let many = vec![r"C:\Users\dev\.m2\repository\org\x\lib\1.0\lib-1.0.jar".to_owned(); 600]; + assert_eq!(shortening_for(&java, &few, &[], true), Shortening::None); + assert_eq!(shortening_for(&java, &many, &[], true), Shortening::ArgFile); + assert_eq!(shortening_for(&java, &many, &[], false), Shortening::None); + assert_eq!(shortening_for(Path::new("java"), &many, &[], true), Shortening::JarManifest); + } + + #[test] + fn release_versions() { + assert_eq!(parse_release("JAVA_VERSION=\"1.8.0_372\"\n"), Some(8)); + assert_eq!(parse_release("IMPLEMENTOR=x\nJAVA_VERSION=\"21.0.3\""), Some(21)); + assert_eq!(parse_release("JAVA_VERSION=\"25\""), Some(25)); + assert_eq!(parse_release("nothing"), None); + } + + #[test] + fn feature_version_from_the_jdk_layout() { + let dir = tempfile::tempdir().unwrap(); + let jdk8 = dir.path().join("jdk8"); + std::fs::create_dir_all(jdk8.join("jre/bin")).unwrap(); + std::fs::write(jdk8.join("release"), "JAVA_VERSION=\"1.8.0_372\"").unwrap(); + assert_eq!(java_feature_version(&jdk8.join("jre/bin/java")), Some(8)); + let jdk21 = dir.path().join("jdk21"); + std::fs::create_dir_all(jdk21.join("bin")).unwrap(); + std::fs::write(jdk21.join("release"), "JAVA_VERSION=\"21.0.3\"").unwrap(); + assert_eq!(java_feature_version(&jdk21.join("bin/java")), Some(21)); + assert_eq!(java_feature_version(Path::new("java")), None); + } + + #[test] + fn launchers() { + for p in ["java", "/usr/bin/java", r"C:\jdk\bin\java.exe", r"C:\jdk\bin\javaw.exe", "JAVA.EXE"] { + assert!(is_java_launcher(Path::new(p)), "{p}"); + } + for p in ["javac", "kotlin", "/usr/bin/mvn", "java-agent"] { + assert!(!is_java_launcher(Path::new(p)), "{p}"); + } + } + + #[test] + fn finds_classpath_among_vm_options_only() { + let a = strings(&["-Xmx1g", "--add-opens", "a/b=ALL", "-cp", "x.jar", "Main", "-cp", "y"]); + assert_eq!(path_options(&a), (Some(4), None)); + let a = strings(&["-p", "mods", "-classpath", "x.jar", "Main"]); + assert_eq!(path_options(&a), (Some(3), Some(1))); + assert_eq!(path_options(&strings(&["-jar", "app.jar", "-cp", "x"])), (None, None)); + assert_eq!(path_options(&strings(&["Main", "-cp", "x"])), (None, None)); + } + + #[test] + fn urls_are_encoded_and_directories_end_with_a_slash() { + assert_eq!(file_url("/p/a b/x.jar", false), "file:/p/a%20b/x.jar"); + assert_eq!(file_url("/p/classes", true), "file:/p/classes/"); + assert_eq!(file_url("/p/classes/", true), "file:/p/classes/"); + assert_eq!(file_url(r"C:\Users\me\.m2\a+b#c.jar", false), "file:/C:/Users/me/.m2/a%2Bb%23c.jar"); + assert_eq!(file_url(r"\\srv\share\x.jar", false), "file:////srv/share/x.jar"); + assert_eq!(file_url("/p/用户/x.jar", false), "file:/p/%E7%94%A8%E6%88%B7/x.jar"); + assert_eq!(file_url("/p/100%/x.jar", false), "file:/p/100%25/x.jar"); + } + + #[test] + fn manifest_lines_are_at_most_72_bytes() { + let entries: Vec = (0..300) + .map(|i| PathBuf::from(format!("/home/u/.m2/repository/org/x/lib-{i}/1.0/lib {i}-1.0.jar"))) + .collect(); + let m = manifest(&entries); + assert!(m.starts_with("Manifest-Version: 1.0\r\n")); + assert!(m.ends_with("\r\n\r\n")); + for line in m.split("\r\n") { + assert!(line.len() <= 72, "{line:?}"); + } + // Unwrapping restores the header exactly. + let joined = m.replace("\r\n ", ""); + let joined = joined.lines().find_map(|l| l.strip_prefix("Class-Path: ")).unwrap(); + let urls: Vec<&str> = joined.split(' ').collect(); + assert_eq!(urls.len(), 300); + assert_eq!(urls[7], "file:/home/u/.m2/repository/org/x/lib-7/1.0/lib%207-1.0.jar"); + } + + #[test] + fn argfile_quotes_and_escapes() { + assert_eq!( + argfile_text(&strings(&["-cp", r#"C:\a b\x.jar;C:\q"y.jar"#])), + "\"-cp\"\n\"C:\\\\a b\\\\x.jar;C:\\\\q\\\"y.jar\"\n" + ); + } + + #[test] + fn short_lines_and_other_programs_are_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let args = strings(&["-cp", "a.jar", "Main"]); + assert!(shorten(Path::new("java"), &args, dir.path(), true, dir.path()).unwrap().is_none()); + let long = strings(&["-cp", &"x".repeat(40_000), "Main"]); + assert!(shorten(Path::new("node"), &long, dir.path(), true, dir.path()).unwrap().is_none()); + // Fits the Unix limit; too long for Windows. + assert!(shorten(Path::new("java"), &long, dir.path(), false, dir.path()).unwrap().is_none()); + } + + /// The reporter's case: JDK 8, ~270 jars, past 32767 chars on Windows. + #[test] + fn jdk8_gets_a_pathing_jar() { + let dir = tempfile::tempdir().unwrap(); + let jdk = dir.path().join("jdk8"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"1.8.0_372\"").unwrap(); + let java = jdk.join("bin/java.exe"); + let cp: Vec = (0..270) + .map(|i| format!(r"C:\Users\developer01\.m2\repository\org\springframework\boot\spring-boot-starter-lib-{i}\2.7.18\spring-boot-starter-lib-{i}-2.7.18.jar")) + .collect(); + let cp = format!(r"C:\work\demo\target\classes;{}", cp.join(";")); + assert!(cp.len() > 32_767); + let args = + strings(&["-Dspring.profiles.active=dev", "-cp", &cp, "com.example.DemoApplication", "--port", "8080"]); + let tmp = dir.path().join("tmp"); + let s = shorten(&java, &args, dir.path(), true, &tmp).unwrap().unwrap(); + assert_eq!(s.how, Shortening::JarManifest); + assert_eq!(s.args[0], "-Dspring.profiles.active=dev"); + assert_eq!(s.args[1], "-cp"); + assert_eq!(s.args[2], s.file.to_string_lossy()); + assert_eq!(&s.args[3..], ["com.example.DemoApplication", "--port", "8080"]); + assert!(command_line_length(&java.to_string_lossy(), &s.args) < 1_000); + let mut jar = zip::ZipArchive::new(std::fs::File::open(&s.file).unwrap()).unwrap(); + let mut text = String::new(); + std::io::Read::read_to_string(&mut jar.by_name("META-INF/MANIFEST.MF").unwrap(), &mut text).unwrap(); + let text = text.replace("\r\n ", ""); + let urls: Vec<&str> = text.lines().find_map(|l| l.strip_prefix("Class-Path: ")).unwrap().split(' ').collect(); + assert_eq!(urls.len(), 271); + assert!(urls.iter().all(|u| u.starts_with("file:/") && u.ends_with(".jar") || u.ends_with("classes"))); + } + + #[test] + fn jdk9_plus_gets_an_argfile_with_the_module_path() { + let dir = tempfile::tempdir().unwrap(); + let jdk = dir.path().join("jdk17"); + std::fs::create_dir_all(jdk.join("bin")).unwrap(); + std::fs::write(jdk.join("release"), "JAVA_VERSION=\"17.0.6\"").unwrap(); + let cp = vec!["/r/lib.jar"; 3_000].join(":"); + let args = strings(&["-Xmx1g", "--module-path", "/r/mods", "-cp", &cp, "a.Main", "x"]); + let s = shorten(&jdk.join("bin/java"), &args, dir.path(), true, dir.path()).unwrap().unwrap(); + assert_eq!(s.how, Shortening::ArgFile); + assert_eq!(s.args, ["-Xmx1g".to_owned(), format!("@{}", s.file.display()), "a.Main".into(), "x".into()]); + let text = std::fs::read_to_string(&s.file).unwrap(); + assert_eq!(text, format!("\"--module-path\"\n\"/r/mods\"\n\"-cp\"\n\"{cp}\"\n")); + } + + #[test] + fn wildcards_and_relative_entries() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("lib")).unwrap(); + for f in ["b.jar", "a.JAR", "notes.txt"] { + std::fs::write(dir.path().join("lib").join(f), "").unwrap(); + } + // An absolute entry stays as is; built from the temp dir so it is absolute on every platform. + let abs = dir.path().join("abs").join("x.jar"); + let sep = if cfg!(windows) { ";" } else { ":" }; + let cp = ["classes", "lib/*", &abs.to_string_lossy()].join(sep); + let e = classpath_entries(&cp, dir.path(), cfg!(windows)); + assert_eq!(e, [dir.path().join("classes"), dir.path().join("lib/a.JAR"), dir.path().join("lib/b.jar"), abs]); + } + + /// A pathing jar really loads classes (and resources from directories) with this machine's JDKs. + #[test] + fn pathing_jar_runs_with_a_local_jdk() { + let Some(home) = std::env::var_os("JAVA_HOME").map(PathBuf::from).or_else(|| { + let java = crate::which::find_executable("java", &std::env::var_os("PATH").unwrap_or_default())?; + std::fs::canonicalize(java).ok()?.parent()?.parent().map(Path::to_path_buf) + }) else { + return; + }; + let bin = |t: &str| home.join("bin").join(if cfg!(windows) { format!("{t}.exe") } else { t.to_owned() }); + if !bin("javac").is_file() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src dir"); + let classes = dir.path().join("out classes"); + std::fs::create_dir_all(src.join("p")).unwrap(); + std::fs::create_dir_all(&classes).unwrap(); + std::fs::write( + src.join("p/Main.java"), + "package p; public class Main { public static void main(String[] a) { System.out.println(\"hello \" + a[0]); } }", + ) + .unwrap(); + let ok = std::process::Command::new(bin("javac")) + .args(["--release", "8", "-d"]) + .arg(&classes) + .arg(src.join("p/Main.java")) + .status() + .is_ok_and(|s| s.success()) + || std::process::Command::new(bin("javac")) + .arg("-d") + .arg(&classes) + .arg(src.join("p/Main.java")) + .status() + .is_ok_and(|s| s.success()); + assert!(ok, "javac failed"); + let mut entries: Vec = (0..400).map(|i| dir.path().join(format!("missing {i}.jar"))).collect(); + entries.push(classes.clone()); + let jar = dir.path().join("cp.jar"); + write_pathing_jar(&entries, &jar).unwrap(); + let out = + std::process::Command::new(bin("java")).arg("-cp").arg(&jar).args(["p.Main", "world"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello world"); + } +} diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 0a717609d..58e6b12cc 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod detect; pub mod envfile; +pub mod java_cmdline; pub mod junit; mod process; mod utf8; diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 4874ede65..40b03834f 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -144,6 +144,44 @@ pub(crate) fn invocation(config: &RunConfig, cwd: &Path) -> Invocation { Invocation { program, args: config.args.clone(), raw_args: false, display } } +/// "Shorten command line" for a `java` launch too long for the platform (see +/// [`crate::java_cmdline`]): rewrites `inv` and returns the temporary file to delete on exit. +fn shorten_java(inv: &mut Invocation, config: &RunConfig, cwd: &Path) -> IdeResult { + if inv.raw_args || !crate::java_cmdline::is_java_launcher(&inv.program) { + return Ok(TempFile(None)); + } + let java = if inv.program.components().count() == 1 { + crate::which::find_executable(&inv.program.to_string_lossy(), &crate::wrap::search_path(config)) + .unwrap_or_else(|| inv.program.clone()) + } else { + inv.program.clone() + }; + let dir = crate::java_cmdline::default_temp_dir(); + let shortened = crate::java_cmdline::shorten(&java, &inv.args, cwd, cfg!(windows), &dir) + .map_err(|e| IdeError::internal(format!("cannot shorten the command line: {e}")))?; + let Some(s) = shortened else { return Ok(TempFile(None)) }; + tracing::debug!(how = s.how.as_str(), file = %s.file.display(), "java command line shortened"); + let mut display = quote(config.command.trim()); + for a in &s.args { + display.push(' '); + display.push_str("e(a)); + } + inv.args = s.args; + inv.display = display; + Ok(TempFile(Some(s.file))) +} + +/// A launch's pathing jar / argfile, deleted when dropped (the process exited, or never started). +struct TempFile(Option); + +impl Drop for TempFile { + fn drop(&mut self) { + if let Some(f) = self.0.take() { + let _ = std::fs::remove_file(f); + } + } +} + /// `command` as one line for the platform shell: `/bin/sh -c`, or `cmd /D /S /C "…"` /// (`/S` strips exactly the outer quotes, so the line's own quoting survives). fn shell_invocation(command: &str, windows: bool, comspec: Option) -> Invocation { @@ -232,7 +270,7 @@ impl ProcessManager { if !cwd.is_dir() { return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display()))); } - let inv = invocation(config, &cwd); + let mut inv = invocation(config, &cwd); let extras = &config.extras; if extras.emulate_terminal && extras.stdin_file.is_some() { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); @@ -248,8 +286,9 @@ impl ProcessManager { cwd: cwd.clone(), env: env.clone(), }); + let temp = shorten_java(&mut inv, config, &cwd)?; if extras.emulate_terminal { - return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output); + return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); } let mut cmd = Command::new(&inv.program); @@ -336,6 +375,7 @@ impl ProcessManager { .name(format!("openidea-run-wait-{}", id.0)) .spawn(move || { let status = child.wait(); + drop(temp); let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status))); }) .map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?; @@ -356,6 +396,7 @@ impl ProcessManager { options: &StartOptions, sink: SharedSink, on_output: OutputFn, + temp: TempFile, ) -> IdeResult { let pair = native_pty_system() .openpty(pty_size(DEFAULT_PTY.0, DEFAULT_PTY.1)) @@ -413,6 +454,7 @@ impl ProcessManager { .name(format!("openidea-run-wait-{}", id.0)) .spawn(move || { let status = child.wait(); + drop(temp); let _ = tx.send(Msg::Exited(Exit::Pty(status))); }) .map_err(|e| IdeError::internal(format!("failed to start waiter thread: {e}")))?; diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index 946edfc10..e60ffb000 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -379,3 +379,38 @@ fn stop_command_runs_first_with_the_run_environment() { assert!(h.root.path().join("stopped").exists(), "stop command did not run"); assert!(text(&chunks, system).contains("touch \"$OPENIDEA_MARKER\"")); } + +/// A `java` command line past the platform limit starts through a pathing jar +/// (JDK 8) or an `@argfile` (JDK 9+), which is deleted once the JVM exits. +#[test] +fn long_java_classpath_is_shortened() { + let Some(home) = std::env::var_os("JAVA_HOME").map(PathBuf::from) else { return }; + let (java, javac) = (home.join("bin/java"), home.join("bin/javac")); + if !javac.is_file() { + return; + } + let h = harness(); + let classes = h.root.path().join("out classes"); + std::fs::create_dir_all(classes.join("p")).unwrap(); + let src = h.root.path().join("Main.java"); + std::fs::write(&src, "package p; public class Main { public static void main(String[] a) { System.out.println(\"hello \" + a[0]); } }").unwrap(); + let ok = std::process::Command::new(&javac).arg("-d").arg(&classes).arg(&src).status().unwrap().success(); + assert!(ok, "javac failed"); + let mut cp: Vec = (0..3_000).map(|i| format!("/no/such/repository/lib-{i}/1.0/lib-{i}-1.0.jar")).collect(); + cp.push(classes.to_string_lossy().into_owned()); + let cp = cp.join(":"); + assert!(cp.len() > ide_run::java_cmdline::UNIX_LIMIT); + let config = cfg("t:java", &java.to_string_lossy(), &["-cp", &cp, "p.Main", "world"]); + let (id, chunks) = start(&h, &config); + assert_eq!(wait_exit(&h, id, Duration::from_secs(30)), Some(0), "{}", text(&chunks, stderr)); + assert_eq!(text(&chunks, stdout).trim(), "hello world"); + let echo = text(&chunks, system); + let first = echo.lines().next().unwrap(); + assert!(first.len() < 1_000, "{first}"); + let file = first + .split(' ') + .map(|a| a.trim_matches('\'').trim_start_matches('@')) + .find(|a| a.contains("openidea-classpath")) + .unwrap_or_else(|| panic!("no pathing jar / argfile in {first}")); + assert!(!Path::new(file).exists(), "{file} not deleted"); +} diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index e7a7d5d27..9c06a1c69 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -51,6 +51,9 @@ pub async fn java_resolve_launch( ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home)) }); info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned())); + let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java")); + let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows)); + info.shorten_command_line = Some(shorten.as_str().to_owned()); if info.error_count > 0 { let vm_args = &settings.languages.java.jdtls_vm_args; let lombok = ide_sdk::lombok::lombok_agent( diff --git a/src/bindings.ts b/src/bindings.ts index 7b0a5b7c9..3f40dce2c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2810,6 +2810,11 @@ export type JavaLaunchInfo = { javaHome: IpcPath | null, /** The `java` launcher to use (filled in by the caller from the installed JDKs). */ javaExec: string | null, + /** + * How a launcher that builds the command line itself must shorten it (java-debug's + * `shortenCommandLine`: `none` | `jarmanifest` | `argfile`; filled in by the caller). + */ + shortenCommandLine: string | null, /** First compile errors of the workspace when the build had errors. */ errors: JavaCompileError[], /** All compile errors (`errors` is capped). */ diff --git a/src/features/debugger/__tests__/java.test.ts b/src/features/debugger/__tests__/java.test.ts index a28e595b7..2166a49b0 100644 --- a/src/features/debugger/__tests__/java.test.ts +++ b/src/features/debugger/__tests__/java.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RunConfig } from "@/platform/ipc"; const invoked: Array<[string, unknown[]]> = []; +let shortenCommandLine: string | null = null; vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); @@ -18,6 +19,7 @@ vi.mock("@/platform/ipc", async (orig) => { modulePaths: [], javaHome: "/jdk21", javaExec: "/jdk21/bin/java", + shortenCommandLine, errors: [], errorCount: 0, hints: [], @@ -49,6 +51,7 @@ const app: RunConfig = { describe("Java debugging (java-debug through jdtls)", () => { beforeEach(() => { invoked.length = 0; + shortenCommandLine = null; }); it("launches Application configs with the resolved classpath on jdtls' debug server", async () => { @@ -68,9 +71,18 @@ describe("Java debugging (java-debug through jdtls)", () => { vmArgs: "-Xmx1g", args: '--port "8081 x"', console: "internalConsole", + shortenCommandLine: "none", }); }); + it("lets java-debug shorten a classpath too long for the OS, as Run does", async () => { + for (const how of ["jarmanifest", "argfile"]) { + shortenCommandLine = how; + const l = await javaLaunchConfig(app, "/p"); + expect(JSON.parse(l.extraJson!).shortenCommandLine).toBe(how); + } + }); + it("attaches to a JDWP agent", async () => { const l = await javaAttachConfig({ name: "mvn test", module: "", hostName: "127.0.0.1", port: 5005 }, "/p"); expect(l).toMatchObject({ request: "attach", cwd: "/p" }); diff --git a/src/features/debugger/java.ts b/src/features/debugger/java.ts index d8bfc44f2..d5b3d45b7 100644 --- a/src/features/debugger/java.ts +++ b/src/features/debugger/java.ts @@ -86,6 +86,8 @@ export async function javaLaunchConfig(rc: RunConfig, root: string): Promise { modulePaths: [], javaHome: null, javaExec: null, + shortenCommandLine: null, }; it("lists the first errors, counts the rest and adds hints", () => { @@ -204,6 +205,7 @@ describe("java command lines", () => { modulePaths: [], javaHome: "/jdk21", javaExec: "/jdk21/bin/java", + shortenCommandLine: "none", errors: [], errorCount: 0, hints: [], From 843127f4a3833824806b1c3482bb373c9977eff4 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:16:54 +0800 Subject: [PATCH 04/27] fix(app): Apply the saved IDE zoom before the page loads so the splash doesn't jump The frontend set the webview zoom once settings loaded, while the splash was still up, so a non-100% zoom visibly scaled the splash logo at the end. --- src-tauri/src/windows.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index bf625819e..a1abc66c9 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -240,6 +240,7 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| b.build()) .map_err(|e| IdeError::internal(format!("cannot create window: {e}")))?; + apply_zoom(app, &window); // "Sync with OS" needs the OS mode, known once the window exists. if let Some(a) = appearance.filter(|a| a.sync_with_os || a.theme == "system") && window.theme().is_ok_and(|t| t == Theme::Dark) @@ -249,6 +250,17 @@ fn build_project_window(app: &AppHandle, mut config: WindowConfig) -> IdeResult< Ok(window) } +/// Sets the saved IDE zoom before the page loads; applied later by the frontend, +/// it would visibly scale up the splash. +fn apply_zoom(app: &AppHandle, window: &WebviewWindow) { + let Some(zoom) = app.try_state::().map(|s| s.global_settings.effective().appearance.zoom) else { + return; + }; + if zoom != 100 { + let _ = window.set_zoom(f64::from(zoom) / 100.0); + } +} + /// The first window (`create: false` in tauri.conf.json so it can take the theme's colour). pub fn create_main_window(app: &AppHandle) -> IdeResult { build_project_window(app, window_config(app)?) @@ -414,6 +426,7 @@ pub fn open_settings_window( .and_then(|b| b.parent(&parent)) .and_then(|b| b.build()) .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; + apply_zoom(app, &window); // window-state restores the remembered size; the position always follows the parent. if let (Ok(pos), Ok(size), Ok(own), Ok(scale)) = (parent.outer_position(), parent.outer_size(), window.outer_size(), parent.scale_factor()) From 5670c2e8dd180436b8bf99ebcae756cb1e50dec5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:11 +0800 Subject: [PATCH 05/27] test: stub Range layout methods once in the test setup (#53) Root cause: jsdom has no layout and CodeMirror measures text ranges in an animation frame, sometimes after a test finished; test files that did not stub Range.getClientRects (e.g. search/codePreview) threw an unhandled TypeError that failed whole CI runs at random (seen on #41). Fix: stub Range.getClientRects / getBoundingClientRect once in src/test/setup.ts; drop the six per-file copies. Verified: vitest on editor/lsp, editor/refactor, templates, search (55 files, 458 tests, no unhandled errors). Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/lsp/completion.test.ts | 4 ---- src/editor/lsp/signature.test.ts | 4 ---- src/editor/lsp/syntaxFallback.test.ts | 3 --- src/editor/refactor/introduce.test.ts | 4 ---- src/editor/refactor/refactorThis.test.ts | 4 ---- src/features/templates/__tests__/editor.test.ts | 5 ----- src/test/setup.ts | 4 ++++ 7 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/editor/lsp/completion.test.ts b/src/editor/lsp/completion.test.ts index 50698082f..047dcf47b 100644 --- a/src/editor/lsp/completion.test.ts +++ b/src/editor/lsp/completion.test.ts @@ -239,10 +239,6 @@ describe("lookup commit characters", () => { }); describe("explicit completion", () => { - // jsdom has no layout; CodeMirror measures text when the tooltip is positioned. - Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; - Range.prototype.getBoundingClientRect ??= () => new DOMRect(); - function editor(labels: string[]) { const source: CompletionSource = (ctx) => { const word = ctx.matchBefore(/\w*/); diff --git a/src/editor/lsp/signature.test.ts b/src/editor/lsp/signature.test.ts index 51cd1d973..99cfc7ef0 100644 --- a/src/editor/lsp/signature.test.ts +++ b/src/editor/lsp/signature.test.ts @@ -16,10 +16,6 @@ const { signatureExtension, acceptsParameter, argumentSpans, argumentStop, param ); const { lspDoc } = await import("./lsp"); -// jsdom has no layout; CodeMirror measures text when the tooltip is positioned. -Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; -Range.prototype.getBoundingClientRect ??= () => new DOMRect(); - const views: EditorView[] = []; afterEach(() => { views.splice(0).forEach((v) => v.destroy()); diff --git a/src/editor/lsp/syntaxFallback.test.ts b/src/editor/lsp/syntaxFallback.test.ts index 8170d8934..5ad76d982 100644 --- a/src/editor/lsp/syntaxFallback.test.ts +++ b/src/editor/lsp/syntaxFallback.test.ts @@ -35,9 +35,6 @@ const { startRename } = await import("./rename"); const { lspDoc } = await import("./lsp"); const { useLspPopup, closePopup } = await import("./popupStore"); -Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; -Range.prototype.getBoundingClientRect ??= () => new DOMRect(); - const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } }); const node = (kind: string, range: Range, field: string | null = null): SyntaxNode => ({ kind, diff --git a/src/editor/refactor/introduce.test.ts b/src/editor/refactor/introduce.test.ts index 04f6bfd88..181c6f9a0 100644 --- a/src/editor/refactor/introduce.test.ts +++ b/src/editor/refactor/introduce.test.ts @@ -120,10 +120,6 @@ const { clearLevelCache } = await import("./projectLevel"); // --- editor --------------------------------------------------------------------- -// jsdom has no layout; popups anchor at caret coordinates. -Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; -Range.prototype.getBoundingClientRect ??= () => new DOMRect(); - let view: EditorView | null = null; function editor(doc: string, language: string, path: string, sel: { anchor: number; head?: number }) { diff --git a/src/editor/refactor/refactorThis.test.ts b/src/editor/refactor/refactorThis.test.ts index 280b603bb..fbe7b802c 100644 --- a/src/editor/refactor/refactorThis.test.ts +++ b/src/editor/refactor/refactorThis.test.ts @@ -55,10 +55,6 @@ const { lspDoc } = await import("@/editor/lsp/lsp"); const { inplaceRenameExtension, inplaceSession } = await import("@/editor/lsp/inplaceRename"); const { temporaryHighlights } = await import("@/editor/lsp/tempHighlight"); -// jsdom has no layout; popups anchor at the caret. -Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList; -Range.prototype.getBoundingClientRect ??= () => new DOMRect(); - const action = (title: string, kind: string, disabledReason: string | null = null): CodeAction => ({ title, kind, diff --git a/src/features/templates/__tests__/editor.test.ts b/src/features/templates/__tests__/editor.test.ts index af6980e6f..e17c9e9b6 100644 --- a/src/features/templates/__tests__/editor.test.ts +++ b/src/features/templates/__tests__/editor.test.ts @@ -17,11 +17,6 @@ import { closeChooser, useChooser } from "../popup/store"; const MODES: Record = { typescript: "TypeScript", rust: "Rust", python: "Python", java: "Java" }; -// jsdom has no layout; CodeMirror measures ranges when scrolling the caret into view. -const rects = () => Object.assign([], { item: () => null }) as unknown as DOMRectList; -Range.prototype.getClientRects ??= rects; -Range.prototype.getBoundingClientRect ??= () => new DOMRect(); - let view: EditorView | null = null; afterEach(() => { view?.destroy(); diff --git a/src/test/setup.ts b/src/test/setup.ts index 2dfbc6b6b..066875e32 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -39,5 +39,9 @@ if (typeof window !== "undefined") { }) as MediaQueryList; } Element.prototype.scrollIntoView ??= function () {}; + // No layout either: CodeMirror measures text ranges when it positions the caret, + // tooltips and line heights, sometimes in an animation frame after a test ends. + Range.prototype.getClientRects ??= () => Object.assign([], { item: () => null }) as unknown as DOMRectList; + Range.prototype.getBoundingClientRect ??= () => new DOMRect(); Element.prototype.scrollTo ??= function () {} as Element["scrollTo"]; } From b4a3e866c61d96c85530a5d9e882eb6811351b08 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:18 +0800 Subject: [PATCH 06/27] fix(terminal): render the terminal in the bundled JetBrains Mono (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the terminal built its own font-family ("JetBrains Mono", Nerd Fonts, monospace) without the bundled "JetBrains Mono Variable", so on Windows (no JetBrains Mono installed) xterm fell back to the system monospace (SimSun on Chinese Windows); xterm also measured cells before the webfont loaded. Fix: one resolver (src/platform/fonts.ts monoFontFamily) maps JetBrains Mono to the bundled face; xterm re-measures once fonts load; "Use editor font" also takes the editor's fallback font (IDEA). Siblings covered: embedded run terminal, editor default family + fallback, Settings › Editor › Font preview. Verified in-app on macOS (terminal renders JetBrains Mono Variable). Not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/editor/core/cm/setup.ts | 6 +- src/features/settings/pages/editor/font.tsx | 3 +- src/features/terminal/embedded.ts | 9 +- src/features/terminal/session.ts | 14 ++- src/features/terminal/terminal.test.ts | 37 +++++-- src/features/terminal/theme.ts | 113 ++++++++++++-------- src/platform/fonts.test.ts | 24 +++++ src/platform/fonts.ts | 45 ++++++++ 8 files changed, 185 insertions(+), 66 deletions(-) create mode 100644 src/platform/fonts.test.ts create mode 100644 src/platform/fonts.ts diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index ed2c43fcb..4a47ce40b 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -26,6 +26,7 @@ import { highlightSelectionMatches } from "@codemirror/search"; import type { EditorConfigProps } from "@/bindings"; import type { EditorSettings } from "@/platform/ipc"; import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings"; +import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts"; import { editorContributions, type EditorDocContext } from "@/editor/api"; import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree"; import { openideaHighlightStyle } from "./highlight"; @@ -160,9 +161,8 @@ export function fontExtension(settings: EditorSettings | undefined): Extension { const prefs = prefsOf(settings); const family = settings?.fontFamily; const fallback = prefs.fallbackFont.trim(); - if ((family && family !== "JetBrains Mono") || fallback) { - const fam = (family || "JetBrains Mono").replace(/"/g, ""); - vars.push(`--openidea-editor-font: "${fam}"${fallback ? `, "${fallback.replace(/"/g, "")}"` : ""}, var(--font-mono)`); + if ((family && family !== BUNDLED_MONO_FAMILY) || fallback) { + vars.push(`--openidea-editor-font: ${monoFontFamily(family, fallback, "var(--font-mono)")}`); } if (settings?.fontSize) vars.push(`--openidea-editor-font-size: ${settings.fontSize}px`, `--openidea-editor-base-size: ${settings.fontSize}px`); if (settings?.lineHeight) vars.push(`--openidea-editor-line-height: ${settings.lineHeight}px`, `--openidea-editor-base-line: ${settings.lineHeight}px`); diff --git a/src/features/settings/pages/editor/font.tsx b/src/features/settings/pages/editor/font.tsx index a940f17b0..b648bf5f4 100644 --- a/src/features/settings/pages/editor/font.tsx +++ b/src/features/settings/pages/editor/font.tsx @@ -1,6 +1,7 @@ /** Editor › Font: family (installed fonts, monospaced filter), size, line height, ligatures, fallback, editable preview. */ import { useMemo, useState } from "react"; import { Checkbox, type SelectOption, T } from "@/ui"; +import { monoFontFamily } from "@/platform/fonts"; import { NumberInput, Row, SearchLabel, Select } from "../../components/Form"; import { useLabelMatch, useSetting } from "../../components/context"; import { LIMITS } from "../../model/schema"; @@ -149,7 +150,7 @@ function Preview() { const [line] = useSetting("editor.lineHeight"); const [ligatures] = useSetting("editor.ligatures"); const [text, setText] = useState(SAMPLE); - const fam = `"${family.replace(/"/g, "")}"${fallback ? `, "${fallback.replace(/"/g, "")}"` : ""}, var(--font-mono)`; + const fam = monoFontFamily(family, fallback, "var(--font-mono)"); return ( <>
diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index f307bbdc9..d8133fc72 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -11,8 +11,8 @@ import type { EmbeddedTerminal, EmbeddedTerminalOptions } from "@/workbench/cont import { useProject } from "@/platform/project"; import { workflowSettings } from "@/platform/workflowSettings"; import { findTerminalLinks } from "./links"; -import { openExternal, openFileLink } from "./session"; -import { readTerminalTheme, terminalFont, withFontExtras } from "./theme"; +import { fontFor, openExternal, openFileLink } from "./session"; +import { readTerminalTheme, remeasureWhenFontsLoad } from "./theme"; const live = new Set(); @@ -24,9 +24,7 @@ export function applyThemeToEmbedded() { export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminalOptions): EmbeddedTerminal { const settings = workflowSettings().terminal; - const editor = useProject.getState().settings?.editor as - { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined; - const font = withFontExtras(terminalFont(settings, editor), settings); + const font = fontFor(settings); const term = new Terminal({ fontFamily: font.family, fontSize: font.size, @@ -89,6 +87,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal const observer = new ResizeObserver(scheduleFit); observer.observe(host); scheduleFit(); + remeasureWhenFontsLoad(term, font, () => live.has(term), scheduleFit); // The owner learns the initial size even when it matches xterm's default. queueMicrotask(() => opts.onResize(term.cols, term.rows)); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index e8be87d27..140bb5526 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -14,7 +14,8 @@ import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings"; -import { readSearchDecorations, readTerminalTheme, terminalFont, withFontExtras } from "./theme"; +import { editorPrefs } from "@/platform/editorSettings"; +import { readSearchDecorations, readTerminalTheme, remeasureWhenFontsLoad, terminalFont, type TerminalFontSettings } from "./theme"; export interface TerminalSearchOptions { caseSensitive: boolean; @@ -77,9 +78,10 @@ function beep() { } } -function fontFor(t: TerminalSettingsValid) { +/** The terminal font for `t`, resolved against the current editor font (also used by embedded terminals). */ +export function fontFor(t: TerminalFontSettings) { const e = useProject.getState().settings?.editor as { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined; - return withFontExtras(terminalFont(t, e), t); + return terminalFont(t, { ...e, fallbackFont: editorPrefs().fallbackFont }); } const CURSOR: Record = { @@ -237,7 +239,10 @@ export class TerminalSession { applySettings(t: TerminalSettingsValid) { const font = fontFor(t); const o = this.term.options; - if (o.fontFamily !== font.family) o.fontFamily = font.family; + if (o.fontFamily !== font.family) { + o.fontFamily = font.family; + if (this.opened) remeasureWhenFontsLoad(this.term, font, () => !this.disposed, () => this.scheduleFit()); + } if (o.fontSize !== font.size) o.fontSize = font.size; if (o.lineHeight !== font.lineHeight) o.lineHeight = font.lineHeight; if (o.letterSpacing !== font.letterSpacing) o.letterSpacing = font.letterSpacing; @@ -288,6 +293,7 @@ export class TerminalSession { // xterm's DOM renderer: the WebGL one rasterizes glyphs per background, so // selected text rendered at a visibly different weight than the rest. this.term.open(this.host); + remeasureWhenFontsLoad(this.term, fontFor(workflowSettings().terminal), () => !this.disposed, () => this.scheduleFit()); this.term.textarea?.addEventListener("focus", () => this.opts.onFocus(this.key)); } this.resizeObserver?.disconnect(); diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 4632c1dc7..8b89f0942 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -5,7 +5,7 @@ import { buildSearchDecorations } from "./theme"; import { findTerminalLinks, resolvePath, trimUrl } from "./links"; import { layoutPanes, nextTabName, removeFromLayout, splitLayout, tabFullTitle, tabTitle, trimMiddle, type TerminalTab } from "./store"; import type { SessionSnapshot } from "./session"; -import { buildTheme, mix, parseColor, toHex, withFontExtras } from "./theme"; +import { buildTheme, mix, parseColor, terminalFont, toHex } from "./theme"; describe("theme", () => { it("parses colour formats", () => { @@ -208,19 +208,36 @@ describe("split layout", () => { }); }); -describe("withFontExtras", () => { - const font = { family: '"JetBrains Mono", "Symbols Nerd Font", monospace', size: 10, lineHeight: 1 }; +describe("terminalFont", () => { + const BASE = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace'; + const t = { useEditorFont: true, fontFamily: "JetBrains Mono", fontSize: 10, lineHeight: 1, fallbackFontFamily: "", columnWidth: 1 }; + const editor = { fontFamily: "JetBrains Mono", fontSize: 10, lineHeight: 12 }; + const head = (family: string) => family.slice(0, family.indexOf('"Symbols Nerd Font Mono"')); - it("puts the fallback family right after the main one", () => { - expect(withFontExtras(font, { fallbackFontFamily: "PingFang SC", columnWidth: 1 }).family).toBe( - '"JetBrains Mono", "PingFang SC", "Symbols Nerd Font", monospace', - ); - expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1 }).family).toBe(font.family); + it.each([ + // #26: "Use editor font" with the default family must reach the bundled face, not the system monospace. + ["editor font, default family", t, editor, `"JetBrains Mono Variable", "JetBrains Mono", ${BASE.replace(", monospace", "")}, `], + ["editor font, no editor settings", t, undefined, `"JetBrains Mono Variable", "JetBrains Mono", ${BASE.replace(", monospace", "")}, `], + ["editor font, custom family", t, { ...editor, fontFamily: "Fira Code" }, `"Fira Code", ${BASE.replace(", monospace", "")}, `], + ["editor font, editor fallback", t, { ...editor, fallbackFont: "PingFang SC" }, `"JetBrains Mono Variable", "JetBrains Mono", "PingFang SC", ${BASE.replace(", monospace", "")}, `], + ["own font, default family", { ...t, useEditorFont: false }, editor, `"JetBrains Mono Variable", "JetBrains Mono", ${BASE.replace(", monospace", "")}, `], + ["own font, custom + fallback", { ...t, useEditorFont: false, fontFamily: "Hack", fallbackFontFamily: "PingFang SC" }, editor, `"Hack", "PingFang SC", ${BASE.replace(", monospace", "")}, `], + ])("%s resolves through the bundled stack", (_, settings, ed, expected) => { + const font = terminalFont(settings, ed, BASE); + expect(head(font.family)).toBe(expected); + expect(font.family.endsWith(", monospace")).toBe(true); + expect(font.family).not.toContain("var("); + }); + + it("prefers the terminal's fallback over the editor's", () => { + const font = terminalFont({ ...t, fallbackFontFamily: "Sarasa Mono SC" }, { ...editor, fallbackFont: "PingFang SC" }, BASE); + expect(font.family).toContain('"Sarasa Mono SC"'); + expect(font.family).not.toContain("PingFang SC"); }); it("turns the column width into letter spacing", () => { - expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1 }).letterSpacing).toBe(0); - expect(withFontExtras(font, { fallbackFontFamily: "", columnWidth: 1.5 }).letterSpacing).toBe(3); + expect(terminalFont({ ...t, useEditorFont: false }, editor, BASE).letterSpacing).toBe(0); + expect(terminalFont({ ...t, useEditorFont: false, columnWidth: 1.5 }, editor, BASE).letterSpacing).toBe(3); }); }); diff --git a/src/features/terminal/theme.ts b/src/features/terminal/theme.ts index 8d296015b..944f4274a 100644 --- a/src/features/terminal/theme.ts +++ b/src/features/terminal/theme.ts @@ -4,7 +4,8 @@ * tokens (PyCharm's console colours per theme); when a theme lacks them it is * derived from the status + syntax tokens. */ -import type { ITheme } from "@xterm/xterm"; +import type { ITheme, Terminal } from "@xterm/xterm"; +import { BUNDLED_MONO_FAMILY, fontsLoaded, monoFontFamily, monoStack } from "@/platform/fonts"; export type Rgba = [number, number, number, number]; @@ -177,56 +178,82 @@ const NERD_FONT_FALLBACKS = [ .map((f) => `"${f}"`) .join(", "); -export function readTerminalFont(): { family: string; size: number; lineHeight: number } { - const style = getComputedStyle(document.documentElement); - const base = style.getPropertyValue("--font-mono").trim() || "'JetBrains Mono', monospace"; - // Prompt themes (powerlevel10k, starship) use Nerd Font private-use glyphs; - // list common installs so per-glyph fallback finds them. - const family = `${base}, ${NERD_FONT_FALLBACKS}`; - const size = parseFloat(style.getPropertyValue("--fs-code")) || 13; - const lh = parseFloat(style.getPropertyValue("--lh-code")) || 21; - // xterm's lineHeight is a multiplier of the cell height (≈ 1.2 × font size); aim for the editor's line height. - return { family, size, lineHeight: Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100) }; +export interface TerminalFontSettings { + useEditorFont: boolean; + fontFamily: string; + fontSize: number; + lineHeight: number; + fallbackFontFamily: string; + columnWidth: number; +} + +export interface EditorFont { + fontFamily?: string; + fontSize?: number; + lineHeight?: number; + fallbackFont?: string; +} + +export interface TerminalFont { + family: string; + size: number; + lineHeight: number; + letterSpacing: number; } /** - * Font from Settings › Tools › Terminal: the editor font (Editor › Font) or the - * terminal's own family / size / line height (a multiple of the cell height). + * Prompt themes (powerlevel10k, starship) use Nerd Font private-use glyphs; list + * common installs before the generic family so per-glyph fallback finds them. */ -export function terminalFont( - t: { useEditorFont: boolean; fontFamily: string; fontSize: number; lineHeight: number }, - editor: { fontFamily?: string; fontSize?: number; lineHeight?: number } | undefined, -): { family: string; size: number; lineHeight: number } { +function withNerdFonts(family: string): string { + return `${family.replace(/,\s*monospace\s*$/i, "")}, ${NERD_FONT_FALLBACKS}, monospace`; +} + +function cssNumber(name: string): number { + if (typeof document === "undefined") return NaN; + return parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name)); +} + +/** + * Font from Settings › Tools › Terminal: the editor font (Editor › Font, with its + * fallback) or the terminal's own family / size / line height (a multiple of the + * cell height). The family resolves like the editor's (`monoFontFamily`), so the + * bundled JetBrains Mono is used where it isn't installed; `base` is the resolved + * `--font-mono` stack, since xterm measures on a canvas and can't use `var()`. + * The column width (a multiple of the cell width) becomes xterm's letter spacing, + * from the ≈ 0.6 em advance of monospace fonts. + */ +export function terminalFont(t: TerminalFontSettings, editor: EditorFont | undefined, base = monoStack()): TerminalFont { + let family: string, fallback: string, size: number, lineHeight: number; if (!t.useEditorFont) { - return { family: `"${t.fontFamily.replace(/"/g, "")}", ${NERD_FONT_FALLBACKS}, monospace`, size: t.fontSize, lineHeight: t.lineHeight }; + family = t.fontFamily; + fallback = t.fallbackFontFamily; + size = t.fontSize; + lineHeight = t.lineHeight; + } else { + family = editor?.fontFamily || BUNDLED_MONO_FAMILY; + fallback = t.fallbackFontFamily.trim() || editor?.fallbackFont || ""; + size = editor?.fontSize || cssNumber("--fs-code") || 13; + const lh = editor?.lineHeight ?? (editor?.fontSize ? size * 1.6 : cssNumber("--lh-code") || 21); + // xterm's lineHeight is a multiplier of the cell height (≈ 1.2 × font size); aim for the editor's line height. + lineHeight = Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100); } - if (!editor?.fontFamily || !editor.fontSize) return readTerminalFont(); - const size = editor.fontSize; - const lh = editor.lineHeight ?? size * 1.6; - return { - family: `"${editor.fontFamily.replace(/"/g, "")}", ${NERD_FONT_FALLBACKS}, monospace`, - size, - lineHeight: Math.max(1, Math.round((lh / (size * 1.2)) * 100) / 100), - }; + const letterSpacing = Math.round((t.columnWidth - 1) * size * 0.6 * 10) / 10; + return { family: withNerdFonts(monoFontFamily(family, fallback, base)), size, lineHeight, letterSpacing }; } /** - * Settings › Tools › Terminal extras over `terminalFont`: the fallback family - * goes right after the main one, and the column width (a multiple of the cell - * width) becomes xterm's letter spacing, from the ≈ 0.6 em advance of - * monospace fonts. + * xterm measures its cell once per font change; a webfont still loading (the + * bundled JetBrains Mono on first open) leaves cells sized for the swap font. + * Re-measures once the faces load (unless `alive()` turned false), then calls `after` (refit). */ -export function withFontExtras( - font: { family: string; size: number; lineHeight: number }, - t: { fallbackFontFamily: string; columnWidth: number }, -): { family: string; size: number; lineHeight: number; letterSpacing: number } { - const fallback = t.fallbackFontFamily.replace(/"/g, "").trim(); - const comma = font.family.indexOf(","); - const family = !fallback - ? font.family - : comma < 0 - ? `${font.family}, "${fallback}"` - : `${font.family.slice(0, comma)}, "${fallback}"${font.family.slice(comma)}`; - const letterSpacing = Math.round((t.columnWidth - 1) * font.size * 0.6 * 10) / 10; - return { ...font, family, letterSpacing }; +export function remeasureWhenFontsLoad(term: Terminal, font: TerminalFont, alive: () => boolean, after: () => void) { + void fontsLoaded(font.family, font.size).then(() => { + if (!alive()) return; + const o = term.options; + if (o.fontFamily !== font.family) return; + o.fontFamily = "monospace"; + o.fontFamily = font.family; + after(); + }); } diff --git a/src/platform/fonts.test.ts b/src/platform/fonts.test.ts new file mode 100644 index 000000000..f062c459d --- /dev/null +++ b/src/platform/fonts.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { monoFontFamily } from "./fonts"; + +const BASE = "var(--font-mono)"; + +describe("monoFontFamily", () => { + it.each([ + // #26: the default family names the bundled face, so it renders without a system install. + ["JetBrains Mono", "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`], + ["", "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`], + [undefined, "", `"JetBrains Mono Variable", "JetBrains Mono", ${BASE}`], + ["JetBrains Mono", "PingFang SC", `"JetBrains Mono Variable", "JetBrains Mono", "PingFang SC", ${BASE}`], + ["Fira Code", "", `"Fira Code", ${BASE}`], + ['"Cascadia Code"', " Microsoft YaHei ", `"Cascadia Code", "Microsoft YaHei", ${BASE}`], + ])("%j + fallback %j", (family, fallback, expected) => { + expect(monoFontFamily(family, fallback, BASE)).toBe(expected); + }); + + it("always ends with the bundled stack, so a missing family never reaches the system default", () => { + expect(monoFontFamily("Not Installed", "", '"JetBrains Mono Variable", monospace')).toBe( + '"Not Installed", "JetBrains Mono Variable", monospace', + ); + }); +}); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts new file mode 100644 index 000000000..b5413b753 --- /dev/null +++ b/src/platform/fonts.ts @@ -0,0 +1,45 @@ +/** + * Monospace font-family lists shared by every consumer of the editor / terminal + * font. "JetBrains Mono" is the bundled default, but the bundle registers it as + * "JetBrains Mono Variable" (`@fontsource-variable/jetbrains-mono`); a list that + * only names "JetBrains Mono" falls through to the system's generic monospace + * wherever the font isn't installed (SimSun on Chinese Windows). + */ + +export const BUNDLED_MONO_FAMILY = "JetBrains Mono"; +const BUNDLED_MONO_FACES = ['"JetBrains Mono Variable"', '"JetBrains Mono"']; +const MONO_STACK_FALLBACK = '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace'; + +const quote = (f: string) => `"${f.replace(/"/g, "").trim()}"`; + +/** The `--font-mono` stack, resolved (canvas / xterm can't use `var()`). */ +export function monoStack(): string { + if (typeof document === "undefined") return MONO_STACK_FALLBACK; + return getComputedStyle(document.documentElement).getPropertyValue("--font-mono").trim() || MONO_STACK_FALLBACK; +} + +/** + * `family`, then `fallback`, then `base` (the `--font-mono` stack: `var(--font-mono)` + * for CSS, `monoStack()` for canvas measurement). The bundled family maps to its + * bundled face, so it renders without a system install. + */ +export function monoFontFamily(family: string | undefined, fallback = "", base = monoStack()): string { + const name = (family ?? "").replace(/"/g, "").trim(); + const fb = fallback.replace(/"/g, "").trim(); + const head = !name || name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)]; + return [...head, ...(fb ? [quote(fb)] : []), base].join(", "); +} + +/** + * Resolves once the faces `family` uses at `size` are loaded, so code that measures + * glyphs (xterm's cell size) measures the real font rather than the swap fallback. + */ +export async function fontsLoaded(family: string, size: number): Promise { + const fonts = typeof document !== "undefined" ? document.fonts : undefined; + if (!fonts?.load) return; + try { + await Promise.all([fonts.load(`${size}px ${family}`), fonts.load(`bold ${size}px ${family}`)]); + } catch { + /* an unparsable list measures with whatever is available */ + } +} From 642b80cef04afbb4668b21c87d335f6a84fbd1f8 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:26 +0800 Subject: [PATCH 07/27] fix(workbench): Keep a way out of Distraction Free Mode on Windows (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: on Windows the window is frameless and the main menu lives in the custom title bar; Distraction Free Mode rendered a bare title bar without it, the toggle has no default shortcut (as in IDEA) and the mode persists across restarts, leaving no way out. Fix (IDEA): the bare title bar keeps the main menu when there is no native menu bar; a lone Alt opens the main menu; entering the mode (or restoring it) shows "To exit Distraction Free Mode, use View | Appearance … or Find Action" with an Exit action, in its own notification group. Siblings covered: Distraction Free + Full Screen on Windows; other chrome-hiding settings checked (unaffected). Verified in-app on macOS (hint, Exit action, single Don't Show Again). Windows-only parts (☰ in bare title bar, lone Alt) not verified on Windows. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/workbench/MainMenu.test.ts | 61 +++++++++++++++++++++++- src/workbench/MainMenu.tsx | 37 ++++++++++++++- src/workbench/TitleBar.test.tsx | 34 ++++++++++++-- src/workbench/TitleBar.tsx | 3 ++ src/workbench/distractionFree.test.tsx | 64 +++++++++++++++++++++++++- src/workbench/distractionFree.ts | 43 +++++++++++++++-- src/workbench/notificationStore.ts | 1 + 7 files changed, 232 insertions(+), 11 deletions(-) diff --git a/src/workbench/MainMenu.test.ts b/src/workbench/MainMenu.test.ts index 55cd4bf3b..e8b52d985 100644 --- a/src/workbench/MainMenu.test.ts +++ b/src/workbench/MainMenu.test.ts @@ -1,5 +1,56 @@ import { describe, expect, it } from "vitest"; -import { menuItems } from "./MainMenu"; +import { menuItems, onLoneAlt } from "./MainMenu"; + +describe("lone Alt opens the main menu", () => { + const key = (type: string, k: string, init: KeyboardEventInit = {}) => + window.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true, cancelable: true, ...init })); + it.each<[string, () => void, number]>([ + ["Alt alone", () => (key("keydown", "Alt", { altKey: true }), key("keyup", "Alt")), 1], + [ + "Alt+1", + () => ( + key("keydown", "Alt", { altKey: true }), + key("keydown", "1", { altKey: true }), + key("keyup", "1"), + key("keyup", "Alt") + ), + 0, + ], + [ + "Ctrl+Alt", + () => ( + key("keydown", "Control", { ctrlKey: true }), + key("keydown", "Alt", { ctrlKey: true, altKey: true }), + key("keyup", "Alt") + ), + 0, + ], + [ + "Alt+click", + () => ( + key("keydown", "Alt", { altKey: true }), + window.dispatchEvent(new Event("pointerdown")), + key("keyup", "Alt") + ), + 0, + ], + [ + "held Alt (repeat)", + () => ( + key("keydown", "Alt", { altKey: true }), + key("keydown", "Alt", { altKey: true, repeat: true }), + key("keyup", "Alt") + ), + 0, + ], + ])("%s", (_, press, expected) => { + let opened = 0; + const off = onLoneAlt(window, () => void opened++); + press(); + off(); + expect(opened).toBe(expected); + }); +}); describe("main menu model", () => { it("maps menu nodes to menu items, localising spec labels only", () => { @@ -11,7 +62,13 @@ describe("main menu model", () => { id: "openRecent", label: "Open Recent", children: [ - { kind: "action", id: "file.openRecentProject", label: "openidea — ~/openidea", hint: null, arg: "/p/openidea" }, + { + kind: "action", + id: "file.openRecentProject", + label: "openidea — ~/openidea", + hint: null, + arg: "/p/openidea", + }, { kind: "placeholder", id: "recent.none", label: "No Recent Projects" }, ], }, diff --git a/src/workbench/MainMenu.tsx b/src/workbench/MainMenu.tsx index 55fcef048..8dc9061fa 100644 --- a/src/workbench/MainMenu.tsx +++ b/src/workbench/MainMenu.tsx @@ -2,9 +2,10 @@ * Windows title bar: IDEA's main menu button. Clicking ☰ expands the menu bar * in place (File Edit View …) with File open; hovering another title switches * menus, ←/→ walk them, and closing the menu collapses the bar again. The - * entries come from the same spec as the native menu (`menu_model`). + * entries come from the same spec as the native menu (`menu_model`). As in + * Windows apps and IDEA, pressing and releasing Alt alone opens it too. */ -import { useRef, useState, type KeyboardEvent } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { lookup, t } from "@/platform/i18n"; import { commands, type MenuNode } from "@/platform/ipc"; @@ -38,6 +39,37 @@ export function menuItems(nodes: MenuNode[]): MenuItem[] { }); } +/** + * Calls `onAlt` when Alt is pressed and released with nothing else in between + * (no other key, no click): Alt+1, Alt+Enter or Alt+click never open the menu. + */ +export function onLoneAlt(target: Window, onAlt: () => void): () => void { + let armed = false; + const down = (e: globalThis.KeyboardEvent) => { + armed = e.key === "Alt" && !e.repeat && !e.ctrlKey && !e.shiftKey && !e.metaKey; + }; + const up = (e: globalThis.KeyboardEvent) => { + const fire = armed && e.key === "Alt"; + armed = false; + if (!fire) return; + e.preventDefault(); + onAlt(); + }; + const cancel = () => { + armed = false; + }; + target.addEventListener("keydown", down, true); + target.addEventListener("keyup", up, true); + target.addEventListener("pointerdown", cancel, true); + target.addEventListener("blur", cancel); + return () => { + target.removeEventListener("keydown", down, true); + target.removeEventListener("keyup", up, true); + target.removeEventListener("pointerdown", cancel, true); + target.removeEventListener("blur", cancel); + }; +} + export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: boolean) => void }) { const [menus, setMenus] = useState(null); const [open, setOpen] = useState(null); @@ -65,6 +97,7 @@ export function MainMenu({ onExpandedChange }: { onExpandedChange?: (expanded: b else collapse(); }); }; + useEffect(() => onLoneAlt(window, () => (menus ? collapse() : void expand()))); const step = (delta: number) => { if (!menus || open === null) return; setOpen((open + delta + menus.length) % menus.length); diff --git a/src/workbench/TitleBar.test.tsx b/src/workbench/TitleBar.test.tsx index eccac34b0..eb99647a4 100644 --- a/src/workbench/TitleBar.test.tsx +++ b/src/workbench/TitleBar.test.tsx @@ -1,6 +1,9 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./WindowControls", () => ({ WindowControls: () => null })); import { fireEvent, render, screen } from "@testing-library/react"; import { registerActions } from "@/platform/actions"; +import { usePlatform } from "@/platform/platformInfo"; import { useThemeOverride } from "./theme"; import { customizeToolbarItems, gearMenuItems, TitleBar } from "./TitleBar"; @@ -44,11 +47,15 @@ describe("title bar gear menu", () => { const items = customizeToolbarItems( { left: ["project", "vcs.branch"], right: ["action:navigate.searchEverywhere"] }, ["run.widget"], - new Map([["run.widget", { id: "run.widget", align: "right", priority: 1, component: () => null, title: "Run Widget" }]]), + new Map([ + ["run.widget", { id: "run.widget", align: "right", priority: 1, component: () => null, title: "Run Widget" }], + ]), (id) => toggled.push(id), () => void reset++, ); - const rows = items.filter((i): i is { id: string; label: string; checked: boolean; run: () => void } => "checked" in i); + const rows = items.filter( + (i): i is { id: string; label: string; checked: boolean; run: () => void } => "checked" in i, + ); expect(rows.map((r) => [r.label, r.checked])).toEqual([ ["Project Widget", true], ["VCS Widget", true], @@ -75,3 +82,24 @@ describe("title bar gear menu", () => { expect(useThemeOverride.getState().theme).toBe("dark-hc"); }); }); + +describe("title bar in Distraction Free Mode (bare)", () => { + // The main menu is the way back out of a view mode that hides the toolbar: + // the title bar's ☰ where the window is frameless (Windows), the OS menu bar elsewhere. + it.each([ + { os: "windows", overlayTitleBar: false, framelessTitleBar: true, mainMenu: true }, + { os: "macos", overlayTitleBar: true, framelessTitleBar: false, mainMenu: false }, + { os: "linux", overlayTitleBar: false, framelessTitleBar: false, mainMenu: false }, + ])("$os: keeps the main menu button where there is no native menu bar", ({ mainMenu, ...platform }) => { + const saved = usePlatform.getState(); + usePlatform.setState(platform); + try { + const { container } = render(); + expect(container.querySelector("header[data-titlebar='bare']")).not.toBeNull(); + expect(screen.queryByRole("button", { name: "IDE and Project Settings" })).toBeNull(); + expect(!!screen.queryByRole("button", { name: "Main Menu" })).toBe(mainMenu); + } finally { + usePlatform.setState(saved); + } + }); +}); diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 86f4b70b1..21266e1db 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -275,11 +275,14 @@ export function TitleBar({ const { map, shown, menu } = useToolbar(); const [menuExpanded, setMenuExpanded] = useState(false); const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless); + // Distraction Free Mode: the window frame only. Where the title bar carries the + // main menu (Windows) it stays, or nothing would lead back out of the mode. if (bare) return (
{frameless && ( <> +
diff --git a/src/workbench/distractionFree.test.tsx b/src/workbench/distractionFree.test.tsx index 88bd3be49..8d85c547f 100644 --- a/src/workbench/distractionFree.test.tsx +++ b/src/workbench/distractionFree.test.tsx @@ -5,8 +5,12 @@ import { getContext } from "@/platform/context"; import { bindingsFor, installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { registerStatusItem, registerToolWindow } from "./contributions"; +import { clearNotifications, useNotifications } from "./notificationStore"; +import { notificationActions } from "./Notifications"; import { DISTRACTION_FREE_ACTION, + DISTRACTION_FREE_HINT_KEY, + distractionFreeHint, distractionFreeWidth, installDistractionFree, isDistractionFree, @@ -127,6 +131,62 @@ describe("distraction free mode", () => { setDistractionFree(false); }); + describe("exit hint", () => { + const hint = () => useNotifications.getState().items.find((n) => n.key === DISTRACTION_FREE_HINT_KEY); + beforeEach(() => clearNotifications()); + + it("tells how to leave the mode on entering, with an Exit action, and expires on exit", () => { + setDistractionFree(true); + expect(hint()?.balloon).toBe(true); + expect(hint()?.body).toBe(distractionFreeHint()); + expect(distractionFreeHint()).toContain("View | Appearance | Exit Distraction Free Mode"); + // Unbound toggle (IntelliJ keymap): Find Action with its shortcut is the keyboard way out. + expect(distractionFreeHint()).toMatch(/Find Action \(.+\)/); + hint()! + .actions!.find((a) => a.label === "Exit Distraction Free Mode")! + .run(); + expect(isDistractionFree()).toBe(false); + expect(hint()?.balloon ?? false).toBe(false); + }); + + it("names the toggle's own shortcut when it has one", () => { + useKeymap.setState({ overrides: { [DISTRACTION_FREE_ACTION]: ["Ctrl+Alt+Z"] } }); + expect(distractionFreeHint()).not.toContain("Find Action"); + }); + + it("shows again after a restart in the mode, once the project frame loads", () => { + setDistractionFree(true); + clearNotifications(); + useDistractionFree.setState({ active: false }); + resetToolWindowLayout(); + disposers.push(installDistractionFree()); + expect(isDistractionFree()).toBe(true); + expect(hint()).toBeUndefined(); + loadToolWindowLayout("/work/df"); + expect(hint()?.balloon).toBe(true); + }); + + it("offers Don't Show Again once, scoped to its own notification group", () => { + setDistractionFree(true); + const labels = notificationActions(hint()!).map((a) => a.label); + expect(labels.filter((l) => l === "Don't Show Again")).toHaveLength(1); + expect(hint()!.group).toBe("distractionFree"); + }); + + it("stays off once its group shows no popup", () => { + const settings = useProject.getState().settings; + useProject.setState({ + settings: { ...settings, notifications: { groups: { distractionFree: { display: "none" } } } } as never, + }); + try { + setDistractionFree(true); + expect(hint()?.balloon ?? false).toBe(false); + } finally { + useProject.setState({ settings }); + } + }); + }); + it("clamps the text width setting", () => { expect(distractionFreeWidth(undefined)).toBe(80); expect(distractionFreeWidth(100.4)).toBe(100); @@ -138,7 +198,9 @@ describe("distraction free mode", () => { describe("distraction free shell", () => { it("hides stripes, status bar and main toolbar contents, keeping the drag region", async () => { const { Workbench } = await import("./Workbench"); - disposers.push(registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => status-x })); + disposers.push( + registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => status-x }), + ); act(() => useProject.setState({ project: { root: "/work/df", name: "df-demo", isGit: false, settings: {} } })); const { unmount } = render(); expect(await screen.findByText("df-demo")).toBeInTheDocument(); diff --git a/src/workbench/distractionFree.ts b/src/workbench/distractionFree.ts index e34bd0230..74aaf91e1 100644 --- a/src/workbench/distractionFree.ts +++ b/src/workbench/distractionFree.ts @@ -7,11 +7,14 @@ * application-wide preference that survives restarts (localStorage, next to * the saved tool window layouts, which keep the layout to restore on exit). * Published as the `distractionFree` context key; components read - * `useDistractionFree`. + * `useDistractionFree`. Entering (or starting in) the mode shows IDEA's hint + * on how to leave it, since the main toolbar and status bar are gone. */ import { create } from "zustand"; import { registerAction, type Action } from "@/platform/actions"; import { setContext } from "@/platform/context"; +import { shortcutLabel } from "@/platform/keymap"; +import { expireNotification, notify } from "./notificationStore"; import { enterDistractionFreeLayout, exitDistractionFreeLayout, useToolWindowLayout } from "./toolwindows/store"; /** Kept from the original id so keymaps (VS Code's ⌘K Z, user bindings) stay bound. */ @@ -19,6 +22,7 @@ export const DISTRACTION_FREE_ACTION = "editor.toggleZen"; export const DISTRACTION_FREE_CONTEXT = "distractionFree"; export const DEFAULT_DISTRACTION_FREE_WIDTH = 80; const STORAGE_KEY = "openidea.distractionFree"; +export const DISTRACTION_FREE_HINT_KEY = "distractionFree.hint"; export const useDistractionFree = create<{ active: boolean }>(() => ({ active: false })); @@ -28,7 +32,9 @@ export function isDistractionFree(): boolean { /** Text width in columns (Settings › Editor › General › Appearance), clamped like IDEA's field. */ export function distractionFreeWidth(raw: unknown): number { - return typeof raw === "number" && Number.isFinite(raw) ? Math.min(500, Math.max(20, Math.round(raw))) : DEFAULT_DISTRACTION_FREE_WIDTH; + return typeof raw === "number" && Number.isFinite(raw) + ? Math.min(500, Math.max(20, Math.round(raw))) + : DEFAULT_DISTRACTION_FREE_WIDTH; } export function distractionFreeTitle(active: boolean): string { @@ -52,13 +58,41 @@ function readStored(): boolean { } } +/** IDEA's "To exit Distraction Free Mode, use View | Appearance or ". */ +export function distractionFreeHint(): string { + const own = shortcutLabel(DISTRACTION_FREE_ACTION); + const find = shortcutLabel("navigate.findAction"); + const way = own ?? (find ? `Find Action (${find})` : "Find Action"); + return `To exit Distraction Free Mode, use View | Appearance | Exit Distraction Free Mode or ${way}.`; +} + +/** Set on entering the mode; the hint shows once a project frame is there to leave. */ +let hintPending = false; + +function showHint() { + if (!hintPending || !isDistractionFree() || !useToolWindowLayout.getState().scope) return; + hintPending = false; + notify({ + key: DISTRACTION_FREE_HINT_KEY, + // Its own group: the balloon's "Don't Show Again" turns off this hint only (Settings › Notifications). + group: "distractionFree", + level: "info", + title: "Distraction Free Mode", + body: distractionFreeHint(), + actions: [{ label: "Exit Distraction Free Mode", run: () => setDistractionFree(false) }], + }); +} + export function setDistractionFree(active: boolean) { if (active === isDistractionFree()) return; useDistractionFree.setState({ active }); setContext(DISTRACTION_FREE_CONTEXT, active); writeStored(active); + hintPending = active; if (active) enterDistractionFreeLayout(); else exitDistractionFreeLayout(); + if (active) showHint(); + else expireNotification(DISTRACTION_FREE_HINT_KEY); } export function toggleDistractionFree() { @@ -89,7 +123,9 @@ export function installDistractionFree(): () => void { previous(); }); const unsubLayout = useToolWindowLayout.subscribe((s, prev) => { - if (s.scope !== prev.scope && isDistractionFree()) enterDistractionFreeLayout(); + if (s.scope === prev.scope || !isDistractionFree()) return; + enterDistractionFreeLayout(); + showHint(); }); setContext(DISTRACTION_FREE_CONTEXT, isDistractionFree()); if (readStored()) setDistractionFree(true); @@ -102,6 +138,7 @@ export function installDistractionFree(): () => void { /** Test helper. */ export function resetDistractionFree() { + hintPending = false; useDistractionFree.setState({ active: false }); setContext(DISTRACTION_FREE_CONTEXT, false); writeStored(false); diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index cdb966ab3..f67a384f5 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -34,6 +34,7 @@ export const NOTIFICATION_GROUPS: ReadonlyArray<{ id: string; title: string }> = { id: "search", title: "Find in Files" }, { id: "plugins", title: "Plugins" }, { id: "updates", title: "IDE Updates" }, + { id: "distractionFree", title: "Distraction Free Mode" }, ]; export type NotificationDisplay = "balloon" | "sticky" | "none"; From e15010124460db9e2b186e546be4162ccc82c0d3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:38:38 +0800 Subject: [PATCH 08/27] feat(debugger): Remote debug run configurations (Remote JVM, Go, Python, Node.js) (#50) Root cause: run configurations could only launch a process; there was no "attach to a debugger that is already listening" type, and debugServer only connected to 127.0.0.1. Fix (IDEA / GoLand / PyCharm): debug-only Remote JVM Debug (attach / listen, live JDWP agent args per JDK version), Go Remote (dlv --headless, substitutePath), Python Remote Debug (debugpy --listen, pathMappings) and Attach to Node.js (inspector, localRoot/remoteRoot); debugServer accepts host:port and [v6]:port; JVM listen mode via a cancellable relay so java-debug can attach. Verified: ide-dap tests (host:port, relay handshake, cancel), run/debugger vitest, in-app on macOS: Python Remote Debug attached to a debugpy server, paused at app.py:3 with variables. Remote JVM/Go/Node not exercised in-app. Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- crates/ide-dap/src/adapter.rs | 19 +- crates/ide-dap/src/jdwp.rs | 97 ++++++ crates/ide-dap/src/lib.rs | 42 ++- crates/ide-dap/src/tests.rs | 48 ++- src-tauri/src/commands/debug.rs | 16 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 8 +- .../debugger/__tests__/remote.test.ts | 159 +++++++++ src/features/debugger/actions.ts | 53 ++- src/features/debugger/index.ts | 10 +- src/features/debugger/remote.ts | 316 ++++++++++++++++++ src/features/run/ConfigForm.tsx | 52 ++- src/features/run/RunWidget.tsx | 15 +- src/features/run/configs.ts | 3 +- src/features/run/index.ts | 7 +- src/features/run/runConfigTypes.test.tsx | 51 ++- src/features/run/store.ts | 12 + src/features/run/types.ts | 20 +- src/platform/locales/text/zh.json | 31 +- src/workbench/contributions.ts | 20 +- 20 files changed, 944 insertions(+), 36 deletions(-) create mode 100644 crates/ide-dap/src/jdwp.rs create mode 100644 src/features/debugger/__tests__/remote.test.ts create mode 100644 src/features/debugger/remote.ts diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 1b3ce2d2d..aff17aeaa 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -36,6 +36,8 @@ pub struct AdapterSpec { pub cwd: PathBuf, /// Extra environment of the adapter process (debugpy's private copy on `PYTHONPATH`). pub env: Vec<(String, String)>, + /// Host of an adapter that is already running (`debugServer`); `None` is the loopback interface. + pub server_host: Option, } /// Built-in adapters used when no language definition names `adapter`. @@ -116,6 +118,7 @@ pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec { install_hint: config.install_hint.clone(), cwd, env: Vec::new(), + server_host: None, } } @@ -309,7 +312,12 @@ impl AdapterLauncher for ProcessLauncher { Box::pin(async move { let stderr = Arc::new(parking_lot::Mutex::new(Vec::new())); if let Some(port) = reuse_port { - let stream = connect_with_retries(port, None, Duration::from_secs(5)).await?; + // A remote adapter (`dlv --headless` on another machine) may answer slowly. + let (host, timeout) = match spec.server_host.as_deref() { + Some(host) => (host, CONNECT_TIMEOUT), + None => (LOOPBACK, Duration::from_secs(5)), + }; + let stream = connect_with_retries(host, port, None, timeout).await?; let (r, w) = stream.into_split(); return Ok(AdapterIo { reader: Box::new(r), @@ -366,7 +374,7 @@ impl AdapterLauncher for ProcessLauncher { if let Some(out) = child.stdout.take() { pump_lines(out, spec.adapter.clone(), stderr.clone()); } - let stream = match connect_with_retries(port, Some(&mut child), CONNECT_TIMEOUT).await { + let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), CONNECT_TIMEOUT).await { Ok(s) => s, Err(e) => { let _ = child.start_kill(); @@ -410,14 +418,17 @@ fn pump_lines( }); } +const LOOPBACK: &str = "127.0.0.1"; + async fn connect_with_retries( + host: &str, port: u16, mut child: Option<&mut Child>, timeout: Duration, ) -> IdeResult { let deadline = Instant::now() + timeout; loop { - match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + match tokio::net::TcpStream::connect((host, port)).await { Ok(s) => { let _ = s.set_nodelay(true); return Ok(s); @@ -432,7 +443,7 @@ async fn connect_with_retries( } if Instant::now() >= deadline { return Err(IdeError::unavailable(format!( - "could not connect to debug adapter on port {port}: {e}" + "could not connect to debug adapter at {host}:{port}: {e}" ))); } tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/crates/ide-dap/src/jdwp.rs b/crates/ide-dap/src/jdwp.rs new file mode 100644 index 000000000..41deab2c2 --- /dev/null +++ b/crates/ide-dap/src/jdwp.rs @@ -0,0 +1,97 @@ +//! "Listen to remote JVM" (IDEA's Remote JVM Debug in listen mode): the IDE listens, +//! the remote JVM (`-agentlib:jdwp=…,server=n,address=:`) connects to +//! it. java-debug can only attach, so the connection is relayed: once the JVM has +//! connected, java-debug attaches to a one-shot loopback port that forwards the JDWP +//! stream (handshake included) both ways. + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::io::ErrorKind; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; + +/// How long the relay waits for java-debug to attach once the JVM is connected. +const ATTACH_TIMEOUT: Duration = Duration::from_secs(60); + +/// The address to listen on for `host`: the interface it names, or every interface when +/// it is not one of this machine's (a DNS name or a NAT address the JVM connects to). +async fn bind(host: &str, port: u16) -> IdeResult { + let host = host.trim(); + let host = if host.is_empty() { "0.0.0.0" } else { host.trim_start_matches('[').trim_end_matches(']') }; + match TcpListener::bind((host, port)).await { + Ok(l) => Ok(l), + Err(e) if e.kind() == ErrorKind::AddrNotAvailable || e.kind() == ErrorKind::Other => { + TcpListener::bind(("0.0.0.0", port)) + .await + .map_err(|e| IdeError::unavailable(format!("cannot listen on port {port}: {e}"))) + } + Err(e) => Err(IdeError::unavailable(format!("cannot listen on {host}:{port}: {e}"))), + } +} + +/// Waits for a JVM to connect to `host:port`, then returns a loopback port that relays +/// to it (accepts one connection). Cancelled through `cancel` while waiting. +pub async fn listen_for_jvm(host: &str, port: u16, cancel: CancellationToken) -> IdeResult { + let listener = bind(host, port).await?; + let jvm = tokio::select! { + _ = cancel.cancelled() => return Err(IdeError::cancelled()), + accepted = listener.accept() => accepted.map_err(IdeError::from)?.0, + }; + drop(listener); + let _ = jvm.set_nodelay(true); + let relay = TcpListener::bind(("127.0.0.1", 0)).await?; + let local = relay.local_addr()?.port(); + tokio::spawn(relay_one(relay, jvm)); + Ok(local) +} + +async fn relay_one(relay: TcpListener, mut jvm: TcpStream) { + let debugger = match tokio::time::timeout(ATTACH_TIMEOUT, relay.accept()).await { + Ok(Ok((s, _))) => s, + _ => return, + }; + drop(relay); + let _ = debugger.set_nodelay(true); + let mut debugger = debugger; + if let Err(e) = tokio::io::copy_bidirectional(&mut debugger, &mut jvm).await { + tracing::debug!(target: "ide_dap::jdwp", "JDWP relay closed: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn relays_the_jdwp_handshake_both_ways() { + let port = TcpListener::bind(("127.0.0.1", 0)).await.unwrap().local_addr().unwrap().port(); + let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", port, CancellationToken::new())); + // The remote JVM (`server=n`) connects to the IDE. + let mut jvm = loop { + if let Ok(s) = TcpStream::connect(("127.0.0.1", port)).await { + break s; + } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let local = waiting.await.unwrap().unwrap(); + // java-debug attaches to the relay and starts the handshake. + let mut debugger = TcpStream::connect(("127.0.0.1", local)).await.unwrap(); + debugger.write_all(b"JDWP-Handshake").await.unwrap(); + let mut buf = [0u8; 14]; + jvm.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"JDWP-Handshake"); + jvm.write_all(b"JDWP-Handshake").await.unwrap(); + debugger.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"JDWP-Handshake"); + } + + #[tokio::test] + async fn waiting_is_cancellable() { + let cancel = CancellationToken::new(); + let waiting = tokio::spawn(listen_for_jvm("127.0.0.1", 0, cancel.clone())); + tokio::time::sleep(Duration::from_millis(50)).await; + cancel.cancel(); + let err = waiting.await.unwrap().unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + } +} diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 1d76b48f9..20f8c0cbc 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -12,6 +12,7 @@ pub mod adapter; pub mod attach; mod cargo; pub mod connection; +pub mod jdwp; mod session; use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher}; @@ -45,7 +46,8 @@ pub struct LaunchConfig { /// A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo /// and launches the produced executable; `"debugServer": ` connects to an /// adapter already listening on that local port (e.g. java-debug inside jdtls) - /// instead of starting one. + /// instead of starting one, `"debugServer": "host:port"` to one on another machine + /// (`dlv --headless`, `debugpy --listen`). pub extra_json: Option, } @@ -357,15 +359,34 @@ pub(crate) fn substitute_free_port(value: &mut Value, port: &mut impl FnMut() -> }) } +/// Where a running adapter listens: `debugServer` of the launch arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DebugServer { + /// `None` for the loopback interface (an adapter inside a local process such as jdtls). + pub host: Option, + pub port: u16, +} + /// Removes `debugServer` (VS Code's "connect to a running adapter" attribute) from the -/// launch arguments and returns its port. -pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult> { +/// launch arguments: a port (`4711`, `"4711"`) on this machine, or `"host:port"` / +/// `"[v6]:port"` of a remote one (`dlv --headless`, `debugpy --listen`). +pub(crate) fn take_debug_server(arguments: &mut Value) -> IdeResult> { let Some(v) = arguments.as_object_mut().and_then(|o| o.remove("debugServer")) else { return Ok(None) }; - let port = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())); - match port.and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0) { - Some(p) => Ok(Some(p)), - None => Err(IdeError::invalid(format!("invalid debugServer port: {v}"))), + let invalid = || IdeError::invalid(format!("invalid debugServer address: {v}")); + let port_of = |p: u64| u16::try_from(p).ok().filter(|p| *p != 0); + if let Some(p) = v.as_u64() { + return port_of(p).map(|port| Some(DebugServer { host: None, port })).ok_or_else(invalid); } + let s = v.as_str().map(str::trim).ok_or_else(invalid)?; + let (host, port) = match s.rsplit_once(':') { + Some((host, port)) => { + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + (Some(host).filter(|h| !h.is_empty()).map(str::to_owned), port) + } + None => (None, s), + }; + let port = port.trim().parse::().ok().and_then(port_of).ok_or_else(invalid)?; + Ok(Some(DebugServer { host, port })) } /// DAP stack frame → [`StackFrame`] (1-based → 0-based). @@ -558,6 +579,9 @@ impl DebugManager { } let id = DebugSessionId::next(); let debug_server = take_debug_server(&mut arguments)?; + if let Some(server) = &debug_server { + spec.server_host = server.host.clone(); + } if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) { let cargo_args: Vec = cargo .get("args") @@ -599,8 +623,8 @@ impl DebugManager { if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() { arguments["sourceLanguages"] = json!(["rust"]); } - self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, debug_server) - .await + let port = debug_server.map(|s| s.port); + self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await } /// `startDebugging` reverse request: a child session on the same adapter. diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 39939630e..fac94e1b9 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -597,14 +597,23 @@ async fn sessions_deliver_events_to_their_owner_only() { #[test] fn debug_server_is_taken_from_launch_arguments() { + let local = |port| Some(DebugServer { host: None, port }); + let remote = |host: &str, port| Some(DebugServer { host: Some(host.into()), port }); let mut args = json!({"name": "x", "debugServer": 4711, "mainClass": "a.B"}); - assert_eq!(take_debug_server(&mut args).unwrap(), Some(4711)); + assert_eq!(take_debug_server(&mut args).unwrap(), local(4711)); assert!(args.get("debugServer").is_none()); assert_eq!(args["mainClass"], "a.B"); - assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), Some(5005)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "5005"})).unwrap(), local(5005)); assert_eq!(take_debug_server(&mut json!({"name": "x"})).unwrap(), None); + // A remote adapter: `dlv --headless --listen=:2345`, `debugpy --listen 0.0.0.0:5678`. + assert_eq!(take_debug_server(&mut json!({"debugServer": "build-box:2345"})).unwrap(), remote("build-box", 2345)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "10.0.0.7:5678"})).unwrap(), remote("10.0.0.7", 5678)); + assert_eq!(take_debug_server(&mut json!({"debugServer": "[::1]:2345"})).unwrap(), remote("::1", 2345)); + assert_eq!(take_debug_server(&mut json!({"debugServer": ":2345"})).unwrap(), local(2345)); assert!(take_debug_server(&mut json!({"debugServer": 0})).is_err()); assert!(take_debug_server(&mut json!({"debugServer": 70000})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": "host:"})).is_err()); + assert!(take_debug_server(&mut json!({"debugServer": "host:abc"})).is_err()); } #[test] @@ -696,6 +705,41 @@ async fn connects_to_running_debug_server_over_tcp() { assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. }))); } +/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`, +/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one. +#[tokio::test] +async fn connects_to_remote_debug_server_by_host() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log: Log = Arc::default(); + let server_log = log.clone(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + fake_adapter(stream, server_log, Script::default()).await; + }); + let (owner, _events) = recording_owner("main"); + let manager = DebugManager::new(Arc::new(LanguageRegistry::from_configs(Vec::new()))); + let config = LaunchConfig { + name: "Remote".into(), + adapter: "delve".into(), + request: "attach".into(), + program: None, + args: vec![], + cwd: Some(std::env::temp_dir().into()), + env: BTreeMap::new(), + extra_json: Some( + json!({"debugServer": format!("localhost:{port}"), "mode": "remote", "substitutePath": [{"from": "/src", "to": "/app"}]}) + .to_string(), + ), + }; + let id = manager.start(config, owner).await.unwrap(); + let attach = log.lock().iter().find(|m| m["command"] == "attach").cloned().unwrap(); + assert_eq!(attach["arguments"]["mode"], "remote"); + assert_eq!(attach["arguments"]["substitutePath"][0]["to"], "/app"); + assert!(attach["arguments"].get("debugServer").is_none(), "consumed by the client"); + manager.stop(id).await.unwrap(); +} + fn requests(h: &Harness, command: &str) -> Vec { h.log .lock() diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 6c93cd8e5..e435b115f 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -264,6 +264,22 @@ pub async fn debug_attachable_processes() -> IdeResult, + request: ide_core::RequestId, + host: String, + port: u16, +) -> IdeResult { + let token = state.begin_request(request); + let result = jdwp::listen_for_jvm(&host, port, token).await; + state.end_request(request); + result +} + /// HotSwap (Reload Changed Classes) in a java-debug session. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2a312c052..7247d0b85 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -286,6 +286,7 @@ pub fn builder() -> tauri_specta::Builder { debug::debug_set_data_breakpoints, debug::debug_data_breakpoint_info, debug::debug_attachable_processes, + debug::debug_listen_for_jvm, debug::debug_redefine_classes, coverage::coverage_prepare, coverage::coverage_collect, diff --git a/src/bindings.ts b/src/bindings.ts index 3f40dce2c..6e5e95057 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -582,6 +582,11 @@ export const commands = { debugDataBreakpointInfo: (session: DebugSessionId, container: number, name: string, frameId: number | null) => typedError(__TAURI_INVOKE("debug_data_breakpoint_info", { session, container, name, frameId })), /** Attach to Process: the user's processes a debugger can attach to. */ debugAttachableProcesses: () => typedError(__TAURI_INVOKE("debug_attachable_processes")), + /** + * Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote + * JVM to connect to `host:port`; returns the loopback port java-debug attaches to. + */ + debugListenForJvm: (request: RequestId, host: string, port: number) => typedError(__TAURI_INVOKE("debug_listen_for_jvm", { request, host, port })), /** HotSwap (Reload Changed Classes) in a java-debug session. */ debugRedefineClasses: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_redefine_classes", { session })), /** @@ -2949,7 +2954,8 @@ export type LaunchConfig = { * A `"cargo": {"args": [...], "filter": {"name", "kind"}}` entry builds with cargo * and launches the produced executable; `"debugServer": ` connects to an * adapter already listening on that local port (e.g. java-debug inside jdtls) - * instead of starting one. + * instead of starting one, `"debugServer": "host:port"` to one on another machine + * (`dlv --headless`, `debugpy --listen`). */ extraJson: string | null, }; diff --git a/src/features/debugger/__tests__/remote.test.ts b/src/features/debugger/__tests__/remote.test.ts new file mode 100644 index 000000000..d0c17fff8 --- /dev/null +++ b/src/features/debugger/__tests__/remote.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { registerRunConfigType } from "@/workbench/contributions"; + +const invoked: Array<[string, unknown[]]> = []; +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); + return { + ...real, + commands: { + ...real.commands, + javaDebugServer: (...a: unknown[]) => { + invoked.push(["javaDebugServer", a]); + return ok(41234); + }, + }, + }; +}); + +const { javaAttachConfig } = await import("../java"); +const remote = await import("../remote"); +const { missingParams, newConfigOfType, isDebugOnly, terminalApplies, withParams } = + await import("@/features/run/types"); +const { validateConfig } = await import("@/features/run/configs"); + +const { + REMOTE_TYPES, + remoteJvmType, + goRemoteType, + pythonRemoteType, + nodeRemoteType, + jdwpAgentArgs, + remoteAttachConfig, + remoteJvmTarget, +} = remote; + +function configOf(type: (typeof REMOTE_TYPES)[number], params: Record = {}): RunConfig { + const c = newConfigOfType(type, []); + return withParams({ ...c, name: "Remote" }, { ...c.params, ...params }, type); +} + +let disposers: Array<() => void> = []; +beforeEach(() => { + invoked.length = 0; + disposers = REMOTE_TYPES.map(registerRunConfigType); +}); +afterEach(() => disposers.forEach((d) => d())); + +describe("remote debug configuration types", () => { + it.each(REMOTE_TYPES.map((t) => [t.id, t] as const))( + "%s is debug-only, launches nothing and is valid by default", + (_, type) => { + const c = configOf(type); + expect(type.debugOnly).toBe(true); + expect(isDebugOnly(c)).toBe(true); + expect(c.debugAdapter).toBe(type.debugAdapter); + expect(c.command).toBe(""); + expect(terminalApplies(c, type)).toBe(false); + expect(validateConfig(c, [c])).toEqual({}); + expect(missingParams(c, type)).toEqual({}); + }, + ); + + it.each(REMOTE_TYPES.map((t) => [t.id, t] as const))("%s rejects a bad port and an empty host", (_, type) => { + expect(missingParams(configOf(type, { port: "70000" }), type)).toEqual({ + port: "Port must be a number from 1 to 65535", + }); + expect(missingParams(configOf(type, { port: "abc" }), type).port).toBeDefined(); + expect(missingParams(configOf(type, { host: "" }), type)).toEqual({ host: "Host is required" }); + }); + + it("Remote JVM Debug defaults to attaching to localhost:5005", () => { + expect(configOf(remoteJvmType).params).toEqual({ mode: "attach", host: "localhost", port: "5005", jdk: "9" }); + }); +}); + +describe("Remote JVM Debug: command line arguments for remote JVM", () => { + it.each([ + [{ jdk: "9" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"], + [{ jdk: "5" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"], + [{ jdk: "1.4" }, "-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"], + [{ jdk: "9", port: "8000" }, "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8000"], + [ + { mode: "listen", host: "dev-box", port: "5005" }, + "-agentlib:jdwp=transport=dt_socket,server=n,address=dev-box:5005,suspend=y", + ], + ])("%j → %s", (params, expected) => { + expect(jdwpAgentArgs({ ...remoteJvmType.defaults, ...params })).toBe(expected); + }); + + it("is shown as a read-only field that follows the form", () => { + const field = remoteJvmType.fields.find((f) => f.key === "args")!; + expect(field.control).toBe("output"); + expect(field.value!({ port: "6006", jdk: "5" })).toContain("address=6006"); + }); +}); + +describe("remote attach requests", () => { + it("Remote JVM Debug attaches java-debug to host:port with the module's sources", async () => { + const c = configOf(remoteJvmType, { host: "10.0.0.5", port: "5006", module: "api" }); + const t = remoteJvmTarget(c); + expect(t).toEqual({ mode: "attach", host: "10.0.0.5", port: 5006, module: "api" }); + const l = await javaAttachConfig({ name: c.name, module: t.module, hostName: t.host, port: t.port }, "/p"); + expect(invoked).toEqual([["javaDebugServer", ["api"]]]); + expect(l.adapter).toBe("java-debug"); + expect(l.request).toBe("attach"); + expect(l.cwd).toBe("/p/api"); + expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "10.0.0.5", port: 5006 }); + }); + + it("Remote JVM Debug in listen mode", () => { + expect(remoteJvmTarget(configOf(remoteJvmType, { mode: "listen" })).mode).toBe("listen"); + }); + + it("Go Remote connects to dlv --headless and maps the source root", () => { + const l = remoteAttachConfig(configOf(goRemoteType, { host: "build", remoteRoot: "/go/src/app" }), "/p")!; + expect(l).toMatchObject({ adapter: "delve", request: "attach", cwd: "/p" }); + expect(JSON.parse(l.extraJson!)).toEqual({ + debugServer: "build:2345", + mode: "remote", + substitutePath: [{ from: "/p", to: "/go/src/app" }], + }); + const plain = JSON.parse(remoteAttachConfig(configOf(goRemoteType), "/p")!.extraJson!); + expect(plain).toEqual({ debugServer: "localhost:2345", mode: "remote" }); + }); + + it("Python remote connects to debugpy --listen with pathMappings", () => { + const l = remoteAttachConfig( + configOf(pythonRemoteType, { host: "::1", localRoot: "svc", remoteRoot: "/app" }), + "/p", + )!; + expect(l.adapter).toBe("debugpy"); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + debugServer: "[::1]:5678", + pathMappings: [{ localRoot: "/p/svc", remoteRoot: "/app" }], + }); + }); + + it("Attach to Node.js attaches js-debug to the inspector with local/remote roots", () => { + const l = remoteAttachConfig(configOf(nodeRemoteType, { host: "web", remoteRoot: "/usr/src/app" }), "/p")!; + expect(l.adapter).toBe("js-debug"); + expect(JSON.parse(l.extraJson!)).toMatchObject({ + type: "pwa-node", + address: "web", + port: 9229, + localRoot: "/p", + remoteRoot: "/usr/src/app", + }); + }); + + it("start-the-remote-side hints follow the port", () => { + expect(remote.delveServerCommand({ port: "4000" })).toBe( + "dlv debug --headless --listen=:4000 --api-version=2 --accept-multiclient", + ); + expect(remote.debugpyServerCommand({ port: "5679" })).toContain("--listen 0.0.0.0:5679"); + expect(remote.nodeInspectCommand({})).toBe("node --inspect=0.0.0.0:9229 app.js"); + }); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index f70c73c73..761f2da99 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -1,6 +1,6 @@ /** Debugger actions (ids match `keymaps/intellij-macos.ts`). */ import { registerActions, type Action } from "@/platform/actions"; -import { call, commands, isTauri, type RunConfig, type RunId } from "@/platform/ipc"; +import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; @@ -18,6 +18,7 @@ import { toggleBreakpointSmart } from "./breakpoints/toggle"; import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java"; import { reloadChangedClasses } from "./hotswap"; import { debugUnsupportedReason, launchConfigFor } from "./launch"; +import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote"; import { activeSession, isLive, @@ -142,6 +143,52 @@ function evaluateSeed(): string { return expressionAt(ed.view, sel.head)?.text ?? ""; } +/** + * Remote JVM Debug in listen mode: the IDE listens and the remote JVM connects; java-debug + * then attaches through a loopback relay. Null when cancelled. + */ +async function listenForJvm(name: string, host: string, port: number): Promise { + const request = nextRequestId(); + const key = `debug.listen.${request}`; + notifications().notify({ + group: "run", + level: "info", + key, + sticky: true, + title: `'${name}': listening for the remote JVM on port ${port}`, + body: `Start the remote JVM with -agentlib:jdwp=transport=dt_socket,server=n,address=${host}:${port},suspend=y`, + actions: [{ label: "Cancel", run: () => void commands.cancelRequest(request) }], + }); + try { + return await call(commands.debugListenForJvm(request, host, port)); + } catch (e) { + if (isCancelled(e)) return null; + throw e; + } finally { + notifications().expire?.(key); + } +} + +/** Remote JVM Debug, Go Remote, Python / Node.js remote: attach over host:port. */ +async function startRemote(config: RunConfig, root: string) { + if (config.kind === REMOTE_JVM) { + const t = remoteJvmTarget(config); + let hostName = t.host; + let port = t.port; + if (t.mode === "listen") { + const relay = await listenForJvm(config.name, t.host, t.port); + if (relay == null) return; + [hostName, port] = ["127.0.0.1", relay]; + } + const launch = await javaAttachConfig({ name: config.name, module: t.module, hostName, port }, root); + // A listen relay accepts one debugger: rerunning listens again through the configuration. + await startSession(launch, { rerunnable: t.mode === "attach" }); + return; + } + const launch = remoteAttachConfig(config, root); + if (launch) await startSession(launch); +} + async function startConfig(arg: unknown) { const root = useProject.getState().project?.root; if (!root) return; @@ -167,6 +214,10 @@ async function startConfig(arg: unknown) { } config = configs[0]!; } + if (isRemoteConfig(config)) { + await startRemote(config, root); + return; + } if (isJavaDebuggable(config)) { await maybeEditorService() ?.saveAll() diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 7eaa0bbb1..e7d27b8c8 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -8,7 +8,12 @@ import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { valueTooltip } from "./editor/valueTooltip"; import { registerEditorContribution } from "@/editor/api"; -import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; +import { + registerMenuGroup, + registerOverlay, + registerRunConfigType, + registerToolWindow, +} from "@/workbench/contributions"; import { registerDebugActions } from "./actions"; import { loadBreakpoints } from "./breakpoints/store"; import { loadExceptionBreakpoints } from "./breakpoints/exceptions"; @@ -19,6 +24,7 @@ import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; +import { REMOTE_TYPES } from "./remote"; export function activate(): () => void { loadWatches(); @@ -28,6 +34,8 @@ export function activate(): () => void { loadSpecialBreakpoints(useProject.getState().project?.root ?? null); const disposers = [ registerDebugActions(), + // Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration. + ...REMOTE_TYPES.map(registerRunConfigType), registerToolWindow({ id: "debug", title: "Debug", diff --git a/src/features/debugger/remote.ts b/src/features/debugger/remote.ts new file mode 100644 index 000000000..ee86cff10 --- /dev/null +++ b/src/features/debugger/remote.ts @@ -0,0 +1,316 @@ +/** + * Remote debug run configurations: the debugger attaches to a process started + * elsewhere (another machine, a container) over host:port, like IDEA's Remote JVM + * Debug, GoLand's Go Remote, PyCharm's remote debugpy and IDEA's Attach to Node.js. + * Only Debug applies to them (`debugOnly`); they launch no process. + */ +import type { LaunchConfig, RunConfig } from "@/platform/ipc"; +import { workflowSettings } from "@/platform/workflowSettings"; +import type { RunConfigTypeContribution, RunConfigTypeField } from "@/workbench/contributions"; + +export const REMOTE_JVM = "remote-jvm"; +export const GO_REMOTE = "go-remote"; +export const PYTHON_REMOTE = "python-remote"; +export const NODE_REMOTE = "node-remote"; + +type Params = Readonly>; + +/** A TCP port, or null. */ +export function parsePort(value: string | undefined): number | null { + const v = (value ?? "").trim(); + if (!/^\d+$/.test(v)) return null; + const n = Number(v); + return n >= 1 && n <= 65535 ? n : null; +} + +const hostField: RunConfigTypeField = { key: "host", label: "Host", mono: true, required: true }; +const portField: RunConfigTypeField = { + key: "port", + label: "Port", + mono: true, + required: true, + validate: (v) => (parsePort(v) == null ? "Port must be a number from 1 to 65535" : null), +}; + +const hostOf = (p: Params) => p.host?.trim() || "localhost"; +const portOf = (p: Params, fallback: number) => parsePort(p.port) ?? fallback; + +// --------------------------------------------------------------------------- +// Remote JVM Debug + +export type JdwpMode = "attach" | "listen"; +export type JdkVersion = "9" | "5" | "1.4"; + +/** + * The agent the remote JVM is started with, as IDEA shows it ("Command line arguments + * for remote JVM"). Attach: the JVM listens (`server=y`); JDK 9+ binds only to localhost + * unless the address names a host, hence `*:`. Listen: the JVM connects to the IDE. + */ +export function jdwpAgentArgs(p: Params): string { + const mode: JdwpMode = p.mode === "listen" ? "listen" : "attach"; + const jdk = (p.jdk || "9") as JdkVersion; + const port = portOf(p, 5005); + const opts = + mode === "listen" + ? `transport=dt_socket,server=n,address=${hostOf(p)}:${port},suspend=y` + : `transport=dt_socket,server=y,suspend=n,address=${jdk === "9" ? "*:" : ""}${port}`; + return jdk === "1.4" ? `-Xdebug -Xrunjdwp:${opts}` : `-agentlib:jdwp=${opts}`; +} + +const JVM_KINDS = new Set(["application", "spring-boot", "maven", "gradle", "java-file"]); + +export const remoteJvmType: RunConfigTypeContribution = { + id: REMOTE_JVM, + label: "Remote JVM Debug", + icon: "attach-to-process", + description: "Attach the debugger to a JVM started with a JDWP agent, here or on another machine", + defaults: { mode: "attach", host: "localhost", port: "5005", jdk: "9" }, + debugOnly: true, + debugAdapter: "java-debug", + fields: [ + { + key: "mode", + label: "Debugger mode", + control: "select", + options: [ + { value: "attach", label: "Attach to remote JVM" }, + { value: "listen", label: "Listen to remote JVM" }, + ], + }, + hostField, + portField, + { + key: "jdk", + label: "JDK version of the remote JVM", + control: "select", + options: [ + { value: "9", label: "JDK 9 or later" }, + { value: "5", label: "JDK 5 - 8" }, + { value: "1.4", label: "JDK 1.4.x" }, + ], + }, + { + key: "args", + label: "Command line arguments for remote JVM", + control: "output", + mono: true, + value: jdwpAgentArgs, + hint: "Add to the remote JVM's command line (or JAVA_TOOL_OPTIONS)", + }, + { + key: "module", + label: "Use module classpath", + mono: true, + placeholder: "Project root", + hint: "Module directory whose sources the debugger shows", + suggestions: (configs) => [...new Set(configs.filter((c) => JVM_KINDS.has(c.kind) && c.cwd).map((c) => c.cwd!))], + }, + ], + commandLine: () => null, +}; + +// --------------------------------------------------------------------------- +// Go Remote (Delve headless server) + +/** How to start the remote side, as GoLand shows it. */ +export function delveServerCommand(p: Params): string { + return `dlv debug --headless --listen=:${portOf(p, 2345)} --api-version=2 --accept-multiclient`; +} + +const localRootField: RunConfigTypeField = { + key: "localRoot", + label: "Local root", + mono: true, + placeholder: "Project root", +}; + +const remoteRootField = (placeholder: string): RunConfigTypeField => ({ + key: "remoteRoot", + label: "Remote root", + mono: true, + placeholder, + hint: "Where the project's sources are on the remote machine, when the paths differ", +}); + +export const goRemoteType: RunConfigTypeContribution = { + id: GO_REMOTE, + label: "Go Remote", + icon: "file-go", + description: "Attach to a Delve server (dlv --headless) here or on another machine", + defaults: { host: "localhost", port: "2345" }, + debugOnly: true, + debugAdapter: "delve", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: delveServerCommand, + hint: "Delve 1.7.3 or later; dlv exec ./app or dlv attach take the same flags", + }, + localRootField, + remoteRootField("e.g. /go/src/app"), + ], + commandLine: () => null, +}; + +// --------------------------------------------------------------------------- +// Python remote (debugpy --listen) + +export function debugpyServerCommand(p: Params): string { + return `python -m debugpy --listen 0.0.0.0:${portOf(p, 5678)} --wait-for-client app.py`; +} + +export const pythonRemoteType: RunConfigTypeContribution = { + id: PYTHON_REMOTE, + label: "Python Remote Debug (debugpy)", + icon: "file-python", + description: "Attach to a Python process listening with debugpy here or on another machine", + defaults: { host: "localhost", port: "5678" }, + debugOnly: true, + debugAdapter: "debugpy", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: debugpyServerCommand, + hint: "pip install debugpy on the remote machine", + }, + localRootField, + remoteRootField("e.g. /app"), + ], + commandLine: () => null, +}; + +// --------------------------------------------------------------------------- +// Attach to Node.js (inspector) + +export function nodeInspectCommand(p: Params): string { + return `node --inspect=0.0.0.0:${portOf(p, 9229)} app.js`; +} + +export const nodeRemoteType: RunConfigTypeContribution = { + id: NODE_REMOTE, + label: "Attach to Node.js", + icon: "file-js", + description: "Attach to a Node.js process started with --inspect here or on another machine", + defaults: { host: "localhost", port: "9229" }, + debugOnly: true, + debugAdapter: "js-debug", + fields: [ + hostField, + portField, + { + key: "hint", + label: "Start the remote side with", + control: "output", + mono: true, + value: nodeInspectCommand, + }, + localRootField, + remoteRootField("e.g. /usr/src/app"), + ], + commandLine: () => null, +}; + +export const REMOTE_TYPES: readonly RunConfigTypeContribution[] = [ + remoteJvmType, + goRemoteType, + pythonRemoteType, + nodeRemoteType, +]; + +export const isRemoteConfig = (c: Pick) => REMOTE_TYPES.some((t) => t.id === c.kind); + +// --------------------------------------------------------------------------- +// Attach requests + +function isAbsolute(p: string) { + return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p); +} + +function join(base: string, p: string) { + if (isAbsolute(p)) return p; + const rel = p.replace(/^\.\//, ""); + return rel === "." || rel === "" ? base : `${base.replace(/[\\/]$/, "")}/${rel}`; +} + +/** Local root (absolute) ↔ remote root, or null when the paths are the same on both sides. */ +function rootMapping(p: Params, root: string): { local: string; remote: string } | null { + const remote = p.remoteRoot?.trim(); + if (!remote) return null; + const local = p.localRoot?.trim() ? join(root, p.localRoot.trim()) : root; + return { local, remote }; +} + +/** `host:port` for `debugServer` (IPv6 literals in brackets). */ +export function serverAddress(host: string, port: number): string { + return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`; +} + +function attachBase(rc: RunConfig, adapter: string, root: string, extra: Record): LaunchConfig { + return { + name: rc.name, + adapter, + request: "attach", + program: null, + args: [], + cwd: root, + env: {}, + extraJson: JSON.stringify(extra), + }; +} + +/** + * The attach request of a Go / Python / Node.js remote configuration. Delve's headless + * server and `debugpy --listen` speak DAP themselves (`debugServer` connects there); + * js-debug runs here and connects to the inspector. + */ +export function remoteAttachConfig(rc: RunConfig, root: string): LaunchConfig | null { + const p = rc.params ?? {}; + const host = hostOf(p); + const map = rootMapping(p, root); + const skip = workflowSettings().debugger.skipLibraryCode; + switch (rc.kind) { + case GO_REMOTE: + return attachBase(rc, "delve", root, { + debugServer: serverAddress(host, portOf(p, 2345)), + mode: "remote", + ...(map ? { substitutePath: [{ from: map.local, to: map.remote }] } : {}), + }); + case PYTHON_REMOTE: + return attachBase(rc, "debugpy", root, { + debugServer: serverAddress(host, portOf(p, 5678)), + justMyCode: skip, + ...(map ? { pathMappings: [{ localRoot: map.local, remoteRoot: map.remote }] } : {}), + }); + case NODE_REMOTE: + return attachBase(rc, "js-debug", root, { + type: "pwa-node", + address: host, + port: portOf(p, 9229), + ...(map ? { localRoot: map.local, remoteRoot: map.remote } : {}), + skipFiles: skip ? ["/**", "**/node_modules/**"] : [], + }); + default: + return null; + } +} + +/** What a Remote JVM Debug configuration attaches to (before the listen mode relay). */ +export function remoteJvmTarget(rc: RunConfig): { mode: JdwpMode; host: string; port: number; module: string } { + const p = rc.params ?? {}; + return { + mode: p.mode === "listen" ? "listen" : "attach", + host: hostOf(p), + port: portOf(p, 5005), + module: p.module?.trim() ?? "", + }; +} diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index e24b20666..1b0d4698a 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -170,6 +170,8 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi } const compound = config.kind === COMPOUND; + // Compound and debug-only types (Remote JVM Debug) start no process of their own. + const noProcess = compound || !!type?.debugOnly; const params = compound ? {} : formParams(config, type); const update = (patch: Partial) => onChange({ ...config, ...patch }); const setParam = (key: string, value: string) => { @@ -199,7 +201,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi // A terminal has no separate input file: the two options exclude each other. const blocked = (id: OptionId) => (id === "emulateTerminal" && isShown("stdinFile")) || (id === "stdinFile" && !!config.emulateTerminal); - const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !compound || !g.process).flatMap((g): MenuItem[] => [ + const menuItems: MenuItem[] = OPTION_GROUPS.filter((g) => !noProcess || !g.process).flatMap((g): MenuItem[] => [ { kind: "header", label: g.label }, ...g.options .filter( @@ -269,7 +271,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi "isTest", ] as const ).filter( - (id) => (!compound || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault), + (id) => (!noProcess || id === "openToolWindow" || id === "focusToolWindow") && optionSet(config, id, openByDefault), ); return ( @@ -332,7 +334,13 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi type.fields .filter((f) => f.visible?.(params) ?? true) .map((f) => ( - + )} - {!compound && ( + {!noProcess && ( <> {!type?.ownsWorkingDirectory && ( @@ -545,7 +553,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
)} - {!compound && !template && ( + {!noProcess && !template && (
Command line @@ -586,6 +594,7 @@ function TypeField({ }) { const tx = useTx(); const listId = `run-field-${field.key}`; + if (field.control === "output") return ; if (field.control === "select") { const options = [...(field.options ?? [])]; if (value && !options.some((o) => o.value === value)) options.push({ value, label: value }); @@ -635,6 +644,39 @@ function TypeField({ ); } +/** Read-only text the form computes (e.g. the JDWP agent for the remote JVM), with Copy. */ +function OutputField({ label, text }: { label: string; text: string }) { + const tx = useTx(); + const [copied, setCopied] = useState(false); + const copy = () => { + void navigator.clipboard + ?.writeText(text) + .then(() => setCopied(true)) + .catch(() => undefined); + }; + return ( +
+