Compare commits

...
Author SHA1 Message Date
l0ng-ai 7e14395969 feat(java): add the "Serializable class without 'serialVersionUID'" inspection
Settings › Editor › Inspections › Java gets "Serializable class without 'serialVersionUID'", off by default. When it is on, jdtls's compiler reports classes that implement Serializable without a serialVersionUID field, and Alt+Enter on the class name offers "Add default serial version ID" and "Add generated serial version ID". The severity chosen for the inspection sets ECJ's level: error, warning, or info for the weaker levels.

- Built-in inspections can now be off by default (`defaultEnabled`). The settings tree, group toggles and profile filtering respect this, and an override is stored only when it differs from the default.
- `InspectionSettings::active` resolves the profile in effect in the backend. `configure_java_compiler` writes the matching `missingSerialVersion` level into a compiler-defaults file named after the variant, so a change gives jdtls a new configuration and restarts it.
- Saving inspection settings now reconfigures language servers, as code style settings already did.

Fixes #1199
2026-11-05 08:50:35 +08:00
l0ng-ai f5895d0336 test(debug): smoke-test debugging each language's fixture (#1357)
Debugs each language's fixture the way the editor does, against openidea-server and the debugger the IDE installs: a breakpoint on the entry point's marker line, Debug from the file's context, then the paused frame, its variables, Evaluate Expression, Step Over and Resume. Runs per language in the Language smoke workflow. The build model is loaded first, as the Build Tools feature does, so a Gradle Application is debugged through Gradle; Step Over allows a repeated stop on a line (Python 3.12 reports a conditional expression's line twice).
2026-11-05 08:32:11 +08:00
l0ng-ai c3c1d88d10 fix(vcs): let Commit and Shelf tree names use the whole row width (#1396)
Tree names in the Commit and Shelf windows were capped at 75% of the row, so long folder names were truncated with empty space left over. Names now take the whole row and the grey detail after them is truncated first. Fixes #1367
2026-11-05 08:25:51 +08:00
l0ng-ai a0ac988963 fix(debugger): debug uv / poetry run configurations in the project's environment (#1395)
debugpy ran the project manager (`uv`, `poetry`, …) as a module. Configurations run through uv, poetry, pdm, pipenv, hatch or rye now pass the manager as the debuggee's interpreter, keep the script / -m module / tool as the target, and run the adapter and launcher on a plain interpreter (debugLauncherPython, now preferred by the backend for the adapter command).
2026-11-05 08:18:18 +08:00
19 changed files with 495 additions and 45 deletions

No files matched your search

+7 -1
View File
@@ -3,7 +3,9 @@ name: Language smoke
# with a quick fix, an intention, formatting, rename) per language, against the language
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
# Then running and testing each fixture through the run store and openidea-server
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts.
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
# Step Over, Resume): smoke/debug.test.ts.
on:
push:
branches: [main]
@@ -17,6 +19,7 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "src/features/run/**"
- "src/features/debugger/**"
- "src/platform/**"
- "smoke/**"
- "package.json"
@@ -108,3 +111,6 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: cargo build --locked -p ide-host --bin openidea-server
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
env: { GITHUB_TOKEN: "${{ github.token }}" }
+3
View File
@@ -71,6 +71,9 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
`sudo DevToolsSecurity -enable`).
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
`pnpm gen:bindings`.
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
}
}
impl InspectionSettings {
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
let custom = |level: &str, key: &str| match level {
"ide" => self.ide_profiles.get(key),
"project" => self.project_profiles.get(key),
_ => None,
};
match self.profile.as_str() {
"project" => &self.project,
id => {
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
}
}
}
}
/// A named profile made with Copy.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -111,4 +128,20 @@ mod tests {
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
#[test]
fn active_profile_falls_back_to_default() {
let off = |id: &str| {
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
};
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
assert!(s.active().contains_key("a"));
s.profile = "project".into();
assert!(s.active().contains_key("b"));
s.profile = "ide:strict".into();
assert!(s.active().contains_key("c"));
s.profile = "project:gone".into();
assert!(s.active().contains_key("a"));
}
}
+5 -4
View File
@@ -589,11 +589,12 @@ impl DebugManager {
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
if config.adapter == "debugpy" {
let python = match arguments.get("python") {
Some(Value::String(p)) => Some(p.clone()),
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
_ => None,
};
if let Some(python) = python {
+6 -1
View File
@@ -175,7 +175,12 @@ pub fn set_settings(
host.apply_proxy();
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
let code_style = key == "editor"
|| key == "editor.codeStyle"
|| key.starts_with("editor.codeStyle.")
|| key == "inspections"
|| key.starts_with("inspections.");
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
.iter()
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
+1
View File
@@ -209,6 +209,7 @@ impl Host {
cfg.launch_error = Some(e);
None
});
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
ide_sdk::java::configure_jdtls(
cfg,
server_root,
+71 -8
View File
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path};
use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{LanguageServerSettings, Settings};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
@@ -442,15 +442,41 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
}
}
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
if o.enabled != Some(true) {
return None;
}
Some(match o.severity {
Some(InspectionLevel::Error) => "error",
None | Some(InspectionLevel::Warning) => "warning",
Some(_) => "info",
})
}
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
/// Each variant has its own file, so changing an inspection restarts the server with it.
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
let serial = missing_serial_version(inspections);
let (name, content) = match serial {
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
Some(level) => (
format!("compiler-defaults-serial-{level}.prefs"),
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
),
};
let file = data_dir.join("lsp/jdtls").join(name);
let write = || -> ide_core::IdeResult<()> {
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
ide_vfs::write_atomic(&file, content.as_bytes())?;
}
Ok(())
};
@@ -1095,13 +1121,50 @@ mod java_compiler_tests {
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
}
#[test]
fn serial_version_inspection_sets_the_compiler_option() {
let tmp = tempfile::tempdir().unwrap();
let url = |inspections: &InspectionSettings| {
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
configure_java_compiler(&mut cfg, tmp.path(), inspections);
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
};
let set = |o: ide_core::settings::InspectionOverride| {
let mut s = InspectionSettings::default();
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
s
};
let off = url(&InspectionSettings::default());
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
assert_ne!(on, off, "a new file, so the server restarts with it");
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
let error = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
let weak = url(&set(ide_core::settings::InspectionOverride {
enabled: Some(true),
severity: Some(InspectionLevel::WeakWarning),
..Default::default()
}));
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
let severity_only = url(&set(ide_core::settings::InspectionOverride {
severity: Some(InspectionLevel::Error),
..Default::default()
}));
assert_eq!(severity_only, off, "off by default");
}
#[test]
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
let tmp = tempfile::tempdir().unwrap();
let cache = tmp.path().join("cache with spaces 测试");
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
configure_java_compiler(&mut cfg, &cache);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
let options = options(&cfg);
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
assert!(path.starts_with(&cache));
@@ -1112,7 +1175,7 @@ mod java_compiler_tests {
}
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
configure_java_compiler(&mut cfg, &cache);
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
assert_eq!(self::options(&cfg), options);
}
@@ -1122,7 +1185,7 @@ mod java_compiler_tests {
let tmp = tempfile::tempdir().unwrap();
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
configure_java_compiler(&mut cfg, tmp.path());
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
for settings in [
-1
View File
@@ -369,7 +369,6 @@ pub fn configure_jdtls(
if cfg.name != "jdtls" {
return;
}
crate::apply::configure_java_compiler(cfg, data_dir);
// A Lombok agent from the user's JVM arguments wins.
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
let agent = own_agent.as_deref().or(lombok);
+4 -3
View File
@@ -20,9 +20,10 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
language servers, and `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests): when you change a
fixture, run both for that language.
language servers, `smoke/run.test.ts` runs each project's run and test configurations
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
them for that language.
## Setup
+251
View File
@@ -0,0 +1,251 @@
/**
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
*/
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { get } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { Channel } from "@tauri-apps/api/core";
import { commands, type InstallEvent } from "@/bindings";
import { executeAction } from "@/platform/actions";
import { call, nextRequestId, onIdeEvent } from "@/platform/ipc";
import { useBuildModel } from "@/platform/buildModel";
import { openProject, useProject } from "@/platform/project";
import { activate as activateBuildTools } from "@/features/build-tools";
import { useBuild } from "@/features/build-tools/store";
import { activate as activateDebugger } from "@/features/debugger";
import { addBreakpoint } from "@/features/debugger/breakpoints/store";
import { activeSession, evaluate, useDebug } from "@/features/debugger/state";
import { activate as activateRun } from "@/features/run";
import { resetRuns, useRun } from "@/features/run/store";
import { activateWorkbench } from "@/workbench/activate";
import { useNotifications } from "@/workbench/notificationStore";
import { connectDesktop } from "./desktop";
import { repoRoot, Server } from "./server";
interface Case {
fixture: string;
/** Runs in the project copy first (installs its dependencies). */
setup?: (root: string) => void;
/** The IDE's tool that debugs it. */
debugger: string;
/** The entry point, debugged from its context. */
file: string;
/** A variable of the paused frame. */
variable: string;
/** An expression evaluated there (Evaluate Expression), and its value. */
evaluate: [string, RegExp];
/** A server: the breakpoint is in this request's handler, and the session is stopped at the end. */
request?: string;
}
const CASES: Record<string, Case> = {
typescript: {
fixture: "ts-app",
setup: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, stdio: "ignore" }),
debugger: "js-debug",
file: "src/index.ts",
variable: "cart",
evaluate: ["cart.items.length", /^2$/],
},
python: {
fixture: "python-app",
setup: (root) => execFileSync("python3", ["-m", "venv", ".venv"], { cwd: root, stdio: "ignore" }),
debugger: "debugpy",
file: "src/tasks/cli.py",
variable: "tasks",
evaluate: ["len(tasks.pending())", /^3$/],
},
rust: {
fixture: "rust-app",
debugger: "codelldb",
file: "src/main.rs",
variable: "inventory",
evaluate: ["inventory", /\S/],
},
go: {
fixture: "go-app",
debugger: "delve",
file: "cmd/server/main.go",
variable: "r",
evaluate: ["r.Method", /^"GET"$/],
request: "http://127.0.0.1:8080/notes?q=openidea",
},
java: {
fixture: "java-app",
debugger: "java-debug",
file: "src/main/java/com/example/orders/App.java",
variable: "book",
evaluate: ["book.size()", /^3$/],
},
kotlin: {
fixture: "kotlin-gradle",
debugger: "kotlin-debug-adapter",
file: "src/main/kotlin/com/example/library/App.kt",
variable: "library",
evaluate: ["library", /Library/],
},
};
let deactivate: (() => void) | null = null;
let server: Server | null = null;
let disconnect: (() => void) | null = null;
let base = "";
/** Backend events received, for failure messages. */
const events: string[] = [];
beforeAll(() => {
onIdeEvent("*", (e) => events.push(e.type === "debug" ? `debug ${e.session} ${e.event.kind}` : e.type));
const disposers = [activateWorkbench(), activateBuildTools(), activateRun(), activateDebugger()];
deactivate = () => disposers.reverse().forEach((d) => d());
});
afterAll(() => deactivate?.());
afterEach(() => {
resetRuns();
disconnect?.();
server?.close();
if (base) rmSync(base, { recursive: true, force: true });
});
async function open(c: Case): Promise<string> {
base = realpathSync(mkdtempSync(join(tmpdir(), "openidea-smoke-")));
const root = join(base, c.fixture);
cpSync(join(repoRoot, "fixtures", c.fixture), root, { recursive: true });
c.setup?.(root);
server = await Server.start(join(base, "home"));
disconnect = connectDesktop(server);
await openProject(root);
const trusted = await commands.projectTrustSet(root, true, false);
if (trusted.status !== "ok") throw new Error(trusted.error.message);
useProject.setState({ project: { ...useProject.getState().project!, trusted: true } });
// Run and Debug follow the build (a Gradle project's Application runs through Gradle).
await until(
"the build model",
() => {
const error = useBuild.getState().error;
if (error) throw new Error(`the build model did not load: ${error}`);
return useBuildModel.getState().root === root && useBuildModel.getState().model;
},
5 * 60_000,
);
return root;
}
async function install(tool: string) {
const tools = await call(commands.toolsList());
if (tools.find((t) => t.name === tool)?.status === "installed") return;
const channel = new Channel<InstallEvent>();
const info = await call(commands.toolsInstall(tool, nextRequestId(), channel));
expect(info.status, `${tool} installs`).toBe("installed");
}
/** What the Debug and Run tool windows show, for failure messages. */
function shown(): string {
const { sessions, console } = useDebug.getState();
const debug = Object.values(sessions).map(
(s) =>
`debug ${s.id} ${s.name} ${s.status} ${s.stopReason ?? ""}\n` +
(console[s.id] ?? [])
.slice(-40)
.map((l) => ` ${l.text}`)
.join(""),
);
const runs = useRun.getState().sessions.map((s) => `run ${s.id} ${s.config.name} ${s.status} ${s.exitCode ?? ""}`);
const notes = useNotifications.getState().items.map((n) => `${n.level}: ${n.title} ${n.body ?? ""}`);
return [...debug, ...runs, ...notes, `events: ${events.slice(-60).join(", ")}`, server?.log() ?? ""].join("\n");
}
/** Waits for `value` to be truthy; fails with what the tool windows show. */
async function until<T>(what: string, value: () => T | null | undefined | false, timeoutMs = 60_000): Promise<T> {
const end = Date.now() + timeoutMs;
for (;;) {
const v = value();
if (v) return v;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}\n${shown()}`);
await new Promise((r) => setTimeout(r, 200));
}
}
/** Requests `url` once the debugged server accepts connections (the response waits for Resume). */
async function requestWhenListening(url: string) {
for (let i = 0; i < 600; i++) {
const connected = await new Promise<boolean>((resolve) => {
const req = get(url, (res) => {
res.resume();
resolve(true);
});
req.on("socket", (socket) => socket.on("connect", () => resolve(true)));
req.on("error", () => resolve(false));
});
if (connected) return;
await new Promise((r) => setTimeout(r, 500));
}
}
const paused = () => {
const s = activeSession();
return s?.status === "paused" && s.frames.length > 0 ? s : null;
};
/** Paused, failing as soon as every session has ended instead. */
const pausedOrEnded = () => {
const sessions = Object.values(useDebug.getState().sessions);
if (sessions.length > 0 && sessions.every((s) => s.status === "terminated"))
throw new Error(`the debugger ended before the breakpoint\n${shown()}`);
return paused();
};
async function smoke(language: string) {
const c = CASES[language]!;
const root = await open(c);
await install(c.debugger);
const path = join(root, c.file);
const content = readFileSync(path, "utf8");
const marker = content.split("\n").findIndex((l) => l.includes("Set a breakpoint here"));
const line = marker + 1;
addBreakpoint(path, line);
await executeAction("run.debugContext", { path, content });
if (c.request) void requestWhenListening(c.request);
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
const top = s.frames[0]!;
expect(top.path).toBe(path);
expect(top.line).toBe(line);
const scopes = await call(commands.debugScopes(s.id, top.id));
const locals = await call(commands.debugVariables(s.id, scopes[0]!.variablesReference));
expect(locals.map((v) => v.name)).toContain(c.variable);
const value = await evaluate(c.evaluate[0], "repl", s);
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
// (and its breakpoint) again.
for (let step = 0; step < 3 && paused()!.frames[0]!.line === line; step++) {
const stops = useDebug.getState().stopSeq;
await executeAction("debug.stepOver");
await until("Step Over", () => useDebug.getState().stopSeq > stops && paused());
}
const after = paused()!;
expect(after.stopReason, shown()).toBe("step");
expect(after.frames[0]!.line, shown()).toBeGreaterThan(line);
await executeAction("debug.resume");
if (c.request) {
await until("the request to be served", () => useDebug.getState().sessions[s.id]?.status === "running");
await executeAction("debug.stop");
}
// Every session ends (a JVM that listens again as its debugger detaches may be attached once more).
await until("the end", () => Object.values(useDebug.getState().sessions).every((d) => d.status === "terminated"));
}
for (const language of Object.keys(CASES)) {
test(language, () => smoke(language));
}
+11 -7
View File
@@ -3,23 +3,25 @@
* (callbacks, `Channel`s, the event plugin) in-process, and every app command sent to an
* `openidea-server` through the route remote development uses (`src/platform/tauriCore.ts`).
*/
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { setInvokeRoute } from "@/platform/tauriCore";
import { encodeArgs } from "@/platform/remote";
import { Server } from "./server";
type Callback = (message: unknown) => void;
// One runtime per page, as in the app: the frontend registers its event listeners once
// (`src/platform/ipc.ts`), so they outlive the servers connected one after another.
// One runtime per page, there from the start as in the app: the frontend registers its event
// listener once (`src/platform/ipc.ts`), possibly before a server is connected, and keeps it
// across the servers connected one after another.
const callbacks = new Map<number, Callback>();
const listeners: { event: string; handler: number }[] = [];
const channels = new Map<number, { onmessage: (data: unknown) => void }>();
let nextCallback = 1;
let installed = false;
/** `BaseDirectory` values (`@tauri-apps/api/path`) the frontend resolves. */
const DIRECTORIES: Record<number, string> = { 12: tmpdir(), 21: homedir() };
function install() {
if (installed) return;
installed = true;
{
const internals = {
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
transformCallback(cb: Callback) {
@@ -38,6 +40,9 @@ function install() {
return listeners.length;
}
if (cmd === "plugin:window|get_all_windows") return [];
// This machine's directories (`@tauri-apps/api/path`), as on the desktop.
if (cmd === "plugin:path|resolve_directory") return DIRECTORIES[args.directory as number] ?? null;
if (cmd === "plugin:path|join") return join(...(args.paths as string[]));
if (cmd.startsWith("plugin:")) return null;
throw { kind: "unavailable", message: `${cmd} has no route`, detail: null };
},
@@ -50,7 +55,6 @@ function install() {
/** Connects this page's frontend to `server`; returns a function that disconnects it. */
export function connectDesktop(server: Server): () => void {
install();
channels.clear();
server.onChannel = (id, data) => channels.get(id)?.onmessage(data);
server.onEvent = (target, event) => {
+3
View File
@@ -3,6 +3,7 @@
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { dirname, join } from "node:path";
const PROTOCOL_VERSION = 2;
@@ -35,6 +36,8 @@ export class Server {
private constructor(private readonly child: ChildProcessWithoutNullStreams) {
child.stdout.on("data", (chunk: Buffer) => this.receive(chunk));
child.stderr.on("data", (chunk: Buffer) => {
// The whole log, when asked for (OPENIDEA_SMOKE_LOG=<file>; OPENIDEA_LOG sets its level).
if (process.env.OPENIDEA_SMOKE_LOG) appendFileSync(process.env.OPENIDEA_SMOKE_LOG, chunk);
this.stderr.push(chunk.toString());
if (this.stderr.length > 200) this.stderr.shift();
});
@@ -79,6 +79,27 @@ describe("run config → launch config", () => {
expect(extra(globalPytest).python).toBe("python3");
});
it("debugpy: uv / poetry run the debuggee in the project's environment", () => {
const debug = (command: string, args: string[]) =>
launchConfigFor(rc({ kind: "python", command, args, debugAdapter: "debugpy" }), "/p")!;
const script = debug("uv", ["run", "python", "main.py", "-v"]);
expect(script.program).toBe("/p/main.py");
expect(script.args).toEqual(["-v"]);
expect(extra(script)).toMatchObject({ python: ["uv", "run", "python"], debugLauncherPython: "python3" });
expect(extra(script).module).toBeUndefined();
const mod = debug("poetry", ["run", "python3", "-m", "app"]);
expect(extra(mod)).toMatchObject({ module: "app", python: ["poetry", "run", "python3"] });
const tool = debug("uv", ["run", "pytest", "-x"]);
expect(extra(tool)).toMatchObject({ module: "pytest", python: ["uv", "run", "python"] });
expect(tool.args).toEqual(["-x"]);
const django = debug("pdm", ["run", "python", "manage.py", "runserver"]);
expect(extra(django)).toMatchObject({ django: true, python: ["pdm", "run", "python"] });
});
it("js-debug: npm scripts run through the package manager, node scripts directly", () => {
const npm = launchConfigFor(
rc({ kind: "npm", command: "pnpm", args: ["run", "dev"], cwd: "web", debugAdapter: "js-debug" }),
+28 -13
View File
@@ -58,15 +58,20 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
};
}
const PYTHON_NAME = /^python[\d.]*(\.exe)?$/;
function pythonFor(rc: RunConfig, cwd: string): string {
const cmd = rc.command;
const name = cmd.split("/").pop() ?? cmd;
if (/^python[\d.]*(\.exe)?$/.test(name)) return resolveCommand(cmd, cwd);
if (PYTHON_NAME.test(name)) return resolveCommand(cmd, cwd);
// A venv tool such as `.venv/bin/pytest`: use the interpreter next to it.
if (cmd.includes("/")) return resolveCommand(cmd.slice(0, cmd.lastIndexOf("/") + 1) + "python", cwd);
return "python3";
}
/** Project managers whose `run` executes in the project's environment (`uv run python …`). */
const PYTHON_RUNNERS = new Set(["uv", "poetry", "pdm", "pipenv", "hatch", "rye"]);
function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
const out = base(rc, "debugpy", cwd);
const dbg = workflowSettings().debugger;
@@ -78,19 +83,29 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
redirectOutput: true,
};
if (dbg.python.gevent) extra.gevent = true;
const name = rc.command.split("/").pop() ?? rc.command;
const isPython = /^python[\d.]*(\.exe)?$/.test(name);
if (!isPython) {
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
let args = rc.args;
let tool: string | null = PYTHON_NAME.test(name) ? null : name;
if (PYTHON_RUNNERS.has(name) && args[0] === "run" && args[1]) {
// The debuggee runs through the manager (`uv run python <debugpy> …`) to get the project's
// environment; the adapter and its launcher need a plain interpreter.
const python = PYTHON_NAME.test(args[1]) ? args[1] : "python";
extra.python = [resolveCommand(rc.command, cwd), "run", python];
extra.debugLauncherPython = "python3";
tool = PYTHON_NAME.test(args[1]) ? null : args[1];
args = args.slice(2);
}
if (tool) {
// A console script (`pytest`, `uvicorn`, …): run it as a module.
extra.module = name.replace(/\.exe$/, "");
out.args = [...rc.args];
} else if (rc.args[0] === "-m" && rc.args[1]) {
extra.module = rc.args[1];
out.args = rc.args.slice(2);
} else if (rc.args[0]) {
out.program = join(cwd, rc.args[0]);
out.args = rc.args.slice(1);
if (rc.args[0].endsWith("manage.py")) extra.django = true;
extra.module = tool;
out.args = [...args];
} else if (args[0] === "-m" && args[1]) {
extra.module = args[1];
out.args = args.slice(2);
} else if (args[0]) {
out.program = join(cwd, args[0]);
out.args = args.slice(1);
if (args[0].endsWith("manage.py")) extra.django = true;
}
out.extraJson = JSON.stringify(extra);
return out;
@@ -15,7 +15,7 @@ import {
type InspectionNode,
} from "../pages/editor/inspectionsModel";
import { splitPageTarget } from "../model/staging";
import { validateInspections } from "@/platform/inspections";
import { JAVA_SERIAL_VERSION_INSPECTION, validateInspections } from "@/platform/inspections";
import { NATURAL_LANGUAGES_PAGE, SPELLING_PAGE } from "../pages/editor/spelling";
import { INSPECTIONS_PAGE } from "../pages/editor/inspections";
import { EDITOR_PAGES } from "../pages/editor";
@@ -69,6 +69,7 @@ describe("inspections tree", () => {
expect(tree.map((g) => g.name)).toEqual([
"Cargo",
"General",
"Java",
"JavaScript",
"Other",
"Proofreading",
@@ -109,6 +110,19 @@ describe("inspections tree", () => {
expect(checkState(o, python)).toBe("on");
});
it("keeps an inspection that is off by default off until turned on", () => {
const serial = find(tree, `Java/${JAVA_SERIAL_VERSION_INSPECTION}`)!;
expect(checkState({}, serial)).toBe("off");
let o = setEnabled({}, serial, true);
expect(o).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
expect(checkState(o, serial)).toBe("on");
o = setEnabled(o, serial, false);
expect(o).toEqual({});
// Turning a whole group on or off only records what differs from each default.
const java = find(tree, "group:Java")!;
expect(setEnabled({}, java, true)).toEqual({ [JAVA_SERIAL_VERSION_INSPECTION]: { enabled: true } });
});
it("keeps severity and enabled overrides independent", () => {
let o = setLevel({}, "lsp:ruff", "error");
o = setEnabled(o, find(tree, "Python/lsp:ruff")!, false);
@@ -8,6 +8,7 @@ import {
INSPECTION_LEVELS,
codeInspectionId,
customProfileId,
enabledByDefault,
newProfileKey,
overridesOf,
producerInspectionId,
@@ -159,7 +160,7 @@ export function inspectionIds(node: InspectionNode): string[] {
return [...(node.id ? [node.id] : []), ...node.children.flatMap(inspectionIds)];
}
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled !== false;
export const isEnabled = (o: ProfileOverrides, id: string) => o[id]?.enabled ?? enabledByDefault(id);
export type CheckState = "on" | "off" | "mixed";
@@ -181,9 +182,12 @@ function withOverride(o: ProfileOverrides, id: string, patch: Partial<Inspection
return next;
}
/** Turns a node and all its descendants on or off (on is the default, so it removes the override). */
/** Turns a node and all its descendants on or off; an inspection back at its default drops the override. */
export function setEnabled(o: ProfileOverrides, node: InspectionNode, enabled: boolean): ProfileOverrides {
return inspectionIds(node).reduce((acc, id) => withOverride(acc, id, { enabled: enabled ? undefined : false }), o);
return inspectionIds(node).reduce(
(acc, id) => withOverride(acc, id, { enabled: enabled === enabledByDefault(id) ? undefined : enabled }),
o,
);
}
/** `undefined` restores the default ("As reported"). */
+6 -2
View File
@@ -62,9 +62,9 @@
.icon {
flex: none;
}
/* The name takes the whole row; when it doesn't fit, the grey detail after it gives way first. */
.label {
flex: 0 0 auto;
max-width: 75%;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -74,6 +74,7 @@
font-weight: 600;
}
.detail {
flex: 0 10000 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -190,6 +191,9 @@
align-items: center;
gap: 8px;
}
.stashRow > .detail {
flex-shrink: 1;
}
.stashMessage {
flex: 1;
min-width: 0;
+21 -1
View File
@@ -216,9 +216,14 @@ export interface BuiltinInspection {
description: string;
/** Highlighting level when the profile sets none (else as reported). */
defaultLevel?: InspectionLevel;
/** `false`: off until a profile turns it on. */
defaultEnabled?: boolean;
options?: readonly InspectionOptionSpec[];
}
/** Turned on, the Java language server's compiler reports it (`ide-sdk` `SERIAL_VERSION_INSPECTION`). */
export const JAVA_SERIAL_VERSION_INSPECTION = "java:missing-serial-version";
/** Producer key of the Typo inspection's diagnostics. */
export const SPELLING_PRODUCER = "spelling";
export const SPELLING_INSPECTION = "builtin:spelling";
@@ -283,6 +288,16 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
group: "General",
description: "Compiler errors and warnings of the last Build Project, shown at their source positions.",
},
{
id: JAVA_SERIAL_VERSION_INSPECTION,
producer: "jdt-serial-version",
name: "Serializable class without 'serialVersionUID'",
group: "Java",
description:
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.",
defaultLevel: "warning",
defaultEnabled: false,
},
{
id: "builtin:sql",
producer: "sql-inspections",
@@ -310,6 +325,11 @@ export const BUILTIN_INSPECTIONS: readonly BuiltinInspection[] = [
const BUILTIN_BY_PRODUCER = new Map(BUILTIN_INSPECTIONS.map((b) => [b.producer, b]));
const BUILTIN_BY_ID = new Map(BUILTIN_INSPECTIONS.map((b) => [b.id, b]));
/** Whether inspection `id` is on when a profile doesn't say. */
export function enabledByDefault(id: string): boolean {
return BUILTIN_BY_ID.get(id)?.defaultEnabled ?? true;
}
/** Option `key` of inspection `id` under `profile` (its default when unset). */
export function inspectionOption(profile: ProfileOverrides, id: string, key: string): boolean {
const value = profile[id]?.options?.[key];
@@ -363,7 +383,7 @@ export function applyProfile<D extends Diagnostic>(
): readonly D[] {
const base = producerInspectionId(producer);
const own = profile[base];
if (own?.enabled === false) return [];
if (!(own?.enabled ?? enabledByDefault(base))) return [];
const fallback = BUILTIN_BY_PRODUCER.get(producer)?.defaultLevel;
let changed = false;
const out: D[] = [];
+2
View File
@@ -32,6 +32,8 @@
"Not found": "未找到",
"Not found on PATH": "未在 PATH 中找到",
"Reports newer crate versions that Cargo.toml requirements do not already allow.": "报告 Cargo.toml 版本要求尚未允许的较新 crate 版本。",
"Serializable class without 'serialVersionUID'": "没有 'serialVersionUID' 的可序列化类",
"Reports classes that implement Serializable but declare no serialVersionUID field. Alt+Enter on the class name adds a default or a generated ID. The Java language server checks it, and restarts when this is changed.": "报告实现了 Serializable 但没有声明 serialVersionUID 字段的类。在类名上按 Alt+Enter 可添加默认 ID 或生成的 ID。此检查由 Java 语言服务器执行,更改后语言服务器会重启。",
"Run `auggie login` in a terminal.": "在终端中运行 `auggie login`。",
"Run `claude` in a terminal and sign in.": "在终端中运行 `claude` 并登录。",
"Run `codex login` in a terminal.": "在终端中运行 `codex login`。",