дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -0,0 +1,253 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
|
||||
const ipc = vi.hoisted(() => {
|
||||
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
ok,
|
||||
debugStep: vi.fn(() => ok(null)),
|
||||
debugForceStepOver: vi.fn(() => ok(null)),
|
||||
debugRunTo: vi.fn(() => ok(null)),
|
||||
debugStepInTargets: vi.fn(() => ok([] as Array<{ id: number; label: string }>)),
|
||||
debugStepInTarget: vi.fn(() => ok(null)),
|
||||
debugRestartFrame: vi.fn(() => ok(null)),
|
||||
debugAttachableProcesses: vi.fn(() =>
|
||||
ok([
|
||||
{ pid: 9, name: "node", commandLine: "node a.js", kind: "node", adapter: "js-debug", host: null, port: null },
|
||||
]),
|
||||
),
|
||||
debugStackTrace: vi.fn(() => ok([])),
|
||||
debugThreads: vi.fn(() => ok([])),
|
||||
debugScopes: vi.fn(() => ok([])),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const { ok: _ok, ...cmds } = ipc;
|
||||
return { ...real, commands: { ...real.commands, ...cmds } };
|
||||
});
|
||||
|
||||
const editor = vi.hoisted(() => ({
|
||||
current: null as null | { ctx: { path: string; language: string }; view: { state: unknown } },
|
||||
}));
|
||||
vi.mock("@/editor/api", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/editor/api")>();
|
||||
return { ...real, activeEditor: () => editor.current };
|
||||
});
|
||||
|
||||
const opened = vi.hoisted(() => [] as Array<{ path: string; focus: boolean | undefined }>);
|
||||
vi.mock("@/workbench/contributions", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/workbench/contributions")>();
|
||||
return {
|
||||
...real,
|
||||
maybeEditorService: () => ({
|
||||
openFile: async (path: string, o?: { focus?: boolean }) => void opened.push({ path, focus: o?.focus }),
|
||||
saveAll: async () => undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { intellijMacos } from "@/platform/keymaps/intellij-macos";
|
||||
import type { DebugCapabilities } from "@/platform/ipc";
|
||||
import { registerDebugActions } from "../actions";
|
||||
import { breakpointsOf, loadBreakpoints } from "../breakpoints/store";
|
||||
import { loadSpecialBreakpoints, removeAllSpecial, useSpecialBreakpoints } from "../breakpoints/special";
|
||||
import { publishContext, useDebug, type DebugSession } from "../state";
|
||||
import { focusDebugView, blurDebugView } from "../ui/focusedView";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
|
||||
const caps = (p: Partial<DebugCapabilities> = {}): DebugCapabilities => ({
|
||||
restartFrame: false,
|
||||
stepInTargets: false,
|
||||
functionBreakpoints: false,
|
||||
dataBreakpoints: false,
|
||||
hotCodeReplace: false,
|
||||
setValue: true,
|
||||
conditionalBreakpoints: true,
|
||||
hitConditionalBreakpoints: true,
|
||||
logPoints: true,
|
||||
...p,
|
||||
});
|
||||
|
||||
function paused(p: Partial<DebugSession> = {}) {
|
||||
const s: DebugSession = {
|
||||
id: 3,
|
||||
name: "app",
|
||||
adapter: "debugpy",
|
||||
parent: null,
|
||||
launch: null,
|
||||
process: null,
|
||||
status: "paused",
|
||||
stoppedThread: 1,
|
||||
stopReason: "breakpoint",
|
||||
stopDescription: null,
|
||||
threads: [{ id: 1, name: "main" }],
|
||||
selectedThread: 1,
|
||||
frames: [
|
||||
{ id: 70, name: "parse", path: "/p/app.py", line: 2, column: 4, presentationHint: null },
|
||||
{ id: 71, name: "run", path: "/p/app.py", line: 9, column: 0, presentationHint: null },
|
||||
],
|
||||
selectedFrame: 71,
|
||||
hits: 1,
|
||||
exitCode: null,
|
||||
caps: caps(),
|
||||
...p,
|
||||
};
|
||||
useDebug.setState({ sessions: { 3: s }, active: 3 });
|
||||
publishContext();
|
||||
}
|
||||
|
||||
function caret(path: string, language: string, doc: string, line: number) {
|
||||
const state = EditorState.create({ doc });
|
||||
const pos = state.doc.line(line + 1).from;
|
||||
editor.current = {
|
||||
ctx: { path, language },
|
||||
view: { state: EditorState.create({ doc, selection: { anchor: pos } }) },
|
||||
};
|
||||
}
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
let dispose: () => void;
|
||||
beforeAll(() => {
|
||||
dispose = registerDebugActions();
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
|
||||
});
|
||||
afterAll(() => {
|
||||
dispose();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
opened.length = 0;
|
||||
localStorage.clear();
|
||||
loadBreakpoints("/p");
|
||||
loadSpecialBreakpoints("/p");
|
||||
removeAllSpecial();
|
||||
useOverlays.setState({ smartStep: null, attach: null });
|
||||
});
|
||||
|
||||
describe("debugger actions (IDEA parity)", () => {
|
||||
it("registers stable ids with IDEA's macOS shortcuts", () => {
|
||||
const expected: Record<string, string[] | undefined> = {
|
||||
"debug.attachToProcess": ["Alt+Shift+F5"],
|
||||
"debug.toggleTemporaryBreakpoint": ["Meta+Alt+Shift+F8"],
|
||||
"debug.forceStepOver": ["Alt+Shift+F8"],
|
||||
"debug.forceStepInto": ["Alt+Shift+F7"],
|
||||
"debug.smartStepInto": ["Shift+F7"],
|
||||
"debug.forceRunToCursor": ["Meta+Alt+F9"],
|
||||
"debug.showExecutionPoint": ["Alt+F10"],
|
||||
"debug.setValue": ["F2"],
|
||||
"debug.jumpToSource": ["F4"],
|
||||
"debug.resetFrame": undefined,
|
||||
"debug.toggleMethodBreakpoint": undefined,
|
||||
"debug.toggleFieldWatchpoint": undefined,
|
||||
};
|
||||
for (const [id, keys] of Object.entries(expected)) {
|
||||
expect(getAction(id), id).toBeTruthy();
|
||||
expect(intellijMacos[id], id).toEqual(keys);
|
||||
}
|
||||
// F2 / F4 only act inside the debugger views, so the editor keeps its own.
|
||||
expect(getAction("debug.setValue")!.keyWhen).toBe("debugVariablesFocus");
|
||||
expect(getAction("debug.jumpToSource")!.keyWhen).toBe("debugFramesFocus || debugVariablesFocus");
|
||||
});
|
||||
|
||||
it("Force Step Over / Force Step Into / Force Run to Cursor", async () => {
|
||||
paused();
|
||||
caret("/p/app.py", "python", "a\nb\nc\nd", 2);
|
||||
await executeAction("debug.forceStepOver");
|
||||
expect(ipc.debugForceStepOver).toHaveBeenCalledWith(3, 1);
|
||||
await executeAction("debug.forceStepInto");
|
||||
expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into");
|
||||
await executeAction("debug.forceRunToCursor");
|
||||
expect(ipc.debugRunTo).toHaveBeenCalledWith(3, 1, "/p/app.py", 2, true);
|
||||
await executeAction("debug.runToCursor");
|
||||
expect(ipc.debugRunTo).toHaveBeenLastCalledWith(3, 1, "/p/app.py", 2, false);
|
||||
});
|
||||
|
||||
it("Smart Step Into: chooser for several calls, direct step for one, Step Into without support", async () => {
|
||||
paused({ caps: caps({ stepInTargets: true }) });
|
||||
ipc.debugStepInTargets.mockReturnValueOnce(
|
||||
ipc.ok([
|
||||
{ id: 1, label: "str(i)" },
|
||||
{ id: 2, label: "parse(str(i))" },
|
||||
]),
|
||||
);
|
||||
await executeAction("debug.smartStepInto");
|
||||
await settle();
|
||||
expect(ipc.debugStepInTargets).toHaveBeenCalledWith(3, 71);
|
||||
expect(useOverlays.getState().smartStep?.targets.map((t) => t.label)).toEqual(["str(i)", "parse(str(i))"]);
|
||||
|
||||
ipc.debugStepInTargets.mockReturnValueOnce(ipc.ok([{ id: 5, label: "parse()" }]));
|
||||
await executeAction("debug.smartStepInto");
|
||||
await settle();
|
||||
expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 5);
|
||||
|
||||
paused({ caps: caps({ stepInTargets: false }) });
|
||||
await executeAction("debug.smartStepInto");
|
||||
await settle();
|
||||
expect(ipc.debugStep).toHaveBeenCalledWith(3, 1, "into");
|
||||
});
|
||||
|
||||
it("Reset Frame restarts the selected frame when the adapter can", async () => {
|
||||
paused({ caps: caps({ restartFrame: false }) });
|
||||
await executeAction("debug.resetFrame");
|
||||
expect(ipc.debugRestartFrame).not.toHaveBeenCalled();
|
||||
paused({ caps: caps({ restartFrame: true }) });
|
||||
await executeAction("debug.resetFrame");
|
||||
expect(ipc.debugRestartFrame).toHaveBeenCalledWith(3, 71);
|
||||
});
|
||||
|
||||
it("Show Execution Point reveals the top frame with focus", async () => {
|
||||
paused();
|
||||
await executeAction("debug.showExecutionPoint");
|
||||
await settle();
|
||||
expect(useDebug.getState().sessions[3]!.selectedFrame).toBe(70);
|
||||
expect(opened.at(-1)).toEqual({ path: "/p/app.py", focus: true });
|
||||
});
|
||||
|
||||
it("Jump to Source and Set Value go to the focused debugger view", async () => {
|
||||
paused();
|
||||
const setValue = vi.fn();
|
||||
const jump = vi.fn();
|
||||
focusDebugView({ view: "variables", setValue, jumpToSource: jump });
|
||||
await executeAction("debug.setValue");
|
||||
await executeAction("debug.jumpToSource");
|
||||
expect(setValue).toHaveBeenCalledOnce();
|
||||
expect(jump).toHaveBeenCalledOnce();
|
||||
blurDebugView("variables");
|
||||
// No view focused: the selected frame's source.
|
||||
await executeAction("debug.jumpToSource");
|
||||
await settle();
|
||||
expect(opened.at(-1)).toEqual({ path: "/p/app.py", focus: true });
|
||||
});
|
||||
|
||||
it("toggles temporary, method and field breakpoints at the caret", async () => {
|
||||
caret("/p/a.py", "python", "x = 1\ndef run():\n pass", 0);
|
||||
await executeAction("debug.toggleTemporaryBreakpoint");
|
||||
expect(breakpointsOf("/p/a.py")[0]).toMatchObject({ line: 0, temporary: true });
|
||||
|
||||
caret("/p/a.py", "python", "x = 1\ndef run():\n pass", 1);
|
||||
await executeAction("debug.toggleMethodBreakpoint");
|
||||
expect(useSpecialBreakpoints.getState().methods).toMatchObject([{ name: "run", path: "/p/a.py", line: 1 }]);
|
||||
await executeAction("debug.toggleMethodBreakpoint");
|
||||
expect(useSpecialBreakpoints.getState().methods).toEqual([]);
|
||||
|
||||
const java = "package a;\nclass A {\n private int count;\n}";
|
||||
caret("/p/A.java", "java", java, 2);
|
||||
await executeAction("debug.toggleFieldWatchpoint");
|
||||
expect(useSpecialBreakpoints.getState().fields).toMatchObject([{ dataId: "a.A#count", accessType: "write" }]);
|
||||
// ⌘F8 on a Java declaration toggles the watchpoint off again.
|
||||
await executeAction("debug.toggleBreakpoint");
|
||||
expect(useSpecialBreakpoints.getState().fields).toEqual([]);
|
||||
});
|
||||
|
||||
it("Attach to Process opens the popup and loads the process list", async () => {
|
||||
await executeAction("debug.attachToProcess");
|
||||
await settle();
|
||||
expect(ipc.debugAttachableProcesses).toHaveBeenCalled();
|
||||
expect(useOverlays.getState().attach?.processes?.map((p) => p.pid)).toEqual([9]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AttachableProcess } from "@/platform/ipc";
|
||||
|
||||
vi.mock("../java", () => ({
|
||||
javaAttachConfig: vi.fn(async (t: { name: string; hostName: string; port: number }, root: string) => ({
|
||||
name: t.name,
|
||||
adapter: "java-debug",
|
||||
request: "attach",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd: root,
|
||||
env: {},
|
||||
extraJson: JSON.stringify({ debugServer: 4711, hostName: t.hostName, port: t.port }),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { attachConfig, attachDetail, attachRows, matchesFilter } from "../attach";
|
||||
|
||||
const proc = (p: Partial<AttachableProcess> & Pick<AttachableProcess, "pid" | "kind">): AttachableProcess => ({
|
||||
name: p.kind,
|
||||
commandLine: `${p.kind} app`,
|
||||
adapter: { java: "java-debug", node: "js-debug", python: "debugpy", go: "delve", native: "codelldb" }[p.kind],
|
||||
host: null,
|
||||
port: null,
|
||||
...p,
|
||||
});
|
||||
|
||||
const list = [
|
||||
proc({ pid: 30, kind: "native", name: "a.out", commandLine: "./a.out" }),
|
||||
proc({ pid: 10, kind: "java", name: "java", commandLine: "java -agentlib:jdwp=... -jar shop.jar", port: 5005 }),
|
||||
proc({ pid: 20, kind: "python", name: "python3", commandLine: "python3 worker.py" }),
|
||||
proc({
|
||||
pid: 21,
|
||||
kind: "python",
|
||||
name: "python3",
|
||||
commandLine: "python3 -m debugpy --listen 5678 api.py",
|
||||
port: 5678,
|
||||
}),
|
||||
proc({ pid: 25, kind: "node", name: "node", commandLine: "node server.js" }),
|
||||
proc({ pid: 26, kind: "go", name: "gosrv", commandLine: "./gosrv" }),
|
||||
];
|
||||
|
||||
describe("Attach to Process popup model", () => {
|
||||
it("groups processes by debugger in IDEA's order with counts", () => {
|
||||
const rows = attachRows(list, "");
|
||||
expect(rows.filter((r) => r.kind === "group").map((r) => r.kind === "group" && `${r.label}:${r.count}`)).toEqual([
|
||||
"Java (JDWP):1",
|
||||
"Node.js:1",
|
||||
"Python (debugpy):2",
|
||||
"Go (Delve):1",
|
||||
"Native (LLDB):1",
|
||||
]);
|
||||
expect(rows[0]!.kind).toBe("group");
|
||||
expect(rows[1]!.kind === "process" && rows[1]!.process.pid).toBe(10);
|
||||
});
|
||||
|
||||
it("filters on pid, name and command line (all words)", () => {
|
||||
expect(matchesFilter(list[2]!, "worker")).toBe(true);
|
||||
expect(matchesFilter(list[2]!, "20")).toBe(true);
|
||||
expect(matchesFilter(list[2]!, "python api")).toBe(false);
|
||||
const rows = attachRows(list, "debugpy");
|
||||
expect(rows.map((r) => r.key)).toEqual(["group:python", "python:21"]);
|
||||
expect(attachRows(list, "nothing-matches")).toEqual([]);
|
||||
});
|
||||
|
||||
it("describes how each process is reached", () => {
|
||||
expect(attachDetail(list[1]!)).toBe("localhost:5005");
|
||||
expect(attachDetail(list[2]!)).toBe("pid 20");
|
||||
});
|
||||
|
||||
it("builds the attach request of each debugger", async () => {
|
||||
const extra = async (p: AttachableProcess) => JSON.parse((await attachConfig(p, "/proj")).extraJson!);
|
||||
const java = await attachConfig(list[1]!, "/proj");
|
||||
expect(java.adapter).toBe("java-debug");
|
||||
expect(JSON.parse(java.extraJson!)).toMatchObject({ hostName: "localhost", port: 5005 });
|
||||
// debugpy: inject by pid, or talk to a `--listen` adapter directly.
|
||||
expect(await extra(list[2]!)).toMatchObject({ processId: 20 });
|
||||
expect(await extra(list[3]!)).toMatchObject({ debugServer: 5678 });
|
||||
// js-debug: the backend opens the inspector of a process started without --inspect.
|
||||
expect(await extra(list[4]!)).toMatchObject({ type: "pwa-node", processId: "25" });
|
||||
expect(await extra(proc({ pid: 27, kind: "node", port: 9230 }))).toMatchObject({
|
||||
port: 9230,
|
||||
address: "localhost",
|
||||
});
|
||||
expect(await extra(list[5]!)).toEqual({ mode: "local", processId: 26 });
|
||||
const native = await attachConfig(list[0]!, "/proj");
|
||||
expect(native).toMatchObject({ adapter: "codelldb", request: "attach", name: "a.out (30)", cwd: "/proj" });
|
||||
expect(JSON.parse(native.extraJson!)).toEqual({ pid: 30 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { ChangeSet, EditorState, Text } from "@codemirror/state";
|
||||
import { isConditional, makeBreakpoint, mapLines, toBackend, toggleLine } from "../breakpoints/model";
|
||||
import {
|
||||
addBreakpoint,
|
||||
breakpointsOf,
|
||||
loadBreakpoints,
|
||||
removeBreakpoint,
|
||||
setMuted,
|
||||
toggleBreakpoint,
|
||||
updateBreakpoint,
|
||||
useBreakpoints,
|
||||
} from "../breakpoints/store";
|
||||
|
||||
const doc = (lines: number) => Text.of(Array.from({ length: lines }, (_, i) => `line ${i}`));
|
||||
|
||||
function edit(text: Text, spec: { from: number; to?: number; insert?: string }) {
|
||||
const changes = ChangeSet.of([spec], text.length);
|
||||
return { changes, next: changes.apply(text) };
|
||||
}
|
||||
|
||||
describe("breakpoint model", () => {
|
||||
it("toggles a line on and off, keeping the list sorted", () => {
|
||||
let list = toggleLine([], 5);
|
||||
list = toggleLine(list, 2);
|
||||
expect(list.map((b) => b.line)).toEqual([2, 5]);
|
||||
list = toggleLine(list, 5);
|
||||
expect(list.map((b) => b.line)).toEqual([2]);
|
||||
});
|
||||
|
||||
it("treats conditions, hit counts and log messages as conditional", () => {
|
||||
expect(isConditional(makeBreakpoint(1))).toBe(false);
|
||||
expect(isConditional(makeBreakpoint(1, { condition: "x > 1" }))).toBe(true);
|
||||
expect(isConditional(makeBreakpoint(1, { condition: " " }))).toBe(false);
|
||||
expect(isConditional(makeBreakpoint(1, { logMessage: "x={x}" }))).toBe(true);
|
||||
expect(isConditional(makeBreakpoint(1, { hitCondition: "3" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("maps lines down when lines are inserted above", () => {
|
||||
const text = doc(10);
|
||||
const list = [makeBreakpoint(3), makeBreakpoint(7)];
|
||||
// Insert two lines at the start of line 1.
|
||||
const { changes, next } = edit(text, { from: text.line(2).from, insert: "a\nb\n" });
|
||||
expect(mapLines(list, text, changes, next).map((b) => b.line)).toEqual([5, 9]);
|
||||
});
|
||||
|
||||
it("moves a breakpoint with its line when text is inserted at the line start", () => {
|
||||
const text = doc(5);
|
||||
const list = [makeBreakpoint(2)];
|
||||
const { changes, next } = edit(text, { from: text.line(3).from, insert: "\n" });
|
||||
expect(mapLines(list, text, changes, next)[0]!.line).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps breakpoints above an edit and preserves identity", () => {
|
||||
const text = doc(5);
|
||||
const bp = makeBreakpoint(1);
|
||||
const { changes, next } = edit(text, { from: text.line(4).from, insert: "x\n" });
|
||||
const out = mapLines([bp], text, changes, next);
|
||||
expect(out[0]).toBe(bp);
|
||||
});
|
||||
|
||||
it("maps up on deletion and merges breakpoints that collapse onto one line", () => {
|
||||
const text = doc(10);
|
||||
const list = [makeBreakpoint(2), makeBreakpoint(3), makeBreakpoint(8)];
|
||||
// Delete lines 2..3 (0-based) entirely.
|
||||
const { changes, next } = edit(text, { from: text.line(3).from, to: text.line(5).from });
|
||||
const out = mapLines(list, text, changes, next);
|
||||
expect(out.map((b) => b.line)).toEqual([2, 6]);
|
||||
});
|
||||
|
||||
it("works through EditorState transactions", () => {
|
||||
const state = EditorState.create({ doc: "a\nb\nc\nd" });
|
||||
const tr = state.update({ changes: { from: 0, insert: "zero\n" } });
|
||||
const out = mapLines([makeBreakpoint(3)], state.doc, tr.changes, tr.state.doc);
|
||||
expect(out[0]!.line).toBe(4);
|
||||
});
|
||||
|
||||
it("serialises for the backend, muting disables everything", () => {
|
||||
const list = [makeBreakpoint(1, { condition: " x ", enabled: true }), makeBreakpoint(4, { enabled: false })];
|
||||
expect(toBackend(list, false)).toEqual([
|
||||
{ line: 1, condition: "x", hitCondition: null, logMessage: null, enabled: true },
|
||||
{ line: 4, condition: null, hitCondition: null, logMessage: null, enabled: false },
|
||||
]);
|
||||
expect(toBackend(list, true).every((b) => !b.enabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakpoint store", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
loadBreakpoints("/proj");
|
||||
});
|
||||
|
||||
it("toggles, edits and removes per file", () => {
|
||||
toggleBreakpoint("/proj/a.py", 4);
|
||||
toggleBreakpoint("/proj/a.py", 1);
|
||||
toggleBreakpoint("/proj/b.py", 0);
|
||||
expect(breakpointsOf("/proj/a.py").map((b) => b.line)).toEqual([1, 4]);
|
||||
const bp = breakpointsOf("/proj/a.py")[1]!;
|
||||
updateBreakpoint("/proj/a.py", bp.id, { condition: "i == 3", enabled: false });
|
||||
expect(breakpointsOf("/proj/a.py")[1]).toMatchObject({ condition: "i == 3", enabled: false, line: 4 });
|
||||
removeBreakpoint("/proj/a.py", bp.id);
|
||||
expect(breakpointsOf("/proj/a.py").map((b) => b.line)).toEqual([1]);
|
||||
toggleBreakpoint("/proj/a.py", 1);
|
||||
expect(useBreakpoints.getState().byFile["/proj/a.py"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds a conditional breakpoint replacing one on the same line", () => {
|
||||
toggleBreakpoint("/proj/a.py", 2);
|
||||
addBreakpoint("/proj/a.py", 2, { logMessage: "hit {i}" });
|
||||
const list = breakpointsOf("/proj/a.py");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]!.logMessage).toBe("hit {i}");
|
||||
});
|
||||
|
||||
it("persists per project, including mute", () => {
|
||||
toggleBreakpoint("/proj/a.py", 7);
|
||||
setMuted(true);
|
||||
loadBreakpoints("/other");
|
||||
expect(useBreakpoints.getState().byFile).toEqual({});
|
||||
loadBreakpoints("/proj");
|
||||
expect(breakpointsOf("/proj/a.py").map((b) => b.line)).toEqual([7]);
|
||||
expect(useBreakpoints.getState().muted).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores corrupt storage", () => {
|
||||
localStorage.setItem("openidea.debugger.breakpoints:/bad", "{nope");
|
||||
loadBreakpoints("/bad");
|
||||
expect(useBreakpoints.getState().byFile).toEqual({});
|
||||
localStorage.setItem(
|
||||
"openidea.debugger.breakpoints:/bad2",
|
||||
JSON.stringify({ files: { "/x": [{ line: "a" }, { line: 3 }] } }),
|
||||
);
|
||||
loadBreakpoints("/bad2");
|
||||
expect(breakpointsOf("/x").map((b) => b.line)).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView, GutterMarker, gutter, lineNumbers } from "@codemirror/view";
|
||||
import { breakpointGutter, lookOf } from "../editor/breakpointGutter";
|
||||
import { executionPoint } from "../editor/execution";
|
||||
import { breakpointsOf, loadBreakpoints, toggleBreakpoint, updateBreakpoint } from "../breakpoints/store";
|
||||
import { adoptSession, useExecutionPoint, useDebug, handleDebugEvent } from "../state";
|
||||
import { getContext } from "@/platform/context";
|
||||
import styles from "../editor/editor.module.css";
|
||||
|
||||
const ctx = { doc: 1, path: "/proj/a.py", language: "python", uiMode: null, readOnly: false };
|
||||
const views: EditorView[] = [];
|
||||
|
||||
function makeView(doc: string) {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc, extensions: [lineNumbers(), breakpointGutter(ctx), executionPoint(ctx)] }),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function gutterClasses(view: EditorView): string[] {
|
||||
return [...view.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement")].map((e) => e.className);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
views.splice(0).forEach((v) => v.destroy());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("breakpoint gutter", () => {
|
||||
it("renders markers as classes on line numbers and tracks edits", async () => {
|
||||
localStorage.clear();
|
||||
loadBreakpoints("/proj");
|
||||
const view = makeView("a\nb\nc\nd\ne");
|
||||
toggleBreakpoint(ctx.path, 2);
|
||||
await flush();
|
||||
expect(gutterClasses(view).filter((c) => c.includes(styles.bp!))).toHaveLength(1);
|
||||
// The number text is still rendered.
|
||||
expect(view.dom.querySelector(`.cm-lineNumbers .${styles.bp}`)?.textContent).toBe("3");
|
||||
|
||||
view.dispatch({ changes: { from: 0, insert: "new\n" } });
|
||||
expect(breakpointsOf(ctx.path).map((b) => b.line)).toEqual([3]);
|
||||
|
||||
const bp = breakpointsOf(ctx.path)[0]!;
|
||||
updateBreakpoint(ctx.path, bp.id, { enabled: false });
|
||||
await flush();
|
||||
expect(view.dom.querySelector(`.${styles.disabled}`)?.textContent).toBe("4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakpoint look", () => {
|
||||
it("shows the verified icon only while a session has bound it", () => {
|
||||
const b = { id: "1", line: 0, enabled: true, condition: null, logMessage: null, hitCondition: null };
|
||||
expect(lookOf(b, undefined, false, false)).toBe("normal");
|
||||
expect(lookOf(b, { verified: true } as never, false, true)).toBe("verified");
|
||||
expect(lookOf(b, { verified: false } as never, false, true)).toBe("unverified");
|
||||
expect(lookOf({ ...b, enabled: false }, { verified: true } as never, false, true)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("breakpoint click area", () => {
|
||||
class Icon extends GutterMarker {
|
||||
toDOM() {
|
||||
return document.createElement("span");
|
||||
}
|
||||
}
|
||||
|
||||
it("toggles from empty space in other gutters but leaves their markers alone", () => {
|
||||
localStorage.clear();
|
||||
loadBreakpoints("/proj");
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "a\nb",
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
gutter({ class: "extra", renderEmptyElements: true, lineMarker: (_v, line) => (line.from > 0 ? new Icon() : null) }),
|
||||
breakpointGutter(ctx),
|
||||
],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
const down = (el: Element) => el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
const cells = view.dom.querySelectorAll(".extra .cm-gutterElement");
|
||||
down(cells[cells.length - 1]!.firstElementChild!);
|
||||
expect(breakpointsOf(ctx.path)).toHaveLength(0);
|
||||
down(cells[0]!);
|
||||
expect(breakpointsOf(ctx.path)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("execution point", () => {
|
||||
it("highlights the suspended line and shows inline values", async () => {
|
||||
const view = makeView("x = 1\ny = x + 1\nprint(y)");
|
||||
useExecutionPoint.setState({ path: ctx.path, line: 1, top: true, values: { x: "1", y: "2" } });
|
||||
await flush();
|
||||
const line = view.dom.querySelectorAll(".cm-line")[1]!;
|
||||
expect(line.className).toContain(styles.execLine);
|
||||
const inline = [...view.dom.querySelectorAll(`.${styles.inlineValue}`)].map((e) => e.textContent);
|
||||
expect(inline).toEqual(["x: 1", "y: 2, x: 1"]);
|
||||
useExecutionPoint.setState({ path: null, values: {} });
|
||||
await flush();
|
||||
expect(view.dom.querySelector(`.${styles.execLine}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session events", () => {
|
||||
it("tracks pause / continue / termination and publishes context keys", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
adoptSession(77);
|
||||
handleDebugEvent(77, { kind: "output", category: "stdout", output: "hel" });
|
||||
handleDebugEvent(77, { kind: "output", category: "stdout", output: "lo\n" });
|
||||
expect(useDebug.getState().console[77]!.map((l) => l.text)).toEqual(["hello\n"]);
|
||||
expect(getContext().debugSessionActive).toBe(true);
|
||||
handleDebugEvent(77, {
|
||||
kind: "stopped",
|
||||
threadId: 1,
|
||||
reason: "breakpoint",
|
||||
description: null,
|
||||
allThreadsStopped: true,
|
||||
});
|
||||
const s = useDebug.getState().sessions[77]!;
|
||||
expect(s.status).toBe("paused");
|
||||
expect(s.hits).toBe(1);
|
||||
expect(getContext().debugSessionPaused).toBe(true);
|
||||
handleDebugEvent(77, { kind: "continued", threadId: 1, allThreadsContinued: true });
|
||||
expect(useDebug.getState().sessions[77]!.status).toBe("running");
|
||||
expect(getContext().debugSessionPaused).toBe(false);
|
||||
handleDebugEvent(77, { kind: "exited", code: 0 });
|
||||
handleDebugEvent(77, { kind: "terminated" });
|
||||
expect(useDebug.getState().sessions[77]!.status).toBe("terminated");
|
||||
expect(getContext().debugSessionActive).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores sessions this window did not start, but adopts children of its own", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
adoptSession(90, { name: "app", adapter: "js-debug" });
|
||||
// Another window's session: no session, no console, no tool window.
|
||||
handleDebugEvent(91, { kind: "started", name: "other", adapter: "debugpy", parent: null });
|
||||
handleDebugEvent(91, {
|
||||
kind: "stopped",
|
||||
threadId: 1,
|
||||
reason: "breakpoint",
|
||||
description: null,
|
||||
allThreadsStopped: true,
|
||||
});
|
||||
handleDebugEvent(91, { kind: "output", category: "stdout", output: "x\n" });
|
||||
expect(useDebug.getState().sessions[91]).toBeUndefined();
|
||||
expect(useDebug.getState().console[91]).toBeUndefined();
|
||||
// A child of someone else's session is not ours either.
|
||||
handleDebugEvent(93, { kind: "started", name: "child", adapter: "debugpy", parent: 91 });
|
||||
expect(useDebug.getState().sessions[93]).toBeUndefined();
|
||||
// A child the adapter of our session started is adopted with its parent.
|
||||
handleDebugEvent(92, { kind: "started", name: "worker", adapter: "js-debug", parent: 90 });
|
||||
handleDebugEvent(92, { kind: "output", category: "stdout", output: "child\n" });
|
||||
const child = useDebug.getState().sessions[92]!;
|
||||
expect([child.name, child.parent, child.status]).toEqual(["worker", 90, "running"]);
|
||||
expect(useDebug.getState().console[92]!.map((l) => l.text)).toEqual(["child\n"]);
|
||||
handleDebugEvent(92, { kind: "terminated" });
|
||||
handleDebugEvent(90, { kind: "terminated" });
|
||||
expect(getContext().debugSessionActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExceptionFilter } from "@/platform/ipc";
|
||||
import {
|
||||
exceptionGroups,
|
||||
isFilterEnabled,
|
||||
mergeKnown,
|
||||
sanitizeChoices,
|
||||
sanitizeKnown,
|
||||
} from "../breakpoints/exceptions";
|
||||
|
||||
const f = (filter: string, def = false): ExceptionFilter => ({
|
||||
filter,
|
||||
label: filter[0]!.toUpperCase() + filter.slice(1),
|
||||
description: null,
|
||||
default: def,
|
||||
supportsCondition: false,
|
||||
});
|
||||
|
||||
describe("exception breakpoints", () => {
|
||||
it("explicit choices win over adapter defaults", () => {
|
||||
expect(isFilterEnabled(f("uncaught", true), {})).toBe(true);
|
||||
expect(isFilterEnabled(f("uncaught", true), { uncaught: false })).toBe(false);
|
||||
expect(isFilterEnabled(f("raised"), { raised: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("groups filters by adapter with effective state", () => {
|
||||
const groups = exceptionGroups(
|
||||
{ debugpy: [f("raised"), f("uncaught", true)], codelldb: [f("rust_panic")] },
|
||||
{ raised: true },
|
||||
);
|
||||
expect(groups.map((g) => g.adapter)).toEqual(["codelldb", "debugpy"]);
|
||||
expect(groups[1]!.filters.map((x) => [x.filter, x.enabled])).toEqual([
|
||||
["raised", true],
|
||||
["uncaught", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges session filters into the cache without churn", () => {
|
||||
const known = { debugpy: [f("raised")] };
|
||||
expect(mergeKnown(known, [{ adapter: "debugpy", filters: [f("raised")] }])).toBe(known);
|
||||
expect(mergeKnown(known, [{ adapter: "debugpy", filters: [] }])).toBe(known);
|
||||
expect(mergeKnown(known, [{ adapter: "delve", filters: [f("panic")] }])).toEqual({ ...known, delve: [f("panic")] });
|
||||
});
|
||||
|
||||
it("sanitizes persisted data", () => {
|
||||
expect(sanitizeChoices({ a: true, b: "x", c: false })).toEqual({ a: true, c: false });
|
||||
expect(sanitizeChoices([1])).toEqual({});
|
||||
expect(sanitizeKnown({ x: [f("a"), { nope: 1 }], y: "z" })).toEqual({ x: [f("a")] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ipc = vi.hoisted(() => {
|
||||
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
ok,
|
||||
javaBuildWorkspace: vi.fn(() => ok("succeed" as string)),
|
||||
debugRedefineClasses: vi.fn(() => ok({ changedClasses: ["com.x.A", "com.x.B"], errorMessage: null as string | null })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const { ok: _ok, ...cmds } = ipc;
|
||||
return { ...real, isTauri: () => true, commands: { ...real.commands, ...cmds } };
|
||||
});
|
||||
|
||||
const notes = vi.hoisted(() => [] as Array<{ level: string; title: string; body?: unknown; actions?: unknown[] }>);
|
||||
vi.mock("@/workbench/contributions", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/workbench/contributions")>();
|
||||
return {
|
||||
...real,
|
||||
notifications: () => ({ notify: (n: (typeof notes)[number]) => void notes.push(n) }),
|
||||
maybeEditorService: () => ({ saveAll: async () => undefined }),
|
||||
};
|
||||
});
|
||||
|
||||
import type { DebugCapabilities, DebugSessionId } from "@/platform/ipc";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { hotSwapMessage, onHotCodeReplace, reloadChangedClasses } from "../hotswap";
|
||||
import { publishContext, useDebug, type DebugSession } from "../state";
|
||||
|
||||
function session(id: number, hotCodeReplace: boolean): DebugSession {
|
||||
return {
|
||||
id: id as DebugSessionId,
|
||||
name: "App",
|
||||
adapter: hotCodeReplace ? "java-debug" : "debugpy",
|
||||
parent: null,
|
||||
launch: { name: "App", adapter: "java-debug", request: "launch", program: null, args: [], cwd: "/p/app", env: {}, extraJson: null },
|
||||
process: null,
|
||||
status: "running",
|
||||
stoppedThread: null,
|
||||
stopReason: null,
|
||||
stopDescription: null,
|
||||
threads: [],
|
||||
selectedThread: null,
|
||||
frames: [],
|
||||
selectedFrame: null,
|
||||
hits: 0,
|
||||
exitCode: null,
|
||||
caps: { hotCodeReplace } as DebugCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HotSwap", () => {
|
||||
beforeEach(() => {
|
||||
notes.length = 0;
|
||||
ipc.javaBuildWorkspace.mockClear();
|
||||
ipc.debugRedefineClasses.mockClear();
|
||||
useDebug.setState({ sessions: { 1: session(1, true), 2: session(2, false) }, active: 1 });
|
||||
publishContext();
|
||||
});
|
||||
|
||||
it("is enabled only with a session whose adapter can redefine classes", () => {
|
||||
expect(getContext().debugCanHotSwap).toBe(true);
|
||||
useDebug.setState({ sessions: { 2: session(2, false) }, active: 2 });
|
||||
publishContext();
|
||||
expect(getContext().debugCanHotSwap).toBe(false);
|
||||
});
|
||||
|
||||
it("builds, reloads in Java sessions and reports the count", async () => {
|
||||
await reloadChangedClasses();
|
||||
expect(ipc.javaBuildWorkspace).toHaveBeenCalledWith("/p/app");
|
||||
expect(ipc.debugRedefineClasses).toHaveBeenCalledTimes(1);
|
||||
expect(ipc.debugRedefineClasses).toHaveBeenCalledWith(1);
|
||||
expect(notes.at(-1)).toMatchObject({ level: "info", title: "2 classes reloaded" });
|
||||
});
|
||||
|
||||
it("does not reload after a failed build", async () => {
|
||||
ipc.javaBuildWorkspace.mockImplementationOnce(() => ipc.ok("failed"));
|
||||
await reloadChangedClasses();
|
||||
expect(ipc.debugRedefineClasses).not.toHaveBeenCalled();
|
||||
expect(notes.at(-1)).toMatchObject({ level: "error", title: "Hot Swap failed" });
|
||||
});
|
||||
|
||||
it("words results like IDEA", () => {
|
||||
expect(hotSwapMessage(0, []).title).toBe("Loaded classes are up to date. Nothing to reload.");
|
||||
expect(hotSwapMessage(1, []).title).toBe("1 class reloaded");
|
||||
expect(hotSwapMessage(3, ["boom"])).toMatchObject({ level: "error", body: "boom" });
|
||||
});
|
||||
|
||||
it("offers a reload when java-debug reports a finished build", () => {
|
||||
// Past the quiet period after the reloads above (their own builds raise no prompt).
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 120_000);
|
||||
onHotCodeReplace(1 as DebugSessionId, { kind: "hotCodeReplace", changeType: "BUILD_COMPLETE", message: "" });
|
||||
expect(notes.at(-1)).toMatchObject({ title: "Classes changed" });
|
||||
expect(notes.at(-1)!.actions).toHaveLength(1);
|
||||
onHotCodeReplace(1 as DebugSessionId, { kind: "hotCodeReplace", changeType: "ERROR", message: "schema change" });
|
||||
expect(notes.at(-1)).toMatchObject({ level: "error", body: "schema change" });
|
||||
const before = notes.length;
|
||||
onHotCodeReplace(99 as DebugSessionId, { kind: "hotCodeReplace", changeType: "ERROR", message: "not ours" });
|
||||
expect(notes.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
|
||||
const invoked: Array<[string, unknown[]]> = [];
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
const ok = (data: unknown) => Promise.resolve({ status: "ok", data });
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
javaResolveLaunch: (...a: unknown[]) => {
|
||||
invoked.push(["javaResolveLaunch", a]);
|
||||
return ok({
|
||||
build: "succeeded",
|
||||
projectRoot: "/p",
|
||||
classPaths: ["/p/target/classes", "/m2/a.jar"],
|
||||
modulePaths: [],
|
||||
javaHome: "/jdk21",
|
||||
javaExec: "/jdk21/bin/java",
|
||||
});
|
||||
},
|
||||
javaDebugServer: (...a: unknown[]) => {
|
||||
invoked.push(["javaDebugServer", a]);
|
||||
return ok(41234);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { isJavaDebuggable, javaAttachConfig, javaLaunchConfig } = await import("../java");
|
||||
|
||||
const app: RunConfig = {
|
||||
id: "application:a.Main",
|
||||
name: "Main",
|
||||
kind: "spring-boot",
|
||||
command: "java",
|
||||
args: ["-Xmx1g", "-cp", "${classpath}", "a.Main", "--port", "8081 x"],
|
||||
cwd: "api",
|
||||
env: { A: "1" },
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: "java-debug",
|
||||
};
|
||||
|
||||
describe("Java debugging (java-debug through jdtls)", () => {
|
||||
beforeEach(() => {
|
||||
invoked.length = 0;
|
||||
});
|
||||
|
||||
it("launches Application configs with the resolved classpath on jdtls' debug server", async () => {
|
||||
expect(isJavaDebuggable(app)).toBe(true);
|
||||
expect(isJavaDebuggable({ ...app, kind: "maven" })).toBe(false);
|
||||
const l = await javaLaunchConfig(app, "/p");
|
||||
expect(invoked).toEqual([
|
||||
["javaResolveLaunch", ["api", false, true]],
|
||||
["javaDebugServer", ["api"]],
|
||||
]);
|
||||
expect(l).toMatchObject({ adapter: "java-debug", request: "launch", cwd: "/p/api", args: [], env: { A: "1" } });
|
||||
expect(JSON.parse(l.extraJson!)).toMatchObject({
|
||||
debugServer: 41234,
|
||||
mainClass: "a.Main",
|
||||
classPaths: ["/p/target/classes", "/m2/a.jar"],
|
||||
javaExec: "/jdk21/bin/java",
|
||||
vmArgs: "-Xmx1g",
|
||||
args: '--port "8081 x"',
|
||||
console: "internalConsole",
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
expect(JSON.parse(l.extraJson!)).toMatchObject({ debugServer: 41234, hostName: "127.0.0.1", port: 5005 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { debugUnsupportedReason, launchConfigFor } from "../launch";
|
||||
|
||||
const rc = (p: Partial<RunConfig>): RunConfig => ({
|
||||
id: "x",
|
||||
name: "cfg",
|
||||
kind: "shell",
|
||||
command: "true",
|
||||
args: [],
|
||||
cwd: null,
|
||||
env: {},
|
||||
detected: true,
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
...p,
|
||||
});
|
||||
|
||||
const extra = (l: { extraJson: string | null } | null) => JSON.parse(l?.extraJson ?? "{}");
|
||||
|
||||
describe("run config → launch config", () => {
|
||||
it("returns null without an adapter", () => {
|
||||
expect(launchConfigFor(rc({}), "/p")).toBeNull();
|
||||
});
|
||||
|
||||
it("cargo run builds the binary through codelldb's cargo integration", () => {
|
||||
const l = launchConfigFor(
|
||||
rc({
|
||||
kind: "cargo",
|
||||
command: "cargo",
|
||||
args: ["run", "-p", "app", "--bin", "app", "--", "--port", "1"],
|
||||
debugAdapter: "codelldb",
|
||||
}),
|
||||
"/p",
|
||||
)!;
|
||||
expect(l.adapter).toBe("codelldb");
|
||||
expect(l.program).toBeNull();
|
||||
expect(l.args).toEqual(["--port", "1"]);
|
||||
expect(l.cwd).toBe("/p");
|
||||
expect(extra(l)).toEqual({
|
||||
cargo: { args: ["build", "-p", "app", "--bin", "app"], filter: { name: "app", kind: "bin" } },
|
||||
sourceLanguages: ["rust"],
|
||||
});
|
||||
});
|
||||
|
||||
it("cargo test builds test binaries without running", () => {
|
||||
const l = launchConfigFor(
|
||||
rc({ kind: "cargo", command: "cargo", args: ["test", "--workspace"], debugAdapter: "codelldb" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(l).cargo).toEqual({ args: ["test", "--no-run", "--workspace"] });
|
||||
});
|
||||
|
||||
it("debugpy: script, module, console script, venv interpreter", () => {
|
||||
const script = launchConfigFor(
|
||||
rc({ kind: "python", command: ".venv/bin/python", args: ["manage.py", "runserver"], debugAdapter: "debugpy" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(script.program).toBe("/p/manage.py");
|
||||
expect(script.args).toEqual(["runserver"]);
|
||||
expect(extra(script)).toMatchObject({ python: "/p/.venv/bin/python", django: true, console: "internalConsole" });
|
||||
|
||||
const mod = launchConfigFor(
|
||||
rc({ kind: "python", command: "python3", args: ["-m", "app", "-v"], debugAdapter: "debugpy" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(mod.program).toBeNull();
|
||||
expect(mod.args).toEqual(["-v"]);
|
||||
expect(extra(mod)).toMatchObject({ module: "app", python: "python3" });
|
||||
|
||||
const pytest = launchConfigFor(
|
||||
rc({ kind: "pytest", command: ".venv/bin/pytest", args: ["-x"], debugAdapter: "debugpy" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(pytest)).toMatchObject({ module: "pytest", python: "/p/.venv/bin/python" });
|
||||
expect(pytest.args).toEqual(["-x"]);
|
||||
|
||||
const globalPytest = launchConfigFor(rc({ kind: "pytest", command: "pytest", debugAdapter: "debugpy" }), "/p")!;
|
||||
expect(extra(globalPytest).python).toBe("python3");
|
||||
});
|
||||
|
||||
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" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(npm.cwd).toBe("/p/web");
|
||||
expect(npm.program).toBeNull();
|
||||
expect(extra(npm)).toMatchObject({ type: "pwa-node", runtimeExecutable: "pnpm", runtimeArgs: ["run", "dev"] });
|
||||
|
||||
const node = launchConfigFor(rc({ command: "node", args: ["server.js", "--x"], debugAdapter: "js-debug" }), "/p")!;
|
||||
expect(node.program).toBe("/p/server.js");
|
||||
expect(node.args).toEqual(["--x"]);
|
||||
expect(extra(node).runtimeExecutable).toBeUndefined();
|
||||
|
||||
// A located Node.js (e.g. 22.6+ for TypeScript) is used instead of PATH's.
|
||||
const ts = launchConfigFor(rc({ command: "/opt/node22/bin/node", args: ["a.ts"], debugAdapter: "js-debug" }), "/p")!;
|
||||
expect(ts.program).toBe("/p/a.ts");
|
||||
expect(extra(ts).runtimeExecutable).toBe("/opt/node22/bin/node");
|
||||
});
|
||||
|
||||
it("js-debug: TypeScript through a runner or type-stripping flag runs as the runtime", () => {
|
||||
const flag = launchConfigFor(
|
||||
rc({ kind: "node", command: "/bin/node", args: ["--experimental-strip-types", "a.ts"], debugAdapter: "js-debug" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(flag.program).toBeNull();
|
||||
expect(extra(flag)).toMatchObject({ runtimeExecutable: "/bin/node", runtimeArgs: ["--experimental-strip-types", "a.ts"] });
|
||||
const tsx = launchConfigFor(rc({ kind: "node", command: "npx", args: ["tsx", "a.ts"], debugAdapter: "js-debug" }), "/p")!;
|
||||
expect(extra(tsx)).toMatchObject({ runtimeExecutable: "npx", runtimeArgs: ["tsx", "a.ts"] });
|
||||
});
|
||||
|
||||
it("deno: waits on its inspector at a free port that js-debug attaches to", () => {
|
||||
const l = launchConfigFor(
|
||||
rc({ kind: "deno", command: "/bin/deno", args: ["run", "-A", "--inspect", "main.ts", "x"], debugAdapter: "js-debug" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(l.adapter).toBe("js-debug");
|
||||
expect(l.program).toBeNull();
|
||||
expect(extra(l)).toMatchObject({
|
||||
type: "pwa-node",
|
||||
runtimeExecutable: "/bin/deno",
|
||||
runtimeArgs: ["run", "--inspect-wait=127.0.0.1:${freePort}", "-A", "main.ts", "x"],
|
||||
attachSimplePort: "${freePort}",
|
||||
});
|
||||
const test = launchConfigFor(rc({ kind: "deno", command: "deno", args: ["test"], debugAdapter: "js-debug" }), "/p")!;
|
||||
expect(extra(test).runtimeArgs).toEqual(["test", "--inspect-wait=127.0.0.1:${freePort}"]);
|
||||
});
|
||||
|
||||
it("deno task and Bun can't be debugged, with a reason", () => {
|
||||
const task = rc({ kind: "deno", command: "deno", args: ["task", "dev"], debugAdapter: "js-debug" });
|
||||
expect(launchConfigFor(task, "/p")).toBeNull();
|
||||
expect(debugUnsupportedReason(task)).toContain("deno run");
|
||||
const bun = rc({ kind: "bun", command: "/bin/bun", args: ["run", "a.ts"], debugAdapter: "js-debug" });
|
||||
expect(launchConfigFor(bun, "/p")).toBeNull();
|
||||
expect(debugUnsupportedReason(bun)).toContain("WebKit");
|
||||
// `bun run <script>` of package.json may start Node, which js-debug follows.
|
||||
const script = rc({ kind: "npm", command: "bun", args: ["run", "dev"], debugAdapter: "js-debug" });
|
||||
expect(debugUnsupportedReason(script)).toBeNull();
|
||||
expect(launchConfigFor(script, "/p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("delve: debug mode for run, test mode for test", () => {
|
||||
const run = launchConfigFor(rc({ kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }), "/p")!;
|
||||
expect(run.program).toBe("/p");
|
||||
expect(extra(run)).toEqual({ mode: "debug" });
|
||||
const sub = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["run", "./cmd/api"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(sub.program).toBe("/p/cmd/api");
|
||||
const test = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["test", "./..."], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(extra(test)).toEqual({ mode: "test" });
|
||||
expect(test.program).toBe("/p");
|
||||
// A gutter test / subtest: the package, and `-run` for the test binary.
|
||||
const one = launchConfigFor(
|
||||
rc({ kind: "go", command: "go", args: ["test", "./calc", "-run", "^TestSub$/^zero_case$"], debugAdapter: "delve" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(one.program).toBe("/p/calc");
|
||||
expect(one.args).toEqual(["-test.run", "^TestSub$/^zero_case$"]);
|
||||
const eq = launchConfigFor(rc({ kind: "go", command: "go", args: ["test", "-run=^TestA$", "."], debugAdapter: "delve" }), "/p")!;
|
||||
expect(eq.program).toBe("/p");
|
||||
expect(eq.args).toEqual(["-test.run", "^TestA$"]);
|
||||
});
|
||||
|
||||
it("other adapters launch the command as the program", () => {
|
||||
const l = launchConfigFor(
|
||||
rc({ command: "./bin/app", args: ["a"], env: { K: "v" }, debugAdapter: "netcoredbg" }),
|
||||
"/p",
|
||||
)!;
|
||||
expect(l).toMatchObject({
|
||||
adapter: "netcoredbg",
|
||||
program: "/p/bin/app",
|
||||
args: ["a"],
|
||||
env: { K: "v" },
|
||||
request: "launch",
|
||||
});
|
||||
});
|
||||
|
||||
it("native programs under CodeLLDB / lldb-dap: program, args, cwd, env", () => {
|
||||
const run = rc({ command: "./build/app", args: ["-v"], cwd: "c", env: { K: "v" } });
|
||||
const lldb = launchConfigFor({ ...run, debugAdapter: "codelldb" }, "/p")!;
|
||||
expect(lldb).toMatchObject({
|
||||
adapter: "codelldb",
|
||||
program: "/p/c/build/app",
|
||||
args: ["-v"],
|
||||
cwd: "/p/c",
|
||||
env: { K: "v" },
|
||||
});
|
||||
expect(lldb.extraJson).toBeNull();
|
||||
const dap = launchConfigFor({ ...run, debugAdapter: "lldb-dap" }, "/p")!;
|
||||
expect(dap).toMatchObject({ adapter: "lldb-dap", program: "/p/c/build/app" });
|
||||
// lldb-dap takes `KEY=VALUE` strings.
|
||||
expect(extra(dap)).toEqual({ env: ["K=v"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { missingAdapter } from "../missingAdapter";
|
||||
|
||||
const unavailable = (message: string) => new IpcError({ kind: "unavailable", message });
|
||||
|
||||
describe("missing debug adapters", () => {
|
||||
it("offers OpenIDEA's debugpy when the interpreter has none", () => {
|
||||
const e = unavailable("debugpy is not installed for /opt/homebrew/bin/python3.14. Install OpenIDEA's copy");
|
||||
expect(missingAdapter("debugpy", e)).toMatchObject({ tool: "debugpy", title: "debugpy is not installed" });
|
||||
});
|
||||
|
||||
it("offers the managed install of a missing adapter binary", () => {
|
||||
const e = unavailable(
|
||||
"debug adapter `js-debug` not found (`js-debug-adapter` is neither installed by OpenIDEA nor on PATH)",
|
||||
);
|
||||
expect(missingAdapter("js-debug", e)).toMatchObject({
|
||||
tool: "js-debug",
|
||||
title: "The JavaScript debugger (vscode-js-debug) is not installed",
|
||||
});
|
||||
expect(missingAdapter("delve", unavailable("debug adapter `delve` not found (x)"))?.tool).toBe("delve");
|
||||
});
|
||||
|
||||
it("leaves other failures and adapters without an installer alone", () => {
|
||||
expect(missingAdapter("js-debug", unavailable("connection refused"))).toBeNull();
|
||||
expect(missingAdapter("js-debug", new Error("debug adapter `js-debug` not found"))).toBeNull();
|
||||
expect(missingAdapter("lldb-dap", unavailable("debug adapter `lldb-dap` not found (x)"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ChangeSet, Text } from "@codemirror/state";
|
||||
import { mapLines } from "../breakpoints/model";
|
||||
import {
|
||||
addFieldWatchpoint,
|
||||
addMethodBreakpoint,
|
||||
loadSpecialBreakpoints,
|
||||
removeAllSpecial,
|
||||
sanitizeSpecial,
|
||||
setSpecialLines,
|
||||
specialAt,
|
||||
toDataBreakpoints,
|
||||
toFunctionBreakpoints,
|
||||
toggleMethodAt,
|
||||
updateSpecial,
|
||||
useSpecialBreakpoints,
|
||||
} from "../breakpoints/special";
|
||||
import { consumeTemporary, breakpointsOf, loadBreakpoints, toggleTemporaryBreakpoint } from "../breakpoints/store";
|
||||
import { toggleBreakpointSmart } from "../breakpoints/toggle";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
loadBreakpoints("/p");
|
||||
loadSpecialBreakpoints("/p");
|
||||
removeAllSpecial();
|
||||
});
|
||||
|
||||
describe("method breakpoints and field watchpoints", () => {
|
||||
it("toggles a method breakpoint on its declaration line and persists it", () => {
|
||||
const bp = toggleMethodAt("/p/A.java", 4, "a.A#run");
|
||||
expect(bp?.name).toBe("a.A#run");
|
||||
expect(specialAt("/p/A.java").map((b) => b.line)).toEqual([4]);
|
||||
loadSpecialBreakpoints("/p");
|
||||
expect(useSpecialBreakpoints.getState().methods.map((m) => m.name)).toEqual(["a.A#run"]);
|
||||
expect(toggleMethodAt("/p/A.java", 4, "a.A#run")).toBeNull();
|
||||
expect(specialAt("/p/A.java")).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends enabled state, conditions and muting to the backend lists", () => {
|
||||
const m = addMethodBreakpoint("main.run");
|
||||
updateSpecial(m.id, { condition: " n > 1 ", enabled: false });
|
||||
const f = addFieldWatchpoint({ dataId: "a.A#count", label: "A.count", accessType: "write" });
|
||||
expect(addFieldWatchpoint({ dataId: "a.A#count", label: "dup" }).id).toBe(f.id);
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
expect(toFunctionBreakpoints(methods, false)).toEqual([
|
||||
{ name: "main.run", condition: "n > 1", hitCondition: null, enabled: false },
|
||||
]);
|
||||
expect(toDataBreakpoints(fields, false)).toEqual([
|
||||
{ dataId: "a.A#count", accessType: "write", condition: null, hitCondition: null, enabled: true },
|
||||
]);
|
||||
expect(toDataBreakpoints(fields, true)[0]!.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("follows edits and drops sanitized junk", () => {
|
||||
const m = toggleMethodAt("/p/a.py", 3, "run")!;
|
||||
const doc = Text.of(["a", "b", "c", "def run():", " pass"]);
|
||||
const changes = ChangeSet.of([{ from: 0, insert: "x\ny\n" }], doc.length);
|
||||
setSpecialLines("/p/a.py", mapLines([{ id: m.id, line: 3 }], doc, changes, changes.apply(doc)));
|
||||
expect(specialAt("/p/a.py")[0]!.line).toBe(5);
|
||||
expect(
|
||||
sanitizeSpecial({ methods: [{ name: "" }, { name: "ok", line: 2 }, 3], fields: [{ label: "no id" }] }),
|
||||
).toEqual({
|
||||
methods: [expect.objectContaining({ name: "ok", line: 2, enabled: true, kind: "method" })],
|
||||
fields: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles like IDEA: Java declarations get method / field breakpoints, other lines line breakpoints", () => {
|
||||
const src = "package a;\nclass A {\n int count;\n void run() {\n count++;\n }\n}";
|
||||
toggleBreakpointSmart("/p/A.java", 3, "java", src);
|
||||
toggleBreakpointSmart("/p/A.java", 2, "java", src);
|
||||
toggleBreakpointSmart("/p/A.java", 4, "java", src);
|
||||
expect(useSpecialBreakpoints.getState().methods.map((m) => m.name)).toEqual(["a.A#run"]);
|
||||
expect(useSpecialBreakpoints.getState().fields.map((f) => f.dataId)).toEqual(["a.A#count"]);
|
||||
expect(breakpointsOf("/p/A.java").map((b) => b.line)).toEqual([4]);
|
||||
// Again on the declaration: removed.
|
||||
toggleBreakpointSmart("/p/A.java", 3, "java", src);
|
||||
expect(useSpecialBreakpoints.getState().methods).toEqual([]);
|
||||
// Python `def` lines stay line breakpoints.
|
||||
toggleBreakpointSmart("/p/a.py", 0, "python", "def run():\n pass");
|
||||
expect(breakpointsOf("/p/a.py").map((b) => b.line)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temporary line breakpoints", () => {
|
||||
it("are removed after the first hit", () => {
|
||||
toggleTemporaryBreakpoint("/p/a.py", 7);
|
||||
expect(breakpointsOf("/p/a.py")[0]).toMatchObject({ line: 7, temporary: true });
|
||||
expect(consumeTemporary("/p/a.py", 6)).toBe(false);
|
||||
expect(consumeTemporary("/p/a.py", 7)).toBe(true);
|
||||
expect(breakpointsOf("/p/a.py")).toEqual([]);
|
||||
});
|
||||
|
||||
it("survive a reload and toggle off", () => {
|
||||
toggleTemporaryBreakpoint("/p/a.py", 2);
|
||||
loadBreakpoints("/p");
|
||||
expect(breakpointsOf("/p/a.py")[0]!.temporary).toBe(true);
|
||||
toggleTemporaryBreakpoint("/p/a.py", 2);
|
||||
expect(breakpointsOf("/p/a.py")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fieldAt, methodAt } from "../breakpoints/symbols";
|
||||
|
||||
const java = `package com.shop;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class Orders {
|
||||
private int count = 0;
|
||||
static final String NAME = "x"; // not a { brace
|
||||
|
||||
public Orders() {
|
||||
count = 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> load(int id) {
|
||||
int local = 2;
|
||||
helper(local);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
static class Line {
|
||||
long total;
|
||||
void add(long n) { total += n; }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe("method / field declarations", () => {
|
||||
it("names Java methods the way java-debug expects (pkg.Class#method)", () => {
|
||||
expect(methodAt("java", java, 13)).toEqual({ name: "com.shop.Orders#load", label: "Orders.load()" });
|
||||
expect(methodAt("java", java, 8)).toEqual({ name: "com.shop.Orders#<init>", label: "Orders.Orders()" });
|
||||
expect(methodAt("java", java, 21)).toEqual({ name: "com.shop.Orders$Line#add", label: "Line.add()" });
|
||||
// Calls and statements are not declarations.
|
||||
expect(methodAt("java", java, 14)).toBeNull();
|
||||
expect(methodAt("java", java, 15)).toBeNull();
|
||||
expect(methodAt("java", java, 16)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds Java fields (not locals) as watchpoint ids", () => {
|
||||
expect(fieldAt("java", java, 5)).toEqual({ dataId: "com.shop.Orders#count", label: "Orders.count" });
|
||||
expect(fieldAt("java", java, 6)).toEqual({ dataId: "com.shop.Orders#NAME", label: "Orders.NAME" });
|
||||
expect(fieldAt("java", java, 20)).toEqual({ dataId: "com.shop.Orders$Line#total", label: "Line.total" });
|
||||
expect(fieldAt("java", java, 14)).toBeNull();
|
||||
expect(fieldAt("java", java, 13)).toBeNull();
|
||||
expect(fieldAt("python", "x = 1", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("names functions for debugpy, Delve, LLDB and js-debug", () => {
|
||||
expect(methodAt("python", "class A:\n async def run(self, n):\n pass", 1)?.name).toBe("run");
|
||||
const go =
|
||||
"package shop\n\nfunc Parse(s string) int {\n}\nfunc (o *Order) Total() int {\n}\nfunc (o Order) Name() string {\n}";
|
||||
expect(methodAt("go", go, 2)?.name).toBe("shop.Parse");
|
||||
expect(methodAt("go", go, 4)?.name).toBe("shop.(*Order).Total");
|
||||
expect(methodAt("go", go, 6)?.name).toBe("shop.Order.Name");
|
||||
expect(methodAt("rust", "pub(crate) async fn parse(text: &str) -> i32 {", 0)?.name).toBe("parse");
|
||||
expect(methodAt("c", "static int parse(const char *text) {", 0)?.name).toBe("parse");
|
||||
expect(methodAt("c", " if (x) {", 0)).toBeNull();
|
||||
expect(methodAt("c", " total += parse(buf);", 0)).toBeNull();
|
||||
expect(methodAt("typescript", "export async function load(id: number) {", 0)?.name).toBe("load");
|
||||
expect(methodAt("plaintext", "def x():", 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
debugScopes: () => ok([{ name: "Locals", variablesReference: 1, expensive: false }]),
|
||||
debugVariables: () =>
|
||||
ok([
|
||||
{ name: "count", value: "3", typeName: "int", variablesReference: 0, evaluateName: "count" },
|
||||
{ name: "label", value: "'hi'", typeName: "str", variablesReference: 0, evaluateName: "label" },
|
||||
]),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useDebug, type DebugSession } from "../state";
|
||||
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
|
||||
|
||||
const session: DebugSession = {
|
||||
id: 1,
|
||||
name: "main",
|
||||
adapter: "debugpy",
|
||||
parent: null,
|
||||
launch: null,
|
||||
process: null,
|
||||
status: "paused",
|
||||
stoppedThread: 1,
|
||||
stopReason: "breakpoint",
|
||||
stopDescription: null,
|
||||
threads: [{ id: 1, name: "MainThread" }],
|
||||
selectedThread: 1,
|
||||
frames: [{ id: 7, name: "main", path: "/p/app/main.py", line: 11, column: 0, presentationHint: null }],
|
||||
selectedFrame: 7,
|
||||
hits: 1,
|
||||
exitCode: null,
|
||||
caps: null,
|
||||
};
|
||||
|
||||
describe("debug tool window", () => {
|
||||
it("renders session tabs, views, frames and typed variables", async () => {
|
||||
useDebug.setState({ sessions: { 1: session }, active: 1 });
|
||||
render(
|
||||
<>
|
||||
<DebugTabs />
|
||||
<DebugToolWindow />
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: /main/ })).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Threads & Variables" })).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Console" })).toBeTruthy();
|
||||
expect(screen.getByText(", main.py:12")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Evaluate expression (↩) or add a watch (⇧⌘↩)")).toBeTruthy();
|
||||
expect(await screen.findByText("{int}")).toBeTruthy();
|
||||
expect(screen.getByText("'hi'")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyValue, identifiersIn, inlineValue } from "../values";
|
||||
import { inlineValuesFor } from "../editor/execution";
|
||||
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
||||
import type { StackFrame } from "@/platform/ipc";
|
||||
|
||||
describe("value classification", () => {
|
||||
it.each([
|
||||
["42", "number"],
|
||||
["-3.5e10", "number"],
|
||||
["0x1F", "number"],
|
||||
["297.00", "number"],
|
||||
["10u32", "number"],
|
||||
['"hello"', "string"],
|
||||
["'x'", "string"],
|
||||
["b'bytes'", "string"],
|
||||
["null", "keyword"],
|
||||
["None", "keyword"],
|
||||
["true", "keyword"],
|
||||
["undefined", "keyword"],
|
||||
["{CreateOrderRequest@4127}", "object"],
|
||||
["CreateOrderRequest@4127", "object"],
|
||||
["[1, 2, 3]", "object"],
|
||||
["Vec(3)", "object"],
|
||||
["<function main at 0x10>", "object"],
|
||||
["some text", "plain"],
|
||||
["", "plain"],
|
||||
])("%s → %s", (value, kind) => {
|
||||
expect(classifyValue(value)).toBe(kind);
|
||||
});
|
||||
|
||||
it("uses the type name as a fallback for strings", () => {
|
||||
expect(classifyValue("hello", "str")).toBe("string");
|
||||
expect(classifyValue("hello", "Order")).toBe("plain");
|
||||
});
|
||||
|
||||
it("flattens and truncates inline values", () => {
|
||||
expect(inlineValue("a\n b")).toBe("a b");
|
||||
expect(inlineValue("x".repeat(80), 10)).toBe("xxxxxxxxx…");
|
||||
});
|
||||
|
||||
it("finds identifiers outside string literals", () => {
|
||||
expect(identifiersIn('log.info("total " + total)')).toEqual(["log", "info", "total"]);
|
||||
});
|
||||
|
||||
it("formats inline values for known locals on a line", () => {
|
||||
const values = { req: "CreateOrderRequest@4127", total: "3", unused: "1" };
|
||||
expect(inlineValuesFor("fun create(req: CreateOrderRequest): OrderDto {", values)).toBe(
|
||||
"req: CreateOrderRequest@4127",
|
||||
);
|
||||
expect(inlineValuesFor("return 1", values)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const frame = (id: number, path: string | null, hint: string | null = null, line = 9): StackFrame => ({
|
||||
id,
|
||||
name: `f${id}`,
|
||||
path,
|
||||
line,
|
||||
column: 0,
|
||||
presentationHint: hint,
|
||||
});
|
||||
|
||||
describe("frames", () => {
|
||||
const root = "/proj";
|
||||
it("detects library frames", () => {
|
||||
expect(isLibraryFrame(frame(1, "/proj/src/a.py"), root)).toBe(false);
|
||||
expect(isLibraryFrame(frame(1, "/proj/node_modules/x/i.js"), root)).toBe(true);
|
||||
expect(isLibraryFrame(frame(1, "/usr/lib/python3/x.py"), root)).toBe(true);
|
||||
expect(isLibraryFrame(frame(1, null), root)).toBe(true);
|
||||
expect(isLibraryFrame(frame(1, "/proj/a.py", "subtle"), root)).toBe(true);
|
||||
expect(isLibraryFrame(frame(1, "/elsewhere/a.py"), root)).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses runs of library frames but keeps the top and selected frames", () => {
|
||||
const frames = [
|
||||
frame(1, "/proj/a.py"),
|
||||
frame(2, null),
|
||||
frame(3, null),
|
||||
frame(4, null),
|
||||
frame(5, "/proj/b.py"),
|
||||
frame(6, null),
|
||||
];
|
||||
const items = groupFrames(frames, root, 1, false);
|
||||
expect(items.map((i) => (i.kind === "hidden" ? `hidden${i.count}` : i.frame.id))).toEqual([1, "hidden3", 5, 6]);
|
||||
const selected = groupFrames(frames, root, 3, false);
|
||||
expect(selected.map((i) => (i.kind === "hidden" ? `hidden${i.count}` : i.frame.id))).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(groupFrames(frames, root, 1, true)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("labels frames with 1-based lines", () => {
|
||||
expect(frameLabel(frame(1, "/proj/a.py", null, 21))).toBe("f1:22");
|
||||
expect(frameLabel(frame(1, null))).toBe("f1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
/** 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 { useProject } from "@/platform/project";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { canPlaceBreakpoint } from "./breakpoints/languages";
|
||||
import {
|
||||
removeAllBreakpoints,
|
||||
setMuted,
|
||||
toggleBreakpoint,
|
||||
toggleTemporaryBreakpoint,
|
||||
useBreakpoints,
|
||||
} from "./breakpoints/store";
|
||||
import { removeAllSpecial, specialAt, toggleFieldAt, toggleMethodAt } from "./breakpoints/special";
|
||||
import { fieldAt, methodAt } from "./breakpoints/symbols";
|
||||
import { toggleBreakpointSmart } from "./breakpoints/toggle";
|
||||
import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java";
|
||||
import { reloadChangedClasses } from "./hotswap";
|
||||
import { debugUnsupportedReason, launchConfigFor } from "./launch";
|
||||
import {
|
||||
activeSession,
|
||||
isLive,
|
||||
rerunSession,
|
||||
revealFrame,
|
||||
rootOf,
|
||||
sessionSupports,
|
||||
showExecutionPoint,
|
||||
startSession,
|
||||
stopSession,
|
||||
useDebug,
|
||||
type DebugSession,
|
||||
} from "./state";
|
||||
import { focusedDebugView } from "./ui/focusedView";
|
||||
import {
|
||||
openBreakpointsDialog,
|
||||
openConfigChooser,
|
||||
openEvaluateDialog,
|
||||
openSmartStepChooser,
|
||||
useOverlays,
|
||||
} from "./ui/popoverStore";
|
||||
|
||||
function isRunConfig(arg: unknown): arg is RunConfig {
|
||||
return !!arg && typeof arg === "object" && "command" in arg && "args" in arg && "debugAdapter" in arg;
|
||||
}
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
async function report(title: string, p: Promise<unknown>) {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
notifications().notify({ group: "run", level: "error", title, body: errorText(e), key: "debug.action" });
|
||||
}
|
||||
}
|
||||
|
||||
/** The paused session and the thread to act on. */
|
||||
function pausedTarget(): { s: DebugSession; thread: number } | null {
|
||||
const s = activeSession();
|
||||
if (!s || s.status !== "paused") return null;
|
||||
const thread = s.selectedThread ?? s.stoppedThread ?? s.threads[0]?.id;
|
||||
return thread == null ? null : { s, thread };
|
||||
}
|
||||
|
||||
function notSupported(what: string, s: DebugSession) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
key: "debug.unsupported",
|
||||
title: `${what} is not supported by ${s.adapter || "this debugger"}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** The selected frame of the paused session (top frame when none is selected). */
|
||||
function selectedFrame(s: DebugSession) {
|
||||
return s.frames.find((f) => f.id === s.selectedFrame) ?? s.frames[0] ?? null;
|
||||
}
|
||||
|
||||
/** Smart Step Into: the calls on the current line; one call (or none) steps straight in, like IDEA. */
|
||||
async function smartStepInto() {
|
||||
const t = pausedTarget();
|
||||
if (!t) return;
|
||||
const frame = selectedFrame(t.s);
|
||||
if (!frame || !sessionSupports("stepInTargets", t.s)) {
|
||||
await call(commands.debugStep(t.s.id, t.thread, "into"));
|
||||
return;
|
||||
}
|
||||
const targets = await call(commands.debugStepInTargets(t.s.id, frame.id));
|
||||
if (targets.length <= 1) {
|
||||
await call(
|
||||
targets[0]
|
||||
? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id)
|
||||
: commands.debugStep(t.s.id, t.thread, "into"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
openSmartStepChooser(t.s.id, t.thread, targets);
|
||||
}
|
||||
|
||||
/** Opens the Attach to Process popup and loads the process list. */
|
||||
export async function openAttachPopup() {
|
||||
if (!isTauri()) {
|
||||
notifications().notify({ group: "run", level: "warning", title: "Attaching needs the desktop app" });
|
||||
return;
|
||||
}
|
||||
useOverlays.setState({ attach: { processes: null, error: null } });
|
||||
try {
|
||||
const processes = await call(commands.debugAttachableProcesses());
|
||||
if (useOverlays.getState().attach) useOverlays.setState({ attach: { processes, error: null } });
|
||||
} catch (e) {
|
||||
if (useOverlays.getState().attach)
|
||||
useOverlays.setState({ attach: { processes: [], error: errorText(e) || "Cannot list processes" } });
|
||||
}
|
||||
}
|
||||
|
||||
function cursor(): { path: string; line: number; selection: string } | null {
|
||||
const ed = activeEditor();
|
||||
if (!ed) return null;
|
||||
const { state } = ed.view;
|
||||
const main = state.selection.main;
|
||||
return {
|
||||
path: ed.ctx.path,
|
||||
line: state.doc.lineAt(main.head).number - 1,
|
||||
selection: state.sliceDoc(main.from, main.to),
|
||||
};
|
||||
}
|
||||
|
||||
async function startConfig(arg: unknown) {
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!root) return;
|
||||
let config: RunConfig | null = isRunConfig(arg) ? arg : null;
|
||||
if (!config) {
|
||||
if (!isTauri()) {
|
||||
notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" });
|
||||
return;
|
||||
}
|
||||
const configs = (await call(commands.runConfigs())).filter((c) => c.debugAdapter);
|
||||
if (!configs.length) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
title: "Nothing to debug",
|
||||
body: "No run configuration in this project has a debug adapter.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (configs.length > 1) {
|
||||
openConfigChooser(configs);
|
||||
return;
|
||||
}
|
||||
config = configs[0]!;
|
||||
}
|
||||
if (isJavaDebuggable(config)) {
|
||||
await maybeEditorService()
|
||||
?.saveAll()
|
||||
.catch(() => undefined);
|
||||
await startSession(await javaLaunchConfig(config, root));
|
||||
return;
|
||||
}
|
||||
const launch = launchConfigFor(config, root);
|
||||
if (!launch) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
title: `“${config.name}” can't be debugged`,
|
||||
body: debugUnsupportedReason(config) ?? "It has no debug adapter.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await maybeEditorService()
|
||||
?.saveAll()
|
||||
.catch(() => undefined);
|
||||
await startSession(launch);
|
||||
}
|
||||
|
||||
export function registerDebugActions(): () => void {
|
||||
const paused = "debugSessionPaused";
|
||||
const step = (id: string, title: string, kind: "over" | "into" | "out", icon: string): Action => ({
|
||||
id,
|
||||
title,
|
||||
category: "Debug",
|
||||
icon,
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind)));
|
||||
},
|
||||
});
|
||||
|
||||
return registerActions([
|
||||
{
|
||||
id: "debug.startConfig",
|
||||
title: "Debug…",
|
||||
category: "Debug",
|
||||
icon: "debug",
|
||||
description: "Start debugging a run configuration",
|
||||
run: ({ arg }) => report("Cannot start debugging", startConfig(arg)),
|
||||
},
|
||||
{
|
||||
id: "debug.attach",
|
||||
title: "Attach Debugger",
|
||||
category: "Debug",
|
||||
hidden: true,
|
||||
description:
|
||||
"Attach the Java debugger to a JVM listening for JDWP (arg: { name, module, hostName, port, process })",
|
||||
run: ({ arg }) =>
|
||||
report(
|
||||
"Cannot attach the debugger",
|
||||
(async () => {
|
||||
const root = useProject.getState().project?.root;
|
||||
if (!root || !isJavaAttachTarget(arg)) return;
|
||||
const launch = await javaAttachConfig(arg, root);
|
||||
await startSession(launch, { process: (arg.process ?? null) as RunId | null, rerunnable: false });
|
||||
})(),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "debug.stop",
|
||||
title: "Stop",
|
||||
category: "Debug",
|
||||
icon: "stop",
|
||||
when: "debugSessionActive",
|
||||
run: () => {
|
||||
const s = activeSession() ?? Object.values(useDebug.getState().sessions).find(isLive);
|
||||
if (s) void stopSession(s.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.reloadChangedClasses",
|
||||
title: "Reload Changed Classes",
|
||||
description: "HotSwap: compile and reload the changed classes into the running Java debug session",
|
||||
category: "Debug",
|
||||
icon: "refresh",
|
||||
keywords: ["hotswap", "hot swap", "hot code replace"],
|
||||
when: "debugCanHotSwap",
|
||||
run: () => reloadChangedClasses(),
|
||||
},
|
||||
{
|
||||
id: "debug.rerun",
|
||||
title: "Rerun",
|
||||
category: "Debug",
|
||||
icon: "rerun",
|
||||
when: "debugCanRerun",
|
||||
run: () => {
|
||||
const s = activeSession();
|
||||
if (s) void rerunSession(rootOf(s).id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.resume",
|
||||
title: "Resume Program",
|
||||
category: "Debug",
|
||||
icon: "resume",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread)));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.pause",
|
||||
title: "Pause Program",
|
||||
category: "Debug",
|
||||
icon: "pause",
|
||||
when: "debugSessionActive && !debugSessionPaused",
|
||||
run: async () => {
|
||||
const s = activeSession();
|
||||
if (!s || !isLive(s)) return;
|
||||
let thread = s.selectedThread ?? s.threads[0]?.id;
|
||||
if (thread == null) {
|
||||
const threads = await call(commands.debugThreads(s.id)).catch(() => []);
|
||||
thread = threads[0]?.id ?? 0;
|
||||
}
|
||||
await report("Pause failed", call(commands.debugPause(s.id, thread)));
|
||||
},
|
||||
},
|
||||
step("debug.stepOver", "Step Over", "over", "step-over"),
|
||||
step("debug.stepInto", "Step Into", "into", "step-into"),
|
||||
step("debug.stepOut", "Step Out", "out", "step-out"),
|
||||
{
|
||||
id: "debug.forceStepOver",
|
||||
title: "Force Step Over",
|
||||
category: "Debug",
|
||||
icon: "force-step-over",
|
||||
description: "Step over, ignoring breakpoints in the called code",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread)));
|
||||
},
|
||||
},
|
||||
{
|
||||
// DAP has no "ignore stepping filters" flag: adapters step into what their
|
||||
// launch options (justMyCode, skipFiles, stepFilters) allow.
|
||||
id: "debug.forceStepInto",
|
||||
title: "Force Step Into",
|
||||
category: "Debug",
|
||||
icon: "force-step-into",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into")));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.smartStepInto",
|
||||
title: "Smart Step Into",
|
||||
category: "Debug",
|
||||
icon: "smart-step-into",
|
||||
description: "Choose which call on the current line to step into",
|
||||
when: paused,
|
||||
run: () => void report("Smart Step Into failed", smartStepInto()),
|
||||
},
|
||||
{
|
||||
id: "debug.showExecutionPoint",
|
||||
title: "Show Execution Point",
|
||||
category: "Debug",
|
||||
icon: "show-execution-point",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const s = activeSession();
|
||||
if (s) void showExecutionPoint(s.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.resetFrame",
|
||||
title: "Reset Frame",
|
||||
category: "Debug",
|
||||
icon: "reset-frame",
|
||||
keywords: ["drop frame", "pop frame", "restart frame"],
|
||||
description: "Return to the start of the selected frame's method",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
const frame = t && selectedFrame(t.s);
|
||||
if (!t || !frame) return;
|
||||
if (!sessionSupports("restartFrame", t.s)) {
|
||||
notSupported("Reset Frame", t.s);
|
||||
return;
|
||||
}
|
||||
void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id)));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.setValue",
|
||||
title: "Set Value…",
|
||||
category: "Debug",
|
||||
icon: "edit",
|
||||
keyWhen: "debugVariablesFocus",
|
||||
when: paused,
|
||||
run: () => focusedDebugView()?.setValue?.(),
|
||||
},
|
||||
{
|
||||
id: "debug.jumpToSource",
|
||||
title: "Jump to Source",
|
||||
category: "Debug",
|
||||
keyWhen: "debugFramesFocus || debugVariablesFocus",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const view = focusedDebugView();
|
||||
if (view?.jumpToSource) {
|
||||
view.jumpToSource();
|
||||
return;
|
||||
}
|
||||
const s = activeSession();
|
||||
const frame = s && selectedFrame(s);
|
||||
if (frame) void revealFrame(frame, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.attachToProcess",
|
||||
title: "Attach to Process…",
|
||||
category: "Debug",
|
||||
icon: "attach-to-process",
|
||||
description: "Attach the debugger to a running Java, Node.js, Python, Go or native process",
|
||||
run: () => void openAttachPopup(),
|
||||
},
|
||||
{
|
||||
id: "debug.runToCursor",
|
||||
title: "Run to Cursor",
|
||||
category: "Debug",
|
||||
icon: "run-to-cursor",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
const c = cursor();
|
||||
if (t && c)
|
||||
void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false)));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.forceRunToCursor",
|
||||
title: "Force Run to Cursor",
|
||||
category: "Debug",
|
||||
icon: "force-run-to-cursor",
|
||||
description: "Run to the caret, ignoring breakpoints on the way",
|
||||
when: paused,
|
||||
run: () => {
|
||||
const t = pausedTarget();
|
||||
const c = cursor();
|
||||
if (t && c)
|
||||
void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true)));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.toggleBreakpoint",
|
||||
title: "Toggle Line Breakpoint",
|
||||
category: "Debug",
|
||||
icon: "breakpoint",
|
||||
run: () => {
|
||||
const c = cursor();
|
||||
if (!c) return;
|
||||
const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line);
|
||||
const language = activeEditor()?.ctx.language ?? "";
|
||||
if (!exists && !canPlaceBreakpoint(language)) {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
key: "debug.breakpointNotAllowed",
|
||||
title: "Breakpoints can't be placed in this file",
|
||||
body: "Enable Settings › Build, Execution, Deployment › Debugger › Allow placing breakpoints in any file.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ed = activeEditor();
|
||||
if (ed) toggleBreakpointSmart(c.path, c.line, language, ed.view.state.doc.toString());
|
||||
else toggleBreakpoint(c.path, c.line);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.toggleTemporaryBreakpoint",
|
||||
title: "Toggle Temporary Line Breakpoint",
|
||||
category: "Debug",
|
||||
icon: "breakpoint",
|
||||
description: "A line breakpoint that is removed after it is hit",
|
||||
run: () => {
|
||||
const c = cursor();
|
||||
if (c && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "")) toggleTemporaryBreakpoint(c.path, c.line);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.toggleMethodBreakpoint",
|
||||
title: "Toggle Method Breakpoint",
|
||||
category: "Debug",
|
||||
icon: "breakpoint-method",
|
||||
run: () => {
|
||||
const ed = activeEditor();
|
||||
const c = cursor();
|
||||
if (!ed || !c) return;
|
||||
const existing = specialAt(c.path).find((b) => b.kind === "method" && b.line === c.line);
|
||||
const method = methodAt(ed.ctx.language, ed.view.state.doc.toString(), c.line);
|
||||
if (!existing && !method) {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
key: "debug.noMethod",
|
||||
title: "No method declaration on this line",
|
||||
body: "Put the caret on a method declaration, or add a method breakpoint by name in View Breakpoints.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toggleMethodAt(c.path, c.line, method?.name ?? "");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.toggleFieldWatchpoint",
|
||||
title: "Toggle Field Watchpoint",
|
||||
category: "Debug",
|
||||
icon: "breakpoint-field",
|
||||
run: () => {
|
||||
const ed = activeEditor();
|
||||
const c = cursor();
|
||||
if (!ed || !c) return;
|
||||
const existing = specialAt(c.path).find((b) => b.kind === "field" && b.line === c.line);
|
||||
const field = fieldAt(ed.ctx.language, ed.view.state.doc.toString(), c.line);
|
||||
if (!existing && !field) {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
key: "debug.noField",
|
||||
title: "No field declaration on this line",
|
||||
body: "Field watchpoints can be set on Java field declarations, or from the Variables view while suspended.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toggleFieldAt(c.path, c.line, field ?? { dataId: "", label: "" });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.viewBreakpoints",
|
||||
title: "View Breakpoints…",
|
||||
category: "Debug",
|
||||
icon: "breakpoint",
|
||||
run: () => {
|
||||
const c = cursor();
|
||||
const bp = c ? useBreakpoints.getState().byFile[c.path]?.find((b) => b.line === c.line) : undefined;
|
||||
openBreakpointsDialog(c && bp ? { path: c.path, id: bp.id } : undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.muteBreakpoints",
|
||||
title: "Mute Breakpoints",
|
||||
category: "Debug",
|
||||
keywords: ["unmute", "disable all"],
|
||||
toggled: () => useBreakpoints.getState().muted,
|
||||
run: () => setMuted(!useBreakpoints.getState().muted),
|
||||
},
|
||||
{
|
||||
id: "debug.removeAllBreakpoints",
|
||||
title: "Remove All Breakpoints",
|
||||
category: "Debug",
|
||||
run: () => {
|
||||
removeAllBreakpoints();
|
||||
removeAllSpecial();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.evaluate",
|
||||
title: "Evaluate Expression…",
|
||||
category: "Debug",
|
||||
when: "debugSessionActive",
|
||||
run: () => {
|
||||
const c = cursor();
|
||||
openEvaluateDialog(c?.selection.split("\n")[0] ?? "");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "debug.showToolWindow",
|
||||
title: "Show Debug Tool Window",
|
||||
category: "Debug",
|
||||
hidden: true,
|
||||
run: () => toolWindowService().show("debug", true),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Attach to Process (⌥⇧F5): the popup model (filter + groups by debugger) and the
|
||||
* attach request each debugger needs, like IDEA's `XAttachDebuggerProvider`s.
|
||||
*/
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import type { AttachKind, AttachableProcess, LaunchConfig } from "@/platform/ipc";
|
||||
import { javaAttachConfig } from "./java";
|
||||
|
||||
export const KIND_LABELS: Record<AttachKind, string> = {
|
||||
java: "Java (JDWP)",
|
||||
node: "Node.js",
|
||||
python: "Python (debugpy)",
|
||||
go: "Go (Delve)",
|
||||
native: "Native (LLDB)",
|
||||
};
|
||||
|
||||
const KIND_ORDER: AttachKind[] = ["java", "node", "python", "go", "native"];
|
||||
|
||||
export type AttachRow =
|
||||
| { kind: "group"; key: string; label: string; count: number }
|
||||
| { kind: "process"; key: string; process: AttachableProcess };
|
||||
|
||||
/** Case-insensitive match on pid, name and command line; every word must match. */
|
||||
export function matchesFilter(p: AttachableProcess, filter: string): boolean {
|
||||
const words = filter.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = `${p.pid} ${p.name} ${p.commandLine}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
|
||||
/** Rows of the popup: a header per debugger, then its processes (interpreters first). */
|
||||
export function attachRows(list: readonly AttachableProcess[], filter: string): AttachRow[] {
|
||||
const rows: AttachRow[] = [];
|
||||
for (const kind of KIND_ORDER) {
|
||||
const items = list.filter((p) => p.kind === kind && matchesFilter(p, filter));
|
||||
if (!items.length) continue;
|
||||
rows.push({ kind: "group", key: `group:${kind}`, label: KIND_LABELS[kind], count: items.length });
|
||||
for (const p of items) rows.push({ kind: "process", key: `${kind}:${p.pid}`, process: p });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** A short description of how the process will be attached to. */
|
||||
export function attachDetail(p: AttachableProcess): string {
|
||||
if (p.port != null) return `${p.host ?? "localhost"}:${p.port}`;
|
||||
return `pid ${p.pid}`;
|
||||
}
|
||||
|
||||
function base(p: AttachableProcess, cwd: string, extra: Record<string, unknown>): LaunchConfig {
|
||||
return {
|
||||
name: `${p.name} (${p.pid})`,
|
||||
adapter: p.adapter,
|
||||
request: "attach",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd,
|
||||
env: {},
|
||||
extraJson: JSON.stringify(extra),
|
||||
};
|
||||
}
|
||||
|
||||
/** The attach request for `p`; `root` is the project root (sources, java module). */
|
||||
export async function attachConfig(p: AttachableProcess, root: string): Promise<LaunchConfig> {
|
||||
const skip = workflowSettings().debugger.skipLibraryCode;
|
||||
switch (p.kind) {
|
||||
case "java":
|
||||
return javaAttachConfig(
|
||||
{ name: `${p.name} (${p.pid})`, module: "", hostName: p.host ?? "localhost", port: p.port! },
|
||||
root,
|
||||
);
|
||||
case "node":
|
||||
return base(p, root, {
|
||||
type: "pwa-node",
|
||||
...(p.port != null ? { address: p.host ?? "localhost", port: p.port } : { processId: String(p.pid) }),
|
||||
skipFiles: skip ? ["<node_internals>/**", "**/node_modules/**"] : [],
|
||||
// Without `--inspect`, js-debug enables the inspector by signalling the process.
|
||||
continueOnAttach: true,
|
||||
});
|
||||
case "python":
|
||||
return base(
|
||||
p,
|
||||
root,
|
||||
// `debugpy --listen` runs its own adapter on that port: talk DAP to it directly.
|
||||
// (`--pid` injection ships only an x86_64 helper on macOS.)
|
||||
p.port != null ? { debugServer: p.port, justMyCode: skip } : { processId: p.pid, justMyCode: skip },
|
||||
);
|
||||
case "go":
|
||||
return base(p, root, { mode: "local", processId: p.pid });
|
||||
case "native":
|
||||
return base(p, root, { pid: p.pid });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Exception breakpoints: the filters each adapter offers (`exceptionBreakpointFilters`,
|
||||
* cached per adapter so the Breakpoints dialog can show them without a session) and the
|
||||
* user's choices (filter id → enabled), persisted per project and mirrored to the backend,
|
||||
* which applies them to every session (adapter defaults for filters without a choice).
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri, type ExceptionFilter } from "@/platform/ipc";
|
||||
|
||||
interface ExceptionState {
|
||||
root: string | null;
|
||||
/** Explicit choices; filters without one use the adapter's default. */
|
||||
choices: Record<string, boolean>;
|
||||
/** Adapter name → filters it offered last time. */
|
||||
known: Record<string, ExceptionFilter[]>;
|
||||
}
|
||||
|
||||
const CHOICES_PREFIX = "openidea.debugger.exceptions:";
|
||||
const KNOWN_KEY = "openidea.debugger.exceptionFilters";
|
||||
|
||||
function readJson(key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) ?? "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Storage unavailable: choices last for this session only.
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeChoices(raw: unknown): Record<string, boolean> {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
return Object.fromEntries(Object.entries(raw).filter((e): e is [string, boolean] => typeof e[1] === "boolean"));
|
||||
}
|
||||
|
||||
export function sanitizeKnown(raw: unknown): Record<string, ExceptionFilter[]> {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, ExceptionFilter[]> = {};
|
||||
for (const [adapter, list] of Object.entries(raw)) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
const filters = list.filter(
|
||||
(f): f is ExceptionFilter => !!f && typeof f.filter === "string" && typeof f.label === "string",
|
||||
);
|
||||
if (filters.length) out[adapter] = filters;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const seed = (filter: string, label: string, def = false): ExceptionFilter => ({
|
||||
filter,
|
||||
label,
|
||||
description: null,
|
||||
default: def,
|
||||
supportsCondition: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Filters of the supported adapters before any session reported its own (the dialog
|
||||
* shows them right away, like IDEA's per-language exception breakpoints).
|
||||
*/
|
||||
export const BUILTIN_FILTERS: Record<string, ExceptionFilter[]> = {
|
||||
debugpy: [
|
||||
seed("raised", "Raised Exceptions"),
|
||||
seed("uncaught", "Uncaught Exceptions", true),
|
||||
seed("userUnhandled", "User Uncaught Exceptions"),
|
||||
],
|
||||
"java-debug": [seed("caught", "Caught Exceptions"), seed("uncaught", "Uncaught Exceptions")],
|
||||
"js-debug": [seed("all", "Caught Exceptions"), seed("uncaught", "Uncaught Exceptions")],
|
||||
codelldb: [
|
||||
seed("rust_panic", "Rust: on panic", true),
|
||||
seed("cpp_throw", "C++: on throw"),
|
||||
seed("cpp_catch", "C++: on catch"),
|
||||
],
|
||||
};
|
||||
|
||||
export const useExceptionBreakpoints = create<ExceptionState>(() => ({
|
||||
root: null,
|
||||
choices: {},
|
||||
known: { ...BUILTIN_FILTERS, ...sanitizeKnown(readJson(KNOWN_KEY)) },
|
||||
}));
|
||||
|
||||
/** Choice key of an adapter's filter (adapters reuse ids such as `uncaught`). */
|
||||
export function choiceKey(adapter: string, filter: string): string {
|
||||
return `${adapter}:${filter}`;
|
||||
}
|
||||
|
||||
/** Effective state: the adapter-scoped choice, a legacy unscoped one, else the default. */
|
||||
export function isFilterEnabled(filter: ExceptionFilter, choices: Record<string, boolean>, adapter = ""): boolean {
|
||||
return choices[choiceKey(adapter, filter.filter)] ?? choices[filter.filter] ?? filter.default;
|
||||
}
|
||||
|
||||
export interface ExceptionGroup {
|
||||
adapter: string;
|
||||
filters: Array<ExceptionFilter & { enabled: boolean }>;
|
||||
}
|
||||
|
||||
/** Filters grouped by adapter (sorted), with their effective state. */
|
||||
export function exceptionGroups(
|
||||
known: Record<string, ExceptionFilter[]>,
|
||||
choices: Record<string, boolean>,
|
||||
): ExceptionGroup[] {
|
||||
return Object.keys(known)
|
||||
.sort()
|
||||
.map((adapter) => ({
|
||||
adapter,
|
||||
filters: known[adapter]!.map((f) => ({ ...f, enabled: isFilterEnabled(f, choices, adapter) })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Merges filters reported by live sessions into the per-adapter cache. */
|
||||
export function mergeKnown(
|
||||
known: Record<string, ExceptionFilter[]>,
|
||||
sessions: Array<{ adapter: string; filters: ExceptionFilter[] }>,
|
||||
): Record<string, ExceptionFilter[]> {
|
||||
let next = known;
|
||||
for (const s of sessions) {
|
||||
if (!s.filters.length) continue;
|
||||
if (JSON.stringify(next[s.adapter]) === JSON.stringify(s.filters)) continue;
|
||||
next = { ...next, [s.adapter]: s.filters };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function push() {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
await call(commands.debugSetExceptionBreakpoints(useExceptionBreakpoints.getState().choices));
|
||||
} catch (e) {
|
||||
console.warn("debugSetExceptionBreakpoints failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads the project's choices and sends them to the backend. */
|
||||
export function loadExceptionBreakpoints(root: string | null) {
|
||||
const choices = root ? sanitizeChoices(readJson(CHOICES_PREFIX + root)) : {};
|
||||
useExceptionBreakpoints.setState({ root, choices });
|
||||
void push();
|
||||
}
|
||||
|
||||
export function setExceptionFilter(adapter: string, filter: string, enabled: boolean) {
|
||||
const { root, choices } = useExceptionBreakpoints.getState();
|
||||
const key = choiceKey(adapter, filter);
|
||||
if (choices[key] === enabled) return;
|
||||
const next = { ...choices, [key]: enabled };
|
||||
useExceptionBreakpoints.setState({ choices: next });
|
||||
if (root) writeJson(CHOICES_PREFIX + root, next);
|
||||
void push();
|
||||
}
|
||||
|
||||
/** Pulls the filters of live sessions (after a session starts, when the dialog opens). */
|
||||
export async function refreshExceptionFilters() {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
const sessions = await call(commands.debugExceptionFilters());
|
||||
const { known } = useExceptionBreakpoints.getState();
|
||||
const next = mergeKnown(known, sessions);
|
||||
if (next !== known) {
|
||||
useExceptionBreakpoints.setState({ known: next });
|
||||
writeJson(KNOWN_KEY, next);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("debugExceptionFilters failed", e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Settings › Debugger › Breakpoints › "Allow placing breakpoints in any file":
|
||||
* when off, new line breakpoints go only into files of languages that have a
|
||||
* debug adapter (`languages/*.toml` `[debugger]`).
|
||||
*/
|
||||
import { commands, isTauri } from "@/platform/ipc";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
|
||||
let debuggable: Set<string> | null = null;
|
||||
let loading = false;
|
||||
|
||||
function load() {
|
||||
if (debuggable || loading || !isTauri()) return;
|
||||
loading = true;
|
||||
commands
|
||||
.languages()
|
||||
.then((langs) => {
|
||||
debuggable = new Set(langs.filter((l) => l.debuggable).map((l) => l.id));
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => (loading = false));
|
||||
}
|
||||
|
||||
/** For tests: languages that have a debugger (`null` = unknown, allow everything). */
|
||||
export function _setDebuggableLanguages(ids: Iterable<string> | null) {
|
||||
debuggable = ids ? new Set(ids) : null;
|
||||
}
|
||||
|
||||
/** Whether a new breakpoint may be placed in a file of `language`. */
|
||||
export function canPlaceBreakpoint(language: string): boolean {
|
||||
if (workflowSettings().debugger.breakpointsAnyFile) return true;
|
||||
load();
|
||||
// Until the language list is known, don't block anything.
|
||||
return !debuggable || debuggable.has(language);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/** Pure breakpoint list operations (0-based lines). */
|
||||
import type { ChangeDesc, Text } from "@codemirror/state";
|
||||
import type { Breakpoint } from "@/platform/ipc";
|
||||
|
||||
export interface BreakpointEntry {
|
||||
id: string;
|
||||
line: number;
|
||||
enabled: boolean;
|
||||
condition: string | null;
|
||||
logMessage: string | null;
|
||||
hitCondition: string | null;
|
||||
/** Temporary line breakpoint (⌥⇧⌘F8): removed after the first hit. */
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
export interface BreakpointStatus {
|
||||
verified: boolean;
|
||||
message: string | null;
|
||||
/** Line the adapter actually bound to (may differ from the requested one). */
|
||||
line: number;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
export function newBreakpointId(): string {
|
||||
seq += 1;
|
||||
return `bp${Date.now().toString(36)}-${seq}`;
|
||||
}
|
||||
|
||||
export function makeBreakpoint(
|
||||
line: number,
|
||||
props: Partial<Omit<BreakpointEntry, "id" | "line">> = {},
|
||||
): BreakpointEntry {
|
||||
return {
|
||||
id: newBreakpointId(),
|
||||
line,
|
||||
enabled: props.enabled ?? true,
|
||||
condition: props.condition ?? null,
|
||||
logMessage: props.logMessage ?? null,
|
||||
hitCondition: props.hitCondition ?? null,
|
||||
...(props.temporary ? { temporary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function sortByLine<T extends { line: number }>(list: readonly T[]): T[] {
|
||||
return [...list].sort((a, b) => a.line - b.line);
|
||||
}
|
||||
|
||||
/** Adds a breakpoint on `line`, or removes the one there. */
|
||||
export function toggleLine(list: readonly BreakpointEntry[], line: number): BreakpointEntry[] {
|
||||
return list.some((b) => b.line === line)
|
||||
? list.filter((b) => b.line !== line)
|
||||
: sortByLine([...list, makeBreakpoint(line)]);
|
||||
}
|
||||
|
||||
/** True when the breakpoint has a condition, hit condition or log message. */
|
||||
export function isConditional(b: BreakpointEntry): boolean {
|
||||
return !!(b.condition?.trim() || b.hitCondition?.trim() || b.logMessage?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps breakpoint lines through an edit. A breakpoint follows the start of
|
||||
* its line; when lines merge, the first breakpoint on a line wins.
|
||||
*/
|
||||
export function mapLines<T extends { line: number }>(
|
||||
list: readonly T[],
|
||||
oldDoc: Text,
|
||||
changes: ChangeDesc,
|
||||
newDoc: Text,
|
||||
): T[] {
|
||||
if (changes.empty) return [...list];
|
||||
const out: T[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const b of sortByLine(list)) {
|
||||
if (b.line >= oldDoc.lines) {
|
||||
if (!seen.has(b.line)) out.push(b);
|
||||
seen.add(b.line);
|
||||
continue;
|
||||
}
|
||||
const lineStart = oldDoc.line(b.line + 1).from;
|
||||
// assoc 1: text inserted at the very start of the line pushes the breakpoint down with it.
|
||||
const pos = changes.mapPos(lineStart, 1);
|
||||
const line = newDoc.lineAt(Math.min(pos, newDoc.length)).number - 1;
|
||||
if (seen.has(line)) continue;
|
||||
seen.add(line);
|
||||
out.push(line === b.line ? b : { ...b, line });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The list sent to the backend; muting disables everything without touching the stored flags. */
|
||||
export function toBackend(list: readonly BreakpointEntry[], muted: boolean): Breakpoint[] {
|
||||
return list.map((b) => ({
|
||||
line: b.line,
|
||||
condition: b.condition?.trim() || null,
|
||||
hitCondition: b.hitCondition?.trim() || null,
|
||||
logMessage: b.logMessage?.trim() || null,
|
||||
enabled: b.enabled && !muted,
|
||||
}));
|
||||
}
|
||||
|
||||
export function sameEntries(a: readonly BreakpointEntry[], b: readonly BreakpointEntry[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((x, i) => {
|
||||
const y = b[i]!;
|
||||
return (
|
||||
x.id === y.id &&
|
||||
x.line === y.line &&
|
||||
x.enabled === y.enabled &&
|
||||
x.condition === y.condition &&
|
||||
x.logMessage === y.logMessage &&
|
||||
x.hitCondition === y.hitCondition &&
|
||||
!!x.temporary === !!y.temporary
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Method breakpoints (DAP function breakpoints) and field watchpoints (DAP data
|
||||
* breakpoints). Both are project-wide lists, persisted like line breakpoints and
|
||||
* mirrored to the backend, which sends them to every session whose adapter supports
|
||||
* them. Entries remember the source line they were created on for the gutter icon.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri, type DataBreakpoint, type FunctionBreakpoint } from "@/platform/ipc";
|
||||
import { newBreakpointId, type BreakpointStatus } from "./model";
|
||||
import { useBreakpoints } from "./store";
|
||||
|
||||
interface Common {
|
||||
id: string;
|
||||
/** Source location (gutter icon, Go to Source); null for watchpoints made from a value. */
|
||||
path: string | null;
|
||||
line: number | null;
|
||||
enabled: boolean;
|
||||
condition: string | null;
|
||||
hitCondition: string | null;
|
||||
}
|
||||
|
||||
export interface MethodBreakpoint extends Common {
|
||||
kind: "method";
|
||||
/** Function name in the adapter's syntax (`pkg.Class#method`, `main.run`, `parse`). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type AccessType = "write" | "read" | "readWrite";
|
||||
|
||||
export interface FieldWatchpoint extends Common {
|
||||
kind: "field";
|
||||
dataId: string;
|
||||
/** What the user sees (`Order.total`). */
|
||||
label: string;
|
||||
accessType: AccessType | null;
|
||||
}
|
||||
|
||||
export type SpecialBreakpoint = MethodBreakpoint | FieldWatchpoint;
|
||||
|
||||
interface SpecialState {
|
||||
root: string | null;
|
||||
methods: MethodBreakpoint[];
|
||||
fields: FieldWatchpoint[];
|
||||
status: Record<string, BreakpointStatus>;
|
||||
}
|
||||
|
||||
export const useSpecialBreakpoints = create<SpecialState>(() => ({ root: null, methods: [], fields: [], status: {} }));
|
||||
|
||||
const STORAGE_PREFIX = "openidea.debugger.specialBreakpoints:";
|
||||
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function common(raw: Record<string, unknown>): Common {
|
||||
return {
|
||||
id: str(raw.id) ?? newBreakpointId(),
|
||||
path: str(raw.path),
|
||||
line: Number.isInteger(raw.line) ? (raw.line as number) : null,
|
||||
enabled: raw.enabled !== false,
|
||||
condition: str(raw.condition),
|
||||
hitCondition: str(raw.hitCondition),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeSpecial(raw: unknown): { methods: MethodBreakpoint[]; fields: FieldWatchpoint[] } {
|
||||
const data = (raw && typeof raw === "object" ? raw : {}) as { methods?: unknown; fields?: unknown };
|
||||
const records = (v: unknown) =>
|
||||
Array.isArray(v) ? v.filter((x): x is Record<string, unknown> => !!x && typeof x === "object") : [];
|
||||
const methods = records(data.methods)
|
||||
.filter((m) => typeof m.name === "string" && m.name.trim())
|
||||
.map((m): MethodBreakpoint => ({ ...common(m), kind: "method", name: m.name as string }));
|
||||
const fields = records(data.fields)
|
||||
.filter((f) => typeof f.dataId === "string" && f.dataId)
|
||||
.map((f): FieldWatchpoint => ({
|
||||
...common(f),
|
||||
kind: "field",
|
||||
dataId: f.dataId as string,
|
||||
label: str(f.label) ?? (f.dataId as string),
|
||||
accessType:
|
||||
f.accessType === "read" || f.accessType === "readWrite" || f.accessType === "write" ? f.accessType : null,
|
||||
}));
|
||||
return { methods, fields };
|
||||
}
|
||||
|
||||
function persist() {
|
||||
const { root, methods, fields } = useSpecialBreakpoints.getState();
|
||||
if (!root) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + root, JSON.stringify({ methods, fields }));
|
||||
} catch {
|
||||
// Storage unavailable: kept for this session only.
|
||||
}
|
||||
}
|
||||
|
||||
/** Backend lists; muting disables everything without touching the stored flags. */
|
||||
export function toFunctionBreakpoints(list: readonly MethodBreakpoint[], muted: boolean): FunctionBreakpoint[] {
|
||||
return list.map((m) => ({
|
||||
name: m.name.trim(),
|
||||
condition: m.condition?.trim() || null,
|
||||
hitCondition: m.hitCondition?.trim() || null,
|
||||
enabled: m.enabled && !muted,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toDataBreakpoints(list: readonly FieldWatchpoint[], muted: boolean): DataBreakpoint[] {
|
||||
return list.map((f) => ({
|
||||
dataId: f.dataId,
|
||||
accessType: f.accessType,
|
||||
condition: f.condition?.trim() || null,
|
||||
hitCondition: f.hitCondition?.trim() || null,
|
||||
enabled: f.enabled && !muted,
|
||||
}));
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function scheduleSync() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void syncSpecial(), 120);
|
||||
}
|
||||
|
||||
export async function syncSpecial() {
|
||||
if (!isTauri()) return;
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
const muted = useBreakpoints.getState().muted;
|
||||
try {
|
||||
const [m, f] = await Promise.all([
|
||||
call(commands.debugSetFunctionBreakpoints(toFunctionBreakpoints(methods, muted))),
|
||||
call(commands.debugSetDataBreakpoints(toDataBreakpoints(fields, muted))),
|
||||
]);
|
||||
useSpecialBreakpoints.setState((s) => {
|
||||
const status = { ...s.status };
|
||||
methods.forEach(
|
||||
(b, i) => m[i] && (status[b.id] = { verified: m[i].verified, message: m[i].message, line: b.line ?? 0 }),
|
||||
);
|
||||
fields.forEach(
|
||||
(b, i) => f[i] && (status[b.id] = { verified: f[i].verified, message: f[i].message, line: b.line ?? 0 }),
|
||||
);
|
||||
return { status };
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("syncing method breakpoints / watchpoints failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSpecialBreakpoints(root: string | null) {
|
||||
let data = { methods: [] as MethodBreakpoint[], fields: [] as FieldWatchpoint[] };
|
||||
if (root) {
|
||||
try {
|
||||
data = sanitizeSpecial(JSON.parse(localStorage.getItem(STORAGE_PREFIX + root) ?? "{}"));
|
||||
} catch {
|
||||
// Corrupt entry: start empty.
|
||||
}
|
||||
}
|
||||
useSpecialBreakpoints.setState({ root, ...data, status: {} });
|
||||
scheduleSync();
|
||||
}
|
||||
|
||||
function setLists(next: Partial<Pick<SpecialState, "methods" | "fields">>) {
|
||||
useSpecialBreakpoints.setState(next);
|
||||
persist();
|
||||
scheduleSync();
|
||||
}
|
||||
|
||||
export function addMethodBreakpoint(name: string, at: { path: string; line: number } | null = null): MethodBreakpoint {
|
||||
const bp: MethodBreakpoint = {
|
||||
id: newBreakpointId(),
|
||||
kind: "method",
|
||||
name: name.trim(),
|
||||
path: at?.path ?? null,
|
||||
line: at?.line ?? null,
|
||||
enabled: true,
|
||||
condition: null,
|
||||
hitCondition: null,
|
||||
};
|
||||
setLists({ methods: [...useSpecialBreakpoints.getState().methods, bp] });
|
||||
return bp;
|
||||
}
|
||||
|
||||
export function addFieldWatchpoint(
|
||||
props: { dataId: string; label: string; accessType?: AccessType | null },
|
||||
at: { path: string; line: number } | null = null,
|
||||
): FieldWatchpoint {
|
||||
const existing = useSpecialBreakpoints.getState().fields.find((f) => f.dataId === props.dataId);
|
||||
if (existing) return existing;
|
||||
const bp: FieldWatchpoint = {
|
||||
id: newBreakpointId(),
|
||||
kind: "field",
|
||||
dataId: props.dataId,
|
||||
label: props.label,
|
||||
accessType: props.accessType ?? null,
|
||||
path: at?.path ?? null,
|
||||
line: at?.line ?? null,
|
||||
enabled: true,
|
||||
condition: null,
|
||||
hitCondition: null,
|
||||
};
|
||||
setLists({ fields: [...useSpecialBreakpoints.getState().fields, bp] });
|
||||
return bp;
|
||||
}
|
||||
|
||||
export function updateSpecial(
|
||||
id: string,
|
||||
patch: Partial<Omit<MethodBreakpoint, "id" | "kind">> | Partial<Omit<FieldWatchpoint, "id" | "kind">>,
|
||||
) {
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
setLists({
|
||||
methods: methods.map((m) => (m.id === id ? ({ ...m, ...patch } as MethodBreakpoint) : m)),
|
||||
fields: fields.map((f) => (f.id === id ? ({ ...f, ...patch } as FieldWatchpoint) : f)),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeSpecial(id: string) {
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
setLists({ methods: methods.filter((m) => m.id !== id), fields: fields.filter((f) => f.id !== id) });
|
||||
}
|
||||
|
||||
export function removeAllSpecial() {
|
||||
setLists({ methods: [], fields: [] });
|
||||
}
|
||||
|
||||
/** Method breakpoints / watchpoints whose gutter icon is in `path`. */
|
||||
export function specialAt(path: string): SpecialBreakpoint[] {
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
return [...methods, ...fields].filter((b) => b.path === path && b.line != null);
|
||||
}
|
||||
|
||||
/** Toggles the method breakpoint on `path:line` (adds `name` when there is none). */
|
||||
export function toggleMethodAt(path: string, line: number, name: string): MethodBreakpoint | null {
|
||||
const existing = useSpecialBreakpoints.getState().methods.find((m) => m.path === path && m.line === line);
|
||||
if (existing) {
|
||||
removeSpecial(existing.id);
|
||||
return null;
|
||||
}
|
||||
return addMethodBreakpoint(name, { path, line });
|
||||
}
|
||||
|
||||
export function toggleFieldAt(
|
||||
path: string,
|
||||
line: number,
|
||||
props: { dataId: string; label: string },
|
||||
): FieldWatchpoint | null {
|
||||
const existing = useSpecialBreakpoints.getState().fields.find((f) => f.path === path && f.line === line);
|
||||
if (existing) {
|
||||
removeSpecial(existing.id);
|
||||
return null;
|
||||
}
|
||||
return addFieldWatchpoint({ ...props, accessType: "write" }, { path, line });
|
||||
}
|
||||
|
||||
/** Moves gutter lines after an edit in `path` (see `mapLines`). */
|
||||
export function setSpecialLines(path: string, moved: ReadonlyArray<{ id: string; line: number }>) {
|
||||
const lines = new Map(moved.map((m) => [m.id, m.line]));
|
||||
const { methods, fields } = useSpecialBreakpoints.getState();
|
||||
const touch = <T extends SpecialBreakpoint>(b: T): T => {
|
||||
if (b.path !== path) return b;
|
||||
const line = lines.get(b.id);
|
||||
// Merged away by the edit (its line was deleted together with another breakpoint's).
|
||||
if (line === undefined) return { ...b, path: null, line: null };
|
||||
return line === b.line ? b : { ...b, line };
|
||||
};
|
||||
const nextMethods = methods.map(touch);
|
||||
const nextFields = fields.map(touch);
|
||||
if (nextMethods.every((m, i) => m === methods[i]) && nextFields.every((f, i) => f === fields[i])) return;
|
||||
setLists({ methods: nextMethods, fields: nextFields });
|
||||
}
|
||||
|
||||
// Muting applies to these too.
|
||||
useBreakpoints.subscribe((s, prev) => {
|
||||
if (s.muted !== prev.muted) scheduleSync();
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Breakpoints of the open project: persisted per project in localStorage and
|
||||
* mirrored to the backend (`debugSetBreakpoints`) whenever a file's list changes.
|
||||
* The backend keeps them across sessions and returns verification states.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
makeBreakpoint,
|
||||
sameEntries,
|
||||
sortByLine,
|
||||
toBackend,
|
||||
toggleLine,
|
||||
type BreakpointEntry,
|
||||
type BreakpointStatus,
|
||||
} from "./model";
|
||||
|
||||
interface BreakpointState {
|
||||
/** Project root the breakpoints belong to (storage key). */
|
||||
root: string | null;
|
||||
byFile: Record<string, BreakpointEntry[]>;
|
||||
/** Verification state by breakpoint id. */
|
||||
status: Record<string, BreakpointStatus>;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export const useBreakpoints = create<BreakpointState>(() => ({ root: null, byFile: {}, status: {}, muted: false }));
|
||||
|
||||
const STORAGE_PREFIX = "openidea.debugger.breakpoints:";
|
||||
|
||||
interface Persisted {
|
||||
muted?: boolean;
|
||||
files?: Record<string, BreakpointEntry[]>;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
const { root, byFile, muted } = useBreakpoints.getState();
|
||||
if (!root) return;
|
||||
try {
|
||||
const files = Object.fromEntries(Object.entries(byFile).filter(([, l]) => l.length));
|
||||
localStorage.setItem(STORAGE_PREFIX + root, JSON.stringify({ muted, files } satisfies Persisted));
|
||||
} catch {
|
||||
// Storage full / unavailable: breakpoints just don't survive a restart.
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize(raw: unknown): BreakpointEntry[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return sortByLine(
|
||||
raw
|
||||
.filter(
|
||||
(b): b is BreakpointEntry => !!b && typeof b === "object" && Number.isInteger((b as BreakpointEntry).line),
|
||||
)
|
||||
.map((b) => ({
|
||||
...makeBreakpoint(b.line),
|
||||
...(typeof b.id === "string" ? { id: b.id } : {}),
|
||||
enabled: b.enabled !== false,
|
||||
condition: typeof b.condition === "string" ? b.condition : null,
|
||||
logMessage: typeof b.logMessage === "string" ? b.logMessage : null,
|
||||
hitCondition: typeof b.hitCondition === "string" ? b.hitCondition : null,
|
||||
...(b.temporary === true ? { temporary: true } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Loads the project's breakpoints and pushes them all to the backend. */
|
||||
export function loadBreakpoints(root: string | null) {
|
||||
let byFile: Record<string, BreakpointEntry[]> = {};
|
||||
let muted = false;
|
||||
if (root) {
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem(STORAGE_PREFIX + root) ?? "{}") as Persisted;
|
||||
muted = !!data.muted;
|
||||
for (const [path, list] of Object.entries(data.files ?? {})) {
|
||||
const clean = sanitize(list);
|
||||
if (clean.length) byFile[path] = clean;
|
||||
}
|
||||
} catch {
|
||||
byFile = {};
|
||||
}
|
||||
}
|
||||
const previous = Object.keys(useBreakpoints.getState().byFile);
|
||||
useBreakpoints.setState({ root, byFile, muted, status: {} });
|
||||
for (const path of new Set([...previous, ...Object.keys(byFile)])) scheduleSync(path);
|
||||
}
|
||||
|
||||
// --- backend sync -------------------------------------------------------------
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Ids in the order last sent per file (backend states are index-aligned). */
|
||||
const sent = new Map<string, string[]>();
|
||||
const SYNC_DELAY = 120;
|
||||
|
||||
export function scheduleSync(path: string) {
|
||||
clearTimeout(timers.get(path));
|
||||
timers.set(
|
||||
path,
|
||||
setTimeout(() => {
|
||||
timers.delete(path);
|
||||
void syncFile(path);
|
||||
}, SYNC_DELAY),
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncFile(path: string) {
|
||||
if (!isTauri()) return;
|
||||
const { byFile, muted } = useBreakpoints.getState();
|
||||
const list = byFile[path] ?? [];
|
||||
sent.set(
|
||||
path,
|
||||
list.map((b) => b.id),
|
||||
);
|
||||
try {
|
||||
const states = await call(commands.debugSetBreakpoints(path, toBackend(list, muted)));
|
||||
applyStates(path, states);
|
||||
} catch (e) {
|
||||
console.warn("debugSetBreakpoints failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads verification states after a `breakpointsChanged` event. */
|
||||
export async function refreshStates(path: string) {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
applyStates(path, await call(commands.debugBreakpointStates(path)));
|
||||
} catch (e) {
|
||||
console.warn("debugBreakpointStates failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStates(path: string, states: Array<{ line: number; verified: boolean; message: string | null }>) {
|
||||
const ids = sent.get(path) ?? (useBreakpoints.getState().byFile[path] ?? []).map((b) => b.id);
|
||||
useBreakpoints.setState((s) => {
|
||||
const status = { ...s.status };
|
||||
states.forEach((st, i) => {
|
||||
const id = ids[i];
|
||||
if (id) status[id] = { verified: st.verified, message: st.message, line: st.line };
|
||||
});
|
||||
return { status };
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks every breakpoint unverified (no session is running). */
|
||||
export function resetStatuses() {
|
||||
useBreakpoints.setState({ status: {} });
|
||||
}
|
||||
|
||||
// --- mutations ----------------------------------------------------------------
|
||||
|
||||
function setFile(path: string, list: BreakpointEntry[]) {
|
||||
const current = useBreakpoints.getState().byFile[path] ?? [];
|
||||
if (sameEntries(current, list)) return;
|
||||
useBreakpoints.setState((s) => {
|
||||
const byFile = { ...s.byFile };
|
||||
if (list.length) byFile[path] = list;
|
||||
else delete byFile[path];
|
||||
return { byFile };
|
||||
});
|
||||
persist();
|
||||
scheduleSync(path);
|
||||
}
|
||||
|
||||
export function breakpointsOf(path: string): BreakpointEntry[] {
|
||||
return useBreakpoints.getState().byFile[path] ?? [];
|
||||
}
|
||||
|
||||
export function toggleBreakpoint(path: string, line: number) {
|
||||
setFile(path, toggleLine(breakpointsOf(path), line));
|
||||
}
|
||||
|
||||
export function addBreakpoint(path: string, line: number, props: Partial<Omit<BreakpointEntry, "id" | "line">> = {}) {
|
||||
const list = breakpointsOf(path).filter((b) => b.line !== line);
|
||||
const bp = makeBreakpoint(line, props);
|
||||
setFile(path, sortByLine([...list, bp]));
|
||||
return bp;
|
||||
}
|
||||
|
||||
export function updateBreakpoint(path: string, id: string, patch: Partial<Omit<BreakpointEntry, "id">>) {
|
||||
setFile(path, sortByLine(breakpointsOf(path).map((b) => (b.id === id ? { ...b, ...patch } : b))));
|
||||
}
|
||||
|
||||
export function removeBreakpoint(path: string, id: string) {
|
||||
setFile(
|
||||
path,
|
||||
breakpointsOf(path).filter((b) => b.id !== id),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeAllBreakpoints() {
|
||||
for (const path of Object.keys(useBreakpoints.getState().byFile)) setFile(path, []);
|
||||
}
|
||||
|
||||
/** Replaces a file's list after its lines moved with an edit (from the editor gutter). */
|
||||
export function setFileBreakpoints(path: string, list: BreakpointEntry[]) {
|
||||
setFile(path, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* A suspend at `path:line` hit the temporary breakpoint there: remove it, like IDEA.
|
||||
* Returns whether one was removed.
|
||||
*/
|
||||
export function consumeTemporary(path: string, line: number): boolean {
|
||||
const bp = breakpointsOf(path).find((b) => b.temporary && b.line === line && b.enabled);
|
||||
if (!bp) return false;
|
||||
removeBreakpoint(path, bp.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggles a temporary breakpoint on `line`; an existing one there is removed. */
|
||||
export function toggleTemporaryBreakpoint(path: string, line: number) {
|
||||
if (breakpointsOf(path).some((b) => b.line === line)) {
|
||||
setFile(
|
||||
path,
|
||||
breakpointsOf(path).filter((b) => b.line !== line),
|
||||
);
|
||||
return;
|
||||
}
|
||||
addBreakpoint(path, line, { temporary: true });
|
||||
}
|
||||
|
||||
export function setMuted(muted: boolean) {
|
||||
if (useBreakpoints.getState().muted === muted) return;
|
||||
useBreakpoints.setState({ muted });
|
||||
persist();
|
||||
for (const path of Object.keys(useBreakpoints.getState().byFile)) scheduleSync(path);
|
||||
}
|
||||
|
||||
export function allBreakpoints(): Array<{ path: string; bp: BreakpointEntry }> {
|
||||
const { byFile } = useBreakpoints.getState();
|
||||
return Object.keys(byFile)
|
||||
.sort()
|
||||
.flatMap((path) => byFile[path]!.map((bp) => ({ path, bp })));
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Method / field declarations on a source line, named the way debug adapters expect
|
||||
* them in function breakpoints and data breakpoint ids. Text-based (no PSI): good for
|
||||
* ordinary declarations, which is what IDEA's gutter toggles act on.
|
||||
*/
|
||||
|
||||
export interface MethodTarget {
|
||||
/** Function breakpoint name for the language's adapter. */
|
||||
name: string;
|
||||
/** Short label (`Order.total()`). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FieldTarget {
|
||||
/** java-debug data breakpoint id (`pkg.Class#field`). */
|
||||
dataId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CONTROL = new Set([
|
||||
"if",
|
||||
"for",
|
||||
"while",
|
||||
"switch",
|
||||
"catch",
|
||||
"return",
|
||||
"new",
|
||||
"throw",
|
||||
"else",
|
||||
"do",
|
||||
"try",
|
||||
"synchronized",
|
||||
"assert",
|
||||
"super",
|
||||
"this",
|
||||
]);
|
||||
|
||||
/** Strips string/char literals and comments so braces and keywords inside them don't count. */
|
||||
function code(line: string, state: { block: boolean }): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i]!;
|
||||
if (state.block) {
|
||||
if (c === "*" && line[i + 1] === "/") {
|
||||
state.block = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && line[i + 1] === "/") break;
|
||||
if (c === "/" && line[i + 1] === "*") {
|
||||
state.block = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
const q = c;
|
||||
i++;
|
||||
while (i < line.length && line[i] !== q) i += line[i] === "\\" ? 2 : 1;
|
||||
out += q + q;
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Enclosing types of `line` (outermost first) in a brace language, plus the package. */
|
||||
function javaContext(lines: readonly string[], line: number): { pkg: string; types: string[] } {
|
||||
let pkg = "";
|
||||
const stack: Array<{ name: string | null; depth: number }> = [];
|
||||
let depth = 0;
|
||||
let pendingType: string | null = null;
|
||||
const state = { block: false };
|
||||
for (let i = 0; i < line; i++) {
|
||||
const text = code(lines[i] ?? "", state);
|
||||
const p = /^\s*package\s+([\w.]+)/.exec(text);
|
||||
if (p) pkg = p[1]!;
|
||||
const t = /\b(?:class|interface|enum|record|object)\s+([A-Za-z_$][\w$]*)/.exec(text);
|
||||
if (t) pendingType = t[1]!;
|
||||
for (const c of text) {
|
||||
if (c === "{") {
|
||||
depth++;
|
||||
stack.push({ name: pendingType, depth });
|
||||
pendingType = null;
|
||||
} else if (c === "}") {
|
||||
while (stack.length && stack[stack.length - 1]!.depth >= depth) stack.pop();
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A type declared on the line itself (one-line `class A { void f() {} }`) is ignored.
|
||||
return { pkg, types: stack.filter((s) => s.name).map((s) => s.name!) };
|
||||
}
|
||||
|
||||
function qualified(pkg: string, types: string[]): string | null {
|
||||
if (!types.length) return null;
|
||||
return (pkg ? `${pkg}.` : "") + types.join("$");
|
||||
}
|
||||
|
||||
const JAVA_METHOD = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*(?:[\w<>,.?[\]\s]+?\s+)?([A-Za-z_$][\w$]*)\s*\(/;
|
||||
|
||||
function javaMethod(lines: readonly string[], line: number): MethodTarget | null {
|
||||
const text = code(lines[line] ?? "", { block: false });
|
||||
const m = JAVA_METHOD.exec(text);
|
||||
if (!m) return null;
|
||||
const name = m[1]!;
|
||||
const before = text.slice(0, m.index + m[0].length - 1 - name.length).trim();
|
||||
if (CONTROL.has(name) || /[=.]|\breturn\b|\bnew\b/.test(before)) return null;
|
||||
// `foo(x);` is a call; a declaration has a type or modifiers in front, or is a constructor.
|
||||
const { pkg, types } = javaContext(lines, line);
|
||||
const cls = qualified(pkg, types);
|
||||
if (!cls) return null;
|
||||
const isConstructor = name === types[types.length - 1];
|
||||
if (!before && !isConstructor) return null;
|
||||
const method = isConstructor ? "<init>" : name;
|
||||
return { name: `${cls}#${method}`, label: `${types[types.length - 1]}.${isConstructor ? name : method}()` };
|
||||
}
|
||||
|
||||
const JAVA_FIELD =
|
||||
/^\s*(?:@\w+(?:\([^)]*\))?\s+)*((?:(?:public|protected|private|static|final|volatile|transient)\s+)*)[\w<>,.?[\]\s]+?\s+([A-Za-z_$][\w$]*)\s*(?:=|;)/;
|
||||
|
||||
function javaField(lines: readonly string[], line: number): FieldTarget | null {
|
||||
const text = code(lines[line] ?? "", { block: false });
|
||||
if (text.includes("(") && !/=/.test(text.slice(0, text.indexOf("(")))) return null;
|
||||
const m = JAVA_FIELD.exec(text);
|
||||
if (!m) return null;
|
||||
const name = m[2]!;
|
||||
if (CONTROL.has(name) || /^\s*return\b/.test(text)) return null;
|
||||
const { pkg, types } = javaContext(lines, line);
|
||||
const cls = qualified(pkg, types);
|
||||
// Only directly inside a type body (a local variable sits inside a method's braces).
|
||||
if (!cls || !insideTypeBody(lines, line, types.length)) return null;
|
||||
return { dataId: `${cls}#${name}`, label: `${types[types.length - 1]}.${name}` };
|
||||
}
|
||||
|
||||
/** Whether every open brace before `line` belongs to a type declaration. */
|
||||
function insideTypeBody(lines: readonly string[], line: number, typeDepth: number): boolean {
|
||||
let depth = 0;
|
||||
const state = { block: false };
|
||||
for (let i = 0; i < line; i++) {
|
||||
for (const c of code(lines[i] ?? "", state)) {
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
}
|
||||
}
|
||||
return depth === typeDepth;
|
||||
}
|
||||
|
||||
function goMethod(lines: readonly string[], line: number): MethodTarget | null {
|
||||
const m = /^\s*func\s+(?:\(\s*\w*\s*(\*?)\s*([A-Za-z_]\w*)(?:\[[^\]]*\])?\s*\)\s*)?([A-Za-z_]\w*)\s*[[(]/.exec(
|
||||
lines[line] ?? "",
|
||||
);
|
||||
if (!m) return null;
|
||||
const pkg = lines.map((l) => /^\s*package\s+(\w+)/.exec(l)?.[1]).find(Boolean) ?? "main";
|
||||
const [, star, recv, fn] = m;
|
||||
const name = recv ? (star ? `${pkg}.(*${recv}).${fn}` : `${pkg}.${recv}.${fn}`) : `${pkg}.${fn}`;
|
||||
return { name, label: recv ? `${recv}.${fn}()` : `${fn}()` };
|
||||
}
|
||||
|
||||
function simple(re: RegExp, lines: readonly string[], line: number): MethodTarget | null {
|
||||
const m = re.exec(lines[line] ?? "");
|
||||
return m ? { name: m[1]!, label: `${m[1]}()` } : null;
|
||||
}
|
||||
|
||||
const C_FUNCTION =
|
||||
/^\s*(?:(?:static|inline|extern|virtual|constexpr|const|unsigned|signed|struct|enum)\s+)*[A-Za-z_][\w:<>,*&\s]*?[\s*&]([A-Za-z_~][\w:~]*)\s*\([^;]*$/;
|
||||
|
||||
/** The method declared on `line` (0-based), named for `language`'s debug adapter. */
|
||||
export function methodAt(language: string, text: string | readonly string[], line: number): MethodTarget | null {
|
||||
const lines = typeof text === "string" ? text.split("\n") : text;
|
||||
switch (language) {
|
||||
case "java":
|
||||
case "kotlin":
|
||||
return language === "java"
|
||||
? javaMethod(lines, line)
|
||||
: simple(/^\s*(?:[\w@]+\s+)*fun\s+(?:<[^>]*>\s*)?(?:[\w.]+\.)?([A-Za-z_]\w*)\s*\(/, lines, line);
|
||||
case "python":
|
||||
return simple(/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/, lines, line);
|
||||
case "go":
|
||||
return goMethod(lines, line);
|
||||
case "rust":
|
||||
return simple(
|
||||
/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:(?:const|async|unsafe|extern\s+"[^"]*")\s+)*fn\s+([A-Za-z_]\w*)/,
|
||||
lines,
|
||||
line,
|
||||
);
|
||||
case "javascript":
|
||||
case "typescript":
|
||||
case "javascriptreact":
|
||||
case "typescriptreact":
|
||||
return (
|
||||
simple(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/, lines, line) ??
|
||||
simple(
|
||||
/^\s*(?:(?:public|private|protected|static|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]*)?\{/,
|
||||
lines,
|
||||
line,
|
||||
)
|
||||
);
|
||||
case "c":
|
||||
case "cpp":
|
||||
case "objective-c":
|
||||
case "objective-cpp": {
|
||||
const t = simple(C_FUNCTION, lines, line);
|
||||
return t && !CONTROL.has(t.name) ? t : null;
|
||||
}
|
||||
case "swift":
|
||||
return simple(/^\s*(?:[\w@]+\s+)*func\s+([A-Za-z_]\w*)/, lines, line);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The field declared on `line` (0-based) as a persistent watchpoint id (Java only). */
|
||||
export function fieldAt(language: string, text: string, line: number): FieldTarget | null {
|
||||
if (language !== "java") return null;
|
||||
return javaField(text.split("\n"), line);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Toggle Line Breakpoint as IDEA does it: in Java, a method declaration line gets a
|
||||
* method breakpoint and a field declaration a field watchpoint; any other line (and
|
||||
* every other language) a line breakpoint. An existing breakpoint of any kind is removed.
|
||||
*/
|
||||
import { removeSpecial, specialAt, toggleFieldAt, toggleMethodAt } from "./special";
|
||||
import { breakpointsOf, toggleBreakpoint } from "./store";
|
||||
import { fieldAt, methodAt } from "./symbols";
|
||||
|
||||
export function toggleBreakpointSmart(path: string, line: number, language: string, text: string) {
|
||||
const special = specialAt(path).find((b) => b.line === line);
|
||||
if (special) {
|
||||
removeSpecial(special.id);
|
||||
return;
|
||||
}
|
||||
if (language === "java" && !breakpointsOf(path).some((b) => b.line === line)) {
|
||||
const field = fieldAt(language, text, line);
|
||||
if (field) {
|
||||
toggleFieldAt(path, line, field);
|
||||
return;
|
||||
}
|
||||
const method = methodAt(language, text, line);
|
||||
if (method) {
|
||||
toggleMethodAt(path, line, method.name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
toggleBreakpoint(path, line);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Breakpoints in the editor: markers on the line-number gutter (classes only,
|
||||
* so the number keeps rendering; the dot is a pseudo-element), click to
|
||||
* toggle, right-click for the properties popover, and line tracking through edits.
|
||||
*/
|
||||
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, GutterMarker, ViewPlugin, lineNumberMarkers, type ViewUpdate } from "@codemirror/view";
|
||||
import { editorsFor, mirroredChange, remoteChange, useEditorRegistry, type EditorDocContext } from "@/editor/api";
|
||||
import { showContextMenu } from "@/ui";
|
||||
import { isConditional, mapLines, type BreakpointEntry, type BreakpointStatus } from "../breakpoints/model";
|
||||
import {
|
||||
addBreakpoint,
|
||||
breakpointsOf,
|
||||
setFileBreakpoints,
|
||||
toggleTemporaryBreakpoint,
|
||||
updateBreakpoint,
|
||||
useBreakpoints,
|
||||
} from "../breakpoints/store";
|
||||
import { canPlaceBreakpoint } from "../breakpoints/languages";
|
||||
import {
|
||||
removeSpecial,
|
||||
setSpecialLines,
|
||||
specialAt,
|
||||
toggleFieldAt,
|
||||
toggleMethodAt,
|
||||
updateSpecial,
|
||||
useSpecialBreakpoints,
|
||||
type SpecialBreakpoint,
|
||||
} from "../breakpoints/special";
|
||||
import { toggleBreakpointSmart } from "../breakpoints/toggle";
|
||||
import { methodAt, fieldAt } from "../breakpoints/symbols";
|
||||
import { sessionsRunning, useDebug } from "../state";
|
||||
import { openBreakpointPopover } from "../ui/popoverStore";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
export type BreakpointLook =
|
||||
| "normal"
|
||||
| "disabled"
|
||||
| "unverified"
|
||||
| "verified"
|
||||
| "conditional"
|
||||
| "log"
|
||||
| "method"
|
||||
| "methodDisabled"
|
||||
| "methodVerified"
|
||||
| "field"
|
||||
| "fieldDisabled"
|
||||
| "fieldVerified";
|
||||
|
||||
export function lookOf(b: BreakpointEntry, status: BreakpointStatus | undefined, muted: boolean, running: boolean) {
|
||||
if (!b.enabled || muted) return "disabled";
|
||||
if (running && status && !status.verified) return "unverified";
|
||||
if (b.logMessage?.trim()) return "log";
|
||||
if (isConditional(b)) return "conditional";
|
||||
return running && status?.verified ? "verified" : "normal";
|
||||
}
|
||||
|
||||
/** Method breakpoints / field watchpoints use IDEA's expui icons. */
|
||||
export function specialLook(
|
||||
b: SpecialBreakpoint,
|
||||
status: BreakpointStatus | undefined,
|
||||
muted: boolean,
|
||||
running: boolean,
|
||||
): BreakpointLook {
|
||||
if (!b.enabled || muted) return b.kind === "method" ? "methodDisabled" : "fieldDisabled";
|
||||
if (running && status && !status.verified) return "unverified";
|
||||
if (running && status?.verified) return b.kind === "method" ? "methodVerified" : "fieldVerified";
|
||||
return b.kind;
|
||||
}
|
||||
|
||||
class BreakpointMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly look: BreakpointLook,
|
||||
) {
|
||||
super();
|
||||
this.elementClass = `${styles.bp} ${styles[look] ?? ""}`;
|
||||
}
|
||||
eq(other: BreakpointMarker) {
|
||||
return other.id === this.id && other.look === this.look;
|
||||
}
|
||||
}
|
||||
|
||||
const bpLine = Decoration.line({ class: styles.bpLine });
|
||||
const DISABLED_LOOKS = new Set<BreakpointLook>(["disabled", "methodDisabled", "fieldDisabled"]);
|
||||
|
||||
const setMarkers = StateEffect.define<RangeSet<BreakpointMarker>>();
|
||||
|
||||
function buildMarkers(state: EditorState, path: string): RangeSet<BreakpointMarker> {
|
||||
const { status, muted } = useBreakpoints.getState();
|
||||
const running = sessionsRunning();
|
||||
const doc = state.doc;
|
||||
const special = specialAt(path).filter((b) => b.line! < doc.lines);
|
||||
const specialStatus = useSpecialBreakpoints.getState().status;
|
||||
const taken = new Set(special.map((b) => b.line));
|
||||
const ranges = [
|
||||
...special.map((b) =>
|
||||
new BreakpointMarker(b.id, specialLook(b, specialStatus[b.id], muted, running)).range(doc.line(b.line! + 1).from),
|
||||
),
|
||||
...breakpointsOf(path)
|
||||
.filter((b) => b.line < doc.lines && !taken.has(b.line))
|
||||
.map((b) => new BreakpointMarker(b.id, lookOf(b, status[b.id], muted, running)).range(doc.line(b.line + 1).from)),
|
||||
];
|
||||
return RangeSet.of(ranges, true);
|
||||
}
|
||||
|
||||
/** For backend patches applied to every pane: the one editor of `path` that moves the stored lines. */
|
||||
function isWriter(view: EditorView, path: string): boolean {
|
||||
const editors = editorsFor(path);
|
||||
if (editors.length <= 1) return true;
|
||||
const active = useEditorRegistry.getState().active;
|
||||
const writer = editors.find((e) => e === active) ?? editors[0];
|
||||
return writer?.view === view;
|
||||
}
|
||||
|
||||
function lineAtEvent(view: EditorView, e: MouseEvent): number | null {
|
||||
const block = view.lineBlockAtHeight(e.clientY - view.documentTop);
|
||||
if (!block) return null;
|
||||
return view.state.doc.lineAt(block.from).number - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like IDEA, the whole gutter left of the fold column toggles breakpoints:
|
||||
* line numbers plus the empty space of the other gutters (their own markers,
|
||||
* e.g. run icons or change bars, keep their clicks).
|
||||
*/
|
||||
function inBreakpointArea(e: MouseEvent): boolean {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter, .cm-vcs-blame")) return false;
|
||||
if (t.closest(".cm-lineNumbers")) return true;
|
||||
if (t.classList.contains("cm-gutter")) return true;
|
||||
return t.classList.contains("cm-gutterElement") && t.childElementCount === 0;
|
||||
}
|
||||
|
||||
export function breakpointGutter(ctx: EditorDocContext): Extension {
|
||||
const path = ctx.path;
|
||||
const field = StateField.define<RangeSet<BreakpointMarker>>({
|
||||
create: (state) => buildMarkers(state, path),
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
|
||||
return tr.docChanged ? value.map(tr.changes) : value;
|
||||
},
|
||||
provide: (f) => [
|
||||
lineNumberMarkers.from(f),
|
||||
// IDEA tints the whole line of an active breakpoint.
|
||||
EditorView.decorations.from(f, (markers) => {
|
||||
const lines: Array<ReturnType<Decoration["range"]>> = [];
|
||||
for (let it = markers.iter(); it.value; it.next())
|
||||
if (!DISABLED_LOOKS.has(it.value.look)) lines.push(bpLine.range(it.from));
|
||||
return Decoration.set(lines);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
private unsubs: Array<() => void>;
|
||||
private pending = false;
|
||||
private destroyed = false;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.unsubs = [
|
||||
useBreakpoints.subscribe((s, prev) => {
|
||||
if (s.byFile[path] !== prev.byFile[path] || s.status !== prev.status || s.muted !== prev.muted)
|
||||
this.scheduleRebuild();
|
||||
}),
|
||||
useDebug.subscribe((s, prev) => {
|
||||
if (s.sessions !== prev.sessions) this.scheduleRebuild();
|
||||
}),
|
||||
useSpecialBreakpoints.subscribe((s, prev) => {
|
||||
if (s.methods !== prev.methods || s.fields !== prev.fields || s.status !== prev.status)
|
||||
this.scheduleRebuild();
|
||||
}),
|
||||
];
|
||||
view.dom.addEventListener("mousedown", this.onMouseDown);
|
||||
view.dom.addEventListener("contextmenu", this.onContextMenu);
|
||||
}
|
||||
|
||||
scheduleRebuild() {
|
||||
if (this.pending) return;
|
||||
this.pending = true;
|
||||
queueMicrotask(() => {
|
||||
this.pending = false;
|
||||
if (this.destroyed) return;
|
||||
const next = buildMarkers(this.view.state, path);
|
||||
const cur = this.view.state.field(field);
|
||||
if (!RangeSet.eq([cur], [next])) this.view.dispatch({ effects: setMarkers.of(next) });
|
||||
});
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
if (!u.docChanged) return;
|
||||
// Split panes mirror one document: only the originating view moves the stored lines.
|
||||
const mirrored = u.transactions.some((tr) => tr.annotation(mirroredChange));
|
||||
const remote = u.transactions.some((tr) => tr.annotation(remoteChange));
|
||||
if (!mirrored && (!remote || isWriter(u.view, path))) {
|
||||
const list = breakpointsOf(path);
|
||||
if (list.length) setFileBreakpoints(path, mapLines(list, u.startState.doc, u.changes, u.state.doc));
|
||||
const special = specialAt(path).map((b) => ({ id: b.id, line: b.line! }));
|
||||
if (special.length) setSpecialLines(path, mapLines(special, u.startState.doc, u.changes, u.state.doc));
|
||||
} else {
|
||||
this.scheduleRebuild();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0 || !inBreakpointArea(e)) return;
|
||||
const line = lineAtEvent(this.view, e);
|
||||
if (line == null) return;
|
||||
if (!canPlaceBreakpoint(ctx.language) && !breakpointsOf(path).some((b) => b.line === line)) return;
|
||||
e.preventDefault();
|
||||
const special = specialAt(path).find((b) => b.line === line);
|
||||
if (special) {
|
||||
if (e.altKey) updateSpecial(special.id, { enabled: !special.enabled });
|
||||
else removeSpecial(special.id);
|
||||
return;
|
||||
}
|
||||
if (e.altKey) {
|
||||
// ⌥-click enables/disables instead of removing.
|
||||
const bp = breakpointsOf(path).find((b) => b.line === line);
|
||||
if (bp) updateBreakpoint(path, bp.id, { enabled: !bp.enabled });
|
||||
else addBreakpoint(path, line);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
// ⇧-click: logging breakpoint that doesn't suspend.
|
||||
const bp = breakpointsOf(path).find((b) => b.line === line) ?? addBreakpoint(path, line);
|
||||
openBreakpointPopover(path, bp.id, { x: e.clientX, y: e.clientY }, true);
|
||||
return;
|
||||
}
|
||||
toggleBreakpointSmart(path, line, ctx.language, this.view.state.doc.toString());
|
||||
};
|
||||
|
||||
onContextMenu = (e: MouseEvent) => {
|
||||
if (!inBreakpointArea(e)) return;
|
||||
const line = lineAtEvent(this.view, e);
|
||||
if (line == null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const at = { x: e.clientX, y: e.clientY };
|
||||
const bp = specialAt(path).find((b) => b.line === line) ?? breakpointsOf(path).find((b) => b.line === line);
|
||||
if (bp) {
|
||||
openBreakpointPopover(path, bp.id, at);
|
||||
return;
|
||||
}
|
||||
if (!canPlaceBreakpoint(ctx.language)) return;
|
||||
const text = this.view.state.doc.toString();
|
||||
const method = methodAt(ctx.language, text, line);
|
||||
const field = fieldAt(ctx.language, text, line);
|
||||
showContextMenu(at, [
|
||||
{ label: "Toggle Line Breakpoint", icon: "breakpoint", run: () => addBreakpoint(path, line) },
|
||||
{
|
||||
label: "Toggle Temporary Line Breakpoint",
|
||||
icon: "breakpoint",
|
||||
run: () => toggleTemporaryBreakpoint(path, line),
|
||||
},
|
||||
...(method
|
||||
? [
|
||||
{
|
||||
label: `Toggle Method Breakpoint (${method.label})`,
|
||||
icon: "breakpoint-method",
|
||||
run: () => void toggleMethodAt(path, line, method.name),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(field
|
||||
? [
|
||||
{
|
||||
label: `Toggle Field Watchpoint (${field.label})`,
|
||||
icon: "breakpoint-field",
|
||||
run: () => void toggleFieldAt(path, line, field),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Add Conditional Breakpoint…",
|
||||
run: () => openBreakpointPopover(path, addBreakpoint(path, line).id, at),
|
||||
},
|
||||
{
|
||||
label: "Add Logpoint…",
|
||||
run: () => openBreakpointPopover(path, addBreakpoint(path, line).id, at, true),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.unsubs.forEach((u) => u());
|
||||
this.view.dom.removeEventListener("mousedown", this.onMouseDown);
|
||||
this.view.dom.removeEventListener("contextmenu", this.onContextMenu);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
field,
|
||||
plugin,
|
||||
EditorView.theme({
|
||||
".cm-lineNumbers .cm-gutterElement": { cursor: "default" },
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Line-number gutter element class; the marker is a pseudo-element that, like
|
||||
* IDEA, replaces the number (centred on the element's content box).
|
||||
*/
|
||||
.bp {
|
||||
position: relative;
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
.bp::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(50% + 1px);
|
||||
top: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.normal::before {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* Disabled / muted: hollow circle. */
|
||||
.disabled::before {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--danger);
|
||||
}
|
||||
|
||||
/* Not bound by the adapter. */
|
||||
.unverified::before {
|
||||
background: var(--fg-gutter);
|
||||
}
|
||||
|
||||
/* Condition / hit count: dot with a small notch. */
|
||||
.conditional::before {
|
||||
background: radial-gradient(circle at 50% 50%, var(--bg-app) 0 0.16em, var(--danger) 0.18em);
|
||||
}
|
||||
|
||||
/* Logpoint: diamond. */
|
||||
.log::before {
|
||||
border-radius: 1.5px;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
/* Bound by the debugger in a running session (expui breakpointValid). */
|
||||
.verified::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointValid.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .verified::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointValid_dark.svg");
|
||||
}
|
||||
|
||||
/* Method breakpoints / field watchpoints (expui icons). */
|
||||
.method::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointMethod.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .method::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointMethod_dark.svg");
|
||||
}
|
||||
|
||||
.methodDisabled::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointMethodDisabled.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .methodDisabled::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointMethodDisabled_dark.svg");
|
||||
}
|
||||
|
||||
.methodVerified::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointMethodValid.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .methodVerified::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointMethodValid_dark.svg");
|
||||
}
|
||||
|
||||
.field::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointField.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .field::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointField_dark.svg");
|
||||
}
|
||||
|
||||
.fieldDisabled::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointFieldDisabled.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .fieldDisabled::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointFieldDisabled_dark.svg");
|
||||
}
|
||||
|
||||
.fieldVerified::before {
|
||||
border-radius: 0;
|
||||
background: url("../../../ui/expui/breakpoints/breakpointFieldValid.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
:global(:root[data-theme^="dark"]) .fieldVerified::before {
|
||||
background-image: url("../../../ui/expui/breakpoints/breakpointFieldValid_dark.svg");
|
||||
}
|
||||
|
||||
/* Beat the caret row (`.cm-activeLine`), as IDEA does; the suspended line wins over a breakpoint. */
|
||||
:global(.cm-editor) :global(.cm-line).bpLine {
|
||||
background: var(--bg-breakpoint-line);
|
||||
}
|
||||
|
||||
:global(.cm-editor) :global(.cm-line).execLine {
|
||||
background: var(--bg-debug-line);
|
||||
}
|
||||
|
||||
.inlineValue {
|
||||
margin-left: 24px;
|
||||
color: var(--syn-inline-value);
|
||||
font-size: var(--fs-ui);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
/* Data Views › Show value tooltip on mouse hover. */
|
||||
.valueTooltip {
|
||||
max-width: 520px;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.valueName {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.valueType {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Execution point in the editor: the suspended line (background only, as in
|
||||
* IDEA) and inline values of the frame's locals after line ends.
|
||||
*/
|
||||
import { StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import type { EditorDocContext } from "@/editor/api";
|
||||
import { useExecutionPoint } from "../state";
|
||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { identifiersIn, inlineValue } from "../values";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
/** How far above the execution point inline values are shown. */
|
||||
const INLINE_SPAN = 60;
|
||||
const MAX_PER_LINE = 4;
|
||||
|
||||
class InlineValues extends WidgetType {
|
||||
constructor(readonly text: string) {
|
||||
super();
|
||||
}
|
||||
eq(o: InlineValues) {
|
||||
return o.text === this.text;
|
||||
}
|
||||
toDOM() {
|
||||
const span = document.createElement("span");
|
||||
span.className = styles.inlineValue!;
|
||||
span.textContent = this.text;
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface Exec {
|
||||
line: number;
|
||||
top: boolean;
|
||||
}
|
||||
|
||||
const setExec = StateEffect.define<Exec | null>();
|
||||
|
||||
function currentExec(path: string): Exec | null {
|
||||
const p = useExecutionPoint.getState();
|
||||
return p.path === path ? { line: p.line, top: p.top } : null;
|
||||
}
|
||||
|
||||
/** `name: value` pairs for identifiers on `text` that are known locals. */
|
||||
export function inlineValuesFor(text: string, values: Record<string, string>): string | null {
|
||||
const parts: string[] = [];
|
||||
for (const id of identifiersIn(text)) {
|
||||
const v = values[id];
|
||||
if (v === undefined) continue;
|
||||
parts.push(`${id}: ${inlineValue(v, 40)}`);
|
||||
if (parts.length >= MAX_PER_LINE) break;
|
||||
}
|
||||
return parts.length ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
function lineDecorations(state: EditorState, exec: Exec | null): DecorationSet {
|
||||
if (!exec || exec.line >= state.doc.lines) return Decoration.none;
|
||||
const line = state.doc.line(exec.line + 1);
|
||||
return Decoration.set([Decoration.line({ class: styles.execLine }).range(line.from)]);
|
||||
}
|
||||
|
||||
export function executionPoint(ctx: EditorDocContext): Extension {
|
||||
const path = ctx.path;
|
||||
const execField = StateField.define<Exec | null>({
|
||||
create: () => currentExec(path),
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setExec)) return e.value;
|
||||
if (value && tr.docChanged) {
|
||||
const doc = tr.startState.doc;
|
||||
if (value.line >= doc.lines) return value;
|
||||
const pos = tr.changes.mapPos(doc.line(value.line + 1).from, 1);
|
||||
return { ...value, line: tr.state.doc.lineAt(pos).number - 1 };
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.compute([f], (state) => lineDecorations(state, state.field(f))),
|
||||
});
|
||||
|
||||
const inline = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet = Decoration.none;
|
||||
private unsub: () => void;
|
||||
private pending = false;
|
||||
private destroyed = false;
|
||||
private unsubSettings: () => void;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.decorations = this.build();
|
||||
// Settings › Debugger › Data Views › Show values inline.
|
||||
this.unsubSettings = onWorkflowSettingsChange(
|
||||
(s) => s.debugger.inlineValues,
|
||||
() => queueMicrotask(() => !this.destroyed && this.view.dispatch({})),
|
||||
);
|
||||
this.unsub = useExecutionPoint.subscribe(() => {
|
||||
if (this.pending) return;
|
||||
this.pending = true;
|
||||
queueMicrotask(() => {
|
||||
this.pending = false;
|
||||
if (this.destroyed) return;
|
||||
const next = currentExec(path);
|
||||
const cur = this.view.state.field(execField);
|
||||
const moved = cur?.line !== next?.line || cur?.top !== next?.top || !!cur !== !!next;
|
||||
// An empty transaction still rebuilds the inline values (locals arrive after the frame).
|
||||
this.view.dispatch(moved ? { effects: setExec.of(next) } : {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
update(u: ViewUpdate) {
|
||||
const changedExec = u.startState.field(execField) !== u.state.field(execField);
|
||||
if (u.docChanged || u.viewportChanged || changedExec || u.transactions.length) this.decorations = this.build();
|
||||
}
|
||||
|
||||
build(): DecorationSet {
|
||||
const exec = this.view.state.field(execField);
|
||||
if (!workflowSettings().debugger.inlineValues) return Decoration.none;
|
||||
const values = useExecutionPoint.getState().values;
|
||||
if (!exec || !Object.keys(values).length) return Decoration.none;
|
||||
const doc = this.view.state.doc;
|
||||
const first = Math.max(0, exec.line - INLINE_SPAN);
|
||||
const widgets = [];
|
||||
for (const { from, to } of this.view.visibleRanges) {
|
||||
const startLine = Math.max(doc.lineAt(from).number - 1, first);
|
||||
const endLine = Math.min(doc.lineAt(to).number - 1, exec.line);
|
||||
for (let l = startLine; l <= endLine; l++) {
|
||||
const line = doc.line(l + 1);
|
||||
const text = inlineValuesFor(line.text, values);
|
||||
if (text) widgets.push(Decoration.widget({ widget: new InlineValues(text), side: 1 }).range(line.to));
|
||||
}
|
||||
}
|
||||
return Decoration.set(widgets, true);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.unsub();
|
||||
this.unsubSettings();
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
|
||||
return [execField, inline];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Settings › Debugger › Data Views › "Show value tooltip on mouse hover":
|
||||
* while suspended, hovering an expression (or the selection under the mouse)
|
||||
* evaluates it in the selected frame.
|
||||
*/
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { hoverTooltip, type EditorView } from "@codemirror/view";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { activeSession, evaluate } from "../state";
|
||||
import { inlineValue } from "../values";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
const BASE_HOVER = 100;
|
||||
const IDENT = /[\p{L}\p{N}_$]/u;
|
||||
|
||||
/** Identifier (with `a.b.c` member access) around `pos`, or the selection containing it. */
|
||||
export function expressionAt(view: EditorView, pos: number): { from: number; to: number; text: string } | null {
|
||||
const { state } = view;
|
||||
const sel = state.selection.main;
|
||||
if (!sel.empty && pos >= sel.from && pos <= sel.to) {
|
||||
const text = state.sliceDoc(sel.from, sel.to).trim();
|
||||
return text && !text.includes("\n") ? { from: sel.from, to: sel.to, text } : null;
|
||||
}
|
||||
const line = state.doc.lineAt(pos);
|
||||
const t = line.text;
|
||||
let start = pos - line.from;
|
||||
let end = start;
|
||||
if (!IDENT.test(t[end] ?? "") && !IDENT.test(t[start - 1] ?? "")) return null;
|
||||
while (end < t.length && IDENT.test(t[end]!)) end++;
|
||||
while (start > 0 && (IDENT.test(t[start - 1]!) || (t[start - 1] === "." && IDENT.test(t[start - 2] ?? "")))) start--;
|
||||
const text = t.slice(start, end);
|
||||
if (!text || /^\d/.test(text)) return null;
|
||||
return { from: line.from + start, to: line.from + end, text };
|
||||
}
|
||||
|
||||
export function valueTooltip(): Extension {
|
||||
return hoverTooltip(
|
||||
async (view, pos) => {
|
||||
const dbg = workflowSettings().debugger;
|
||||
if (!dbg.valueTooltip) return null;
|
||||
const session = activeSession();
|
||||
if (!session || session.status !== "paused") return null;
|
||||
const expr = expressionAt(view, pos);
|
||||
if (!expr) return null;
|
||||
if (dbg.valueTooltipDelay > BASE_HOVER) await new Promise((r) => setTimeout(r, dbg.valueTooltipDelay - BASE_HOVER));
|
||||
let value;
|
||||
try {
|
||||
value = await evaluate(expr.text, "hover", session);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pos: expr.from,
|
||||
end: expr.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = styles.valueTooltip!;
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.valueName!;
|
||||
name.textContent = `${expr.text} = `;
|
||||
dom.append(name, inlineValue(value.value, 400));
|
||||
if (value.typeName) {
|
||||
const type = document.createElement("span");
|
||||
type.className = styles.valueType!;
|
||||
type.textContent = ` {${value.typeName}}`;
|
||||
dom.append(type);
|
||||
}
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
{ hoverTime: BASE_HOVER },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Frame list presentation: library frames collapse into "… N library frames hidden". */
|
||||
import type { StackFrame } from "@/platform/ipc";
|
||||
|
||||
const LIBRARY_PATH =
|
||||
/\/(node_modules|site-packages|dist-packages|\.cargo\/registry|\.rustup|rustc\/[0-9a-f]+|go\/pkg\/mod|\.m2\/repository|\.gradle\/caches)\/|^\/(usr|opt|Library|System)\/|<node_internals>/;
|
||||
|
||||
export function isLibraryFrame(frame: StackFrame, root: string | null): boolean {
|
||||
if (frame.presentationHint === "subtle" || frame.presentationHint === "deemphasize") return true;
|
||||
if (!frame.path) return true;
|
||||
if (LIBRARY_PATH.test(frame.path)) return true;
|
||||
return !!root && !frame.path.startsWith(root + "/");
|
||||
}
|
||||
|
||||
export type FrameItem =
|
||||
{ kind: "frame"; frame: StackFrame; library: boolean } | { kind: "hidden"; count: number; key: string };
|
||||
|
||||
/**
|
||||
* Groups runs of library frames. The selected frame and the first frame are
|
||||
* always shown; `showAll` disables collapsing.
|
||||
*/
|
||||
export function groupFrames(
|
||||
frames: readonly StackFrame[],
|
||||
root: string | null,
|
||||
selected: number | null,
|
||||
showAll: boolean,
|
||||
): FrameItem[] {
|
||||
const out: FrameItem[] = [];
|
||||
let run: StackFrame[] = [];
|
||||
const flush = () => {
|
||||
if (!run.length) return;
|
||||
if (run.length === 1) out.push({ kind: "frame", frame: run[0]!, library: true });
|
||||
else out.push({ kind: "hidden", count: run.length, key: `hidden:${run[0]!.id}` });
|
||||
run = [];
|
||||
};
|
||||
frames.forEach((f, i) => {
|
||||
const library = isLibraryFrame(f, root);
|
||||
if (!library || showAll || i === 0 || f.id === selected) {
|
||||
flush();
|
||||
out.push({ kind: "frame", frame: f, library });
|
||||
} else {
|
||||
run.push(f);
|
||||
}
|
||||
});
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `create:22` — frame name (last segment) and 1-based line. */
|
||||
export function frameLabel(f: StackFrame): string {
|
||||
return f.line >= 0 && f.path ? `${f.name}:${f.line + 1}` : f.name;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* HotSwap (IDEA: Run › Debugging Actions › Reload Changed Classes) through
|
||||
* java-debug's hot code replace: compile with jdtls, then `redefineClasses`
|
||||
* in every Java debug session. When jdtls finishes a build on its own (after
|
||||
* a save) java-debug reports `BUILD_COMPLETE` and, like IDEA's default
|
||||
* "Reload classes after compilation: Ask", we offer the reload.
|
||||
*/
|
||||
import { call, commands, isTauri, type DebugEvent, type DebugSessionId } from "@/platform/ipc";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { isLive, useDebug, type DebugSession } from "./state";
|
||||
|
||||
const KEY = "debug.hotswap";
|
||||
/** `BUILD_COMPLETE` events of our own build are not worth a prompt. */
|
||||
let quietUntil = 0;
|
||||
|
||||
export function hotSwapSessions(): DebugSession[] {
|
||||
return Object.values(useDebug.getState().sessions).filter((s) => isLive(s) && !!s.caps?.hotCodeReplace);
|
||||
}
|
||||
|
||||
function plural(n: number, word: string) {
|
||||
return `${n} ${word}${n === 1 ? "" : "es"}`;
|
||||
}
|
||||
|
||||
/** Message for the reload result (IDEA wording). */
|
||||
export function hotSwapMessage(changed: number, errors: string[]): { level: "info" | "error"; title: string; body?: string } {
|
||||
if (errors.length) return { level: "error", title: "Hot Swap failed", body: errors.join("\n") };
|
||||
if (changed === 0) return { level: "info", title: "Loaded classes are up to date. Nothing to reload." };
|
||||
return { level: "info", title: `${plural(changed, "class")} reloaded` };
|
||||
}
|
||||
|
||||
/** Compiles (unless `build` is false) and reloads changed classes into every Java debug session. */
|
||||
export async function reloadChangedClasses(build = true): Promise<void> {
|
||||
const sessions = hotSwapSessions();
|
||||
if (!sessions.length || !isTauri()) {
|
||||
notifications().notify({ group: "run", level: "info", key: KEY, title: "No debug session can reload classes" });
|
||||
return;
|
||||
}
|
||||
quietUntil = Date.now() + 60_000;
|
||||
try {
|
||||
if (build) {
|
||||
try {
|
||||
await maybeEditorService()?.saveAll();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
const modules = new Set(sessions.map((s) => s.launch?.cwd ?? ""));
|
||||
for (const module of modules) {
|
||||
const status = await call(commands.javaBuildWorkspace(module)).catch(() => null);
|
||||
if (status === "failed" || status === "withErrors") {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
key: KEY,
|
||||
title: "Hot Swap failed",
|
||||
body: "Compilation failed: fix the errors and reload again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const changed = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
for (const s of sessions) {
|
||||
try {
|
||||
const r = await call(commands.debugRedefineClasses(s.id));
|
||||
r.changedClasses.forEach((c) => changed.add(c));
|
||||
if (r.errorMessage) errors.push(r.errorMessage);
|
||||
} catch (e) {
|
||||
errors.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
const m = hotSwapMessage(changed.size, errors);
|
||||
notifications().notify({ group: "run", key: KEY, ...m });
|
||||
} finally {
|
||||
quietUntil = Date.now() + 2_000;
|
||||
}
|
||||
}
|
||||
|
||||
/** java-debug's `hotcodereplace` events of a session this window owns. */
|
||||
export function onHotCodeReplace(session: DebugSessionId, event: Extract<DebugEvent, { kind: "hotCodeReplace" }>) {
|
||||
if (!useDebug.getState().sessions[session]) return;
|
||||
switch (event.changeType) {
|
||||
case "BUILD_COMPLETE":
|
||||
if (Date.now() < quietUntil) return;
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
key: KEY,
|
||||
title: "Classes changed",
|
||||
body: "Reload the changed classes into the running debug session?",
|
||||
actions: [{ label: "Reload", run: () => void reloadChangedClasses(false) }],
|
||||
});
|
||||
break;
|
||||
case "ERROR":
|
||||
case "WARNING":
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: event.changeType === "ERROR" ? "error" : "warning",
|
||||
key: KEY,
|
||||
title: "Hot Swap",
|
||||
body: event.message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Debugger: breakpoints (editor gutter + per-project store synced to the
|
||||
* backend), DAP sessions driven by `IdeEvent::Debug`, the Debug tool window,
|
||||
* execution point + inline values, Evaluate / View Breakpoints dialogs.
|
||||
*/
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { valueTooltip } from "./editor/valueTooltip";
|
||||
import { registerEditorContribution } from "@/editor/api";
|
||||
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { registerDebugActions } from "./actions";
|
||||
import { loadBreakpoints } from "./breakpoints/store";
|
||||
import { loadExceptionBreakpoints } from "./breakpoints/exceptions";
|
||||
import { loadSpecialBreakpoints } from "./breakpoints/special";
|
||||
import { breakpointGutter } from "./editor/breakpointGutter";
|
||||
import { executionPoint } from "./editor/execution";
|
||||
import { onHotCodeReplace } from "./hotswap";
|
||||
import { handleDebugEvent, loadWatches, publishContext } from "./state";
|
||||
import { DebuggerOverlays } from "./ui/Overlays";
|
||||
|
||||
export function activate(): () => void {
|
||||
loadWatches();
|
||||
publishContext();
|
||||
loadBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadExceptionBreakpoints(useProject.getState().project?.root ?? null);
|
||||
loadSpecialBreakpoints(useProject.getState().project?.root ?? null);
|
||||
const disposers = [
|
||||
registerDebugActions(),
|
||||
registerToolWindow({
|
||||
id: "debug",
|
||||
title: "Debug",
|
||||
icon: "toolwindow-debug",
|
||||
anchor: "bottom",
|
||||
stripe: "onUse",
|
||||
order: 20,
|
||||
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
|
||||
}),
|
||||
registerOverlay({ id: "debugger", component: DebuggerOverlays }),
|
||||
registerEditorContribution({
|
||||
id: "debugger",
|
||||
order: 60,
|
||||
extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip()],
|
||||
}),
|
||||
onIdeEvent("debug", (e) => {
|
||||
if (e.event.kind === "hotCodeReplace") onHotCodeReplace(e.session, e.event);
|
||||
else handleDebugEvent(e.session, e.event);
|
||||
}),
|
||||
useProject.subscribe((s, prev) => {
|
||||
const root = s.project?.root ?? null;
|
||||
if (root !== (prev.project?.root ?? null)) {
|
||||
loadBreakpoints(root);
|
||||
loadExceptionBreakpoints(root);
|
||||
loadSpecialBreakpoints(root);
|
||||
}
|
||||
}),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Java debugging through Microsoft java-debug, a jdtls bundle: jdtls starts its
|
||||
* DAP server (`vscode.java.startDebugSession`) and the session connects to that
|
||||
* port (`debugServer`). Application configurations are launched by java-debug;
|
||||
* JVMs started by Maven / Gradle with a JDWP agent are attached to.
|
||||
*/
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, IpcError, type LaunchConfig, type RunConfig } from "@/platform/ipc";
|
||||
import { isJvmApplication, joinJavaArgs, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export const JAVA_ADAPTER = "java-debug";
|
||||
|
||||
export function isJavaDebuggable(rc: RunConfig): boolean {
|
||||
return isJvmApplication(rc) && (rc.debugAdapter ?? JAVA_ADAPTER) === JAVA_ADAPTER;
|
||||
}
|
||||
|
||||
function join(base: string, p: string) {
|
||||
if (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)) return p;
|
||||
const rel = p.replace(/^\.\//, "");
|
||||
return rel === "." || rel === "" ? base : `${base.replace(/\/$/, "")}/${rel}`;
|
||||
}
|
||||
|
||||
/** java-debug missing from jdtls: offer the one-click install (jdtls restarts with the bundle). */
|
||||
function offerInstall(e: unknown): never {
|
||||
if (e instanceof IpcError && e.kind === "unavailable" && /java-debug|debugger/i.test(e.message)) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
key: "debug.java-debug-missing",
|
||||
title: "The Java debugger is not installed",
|
||||
body: "Debugging Java needs Microsoft java-debug, loaded into the Java language server. Install it and debug again once jdtls has restarted.",
|
||||
sticky: true,
|
||||
actions: [{ label: "Install", run: () => void executeAction("tools.install", JAVA_ADAPTER) }],
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
async function debugServer(module: string): Promise<number> {
|
||||
try {
|
||||
return await call(commands.javaDebugServer(module));
|
||||
} catch (e) {
|
||||
offerInstall(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch request for an Application configuration (`java … -cp ${classpath} Main args`). */
|
||||
export async function javaLaunchConfig(rc: RunConfig, root: string): Promise<LaunchConfig> {
|
||||
const module = rc.cwd ?? "";
|
||||
const cwd = rc.cwd ? join(root, rc.cwd) : root;
|
||||
const parsed = parseJavaArgs(rc.args);
|
||||
if (!parsed.mainClass) throw new Error(`No main class in '${rc.name}'`);
|
||||
const info = await call(commands.javaResolveLaunch(module, parsed.classpath === "test", true));
|
||||
const resolved = resolveJavaCommand(rc, info);
|
||||
const port = await debugServer(module);
|
||||
const classPaths = parsed.explicitClasspath ? parsed.explicitClasspath.split(/[:;]/) : info.classPaths;
|
||||
return {
|
||||
name: rc.name,
|
||||
adapter: JAVA_ADAPTER,
|
||||
request: "launch",
|
||||
program: null,
|
||||
// java-debug takes `args` / `vmArgs` as strings.
|
||||
args: [],
|
||||
cwd,
|
||||
env: { ...rc.env },
|
||||
extraJson: JSON.stringify({
|
||||
debugServer: port,
|
||||
mainClass: parsed.mainClass,
|
||||
classPaths,
|
||||
modulePaths: info.modulePaths,
|
||||
javaExec: resolved.command === "java" ? undefined : resolved.command,
|
||||
vmArgs: joinJavaArgs(parsed.vmArgs),
|
||||
args: joinJavaArgs(parsed.programArgs),
|
||||
console: "internalConsole",
|
||||
stopOnEntry: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface JavaAttachTarget {
|
||||
name: string;
|
||||
/** Module directory (relative to the project root) whose jdtls resolves sources. */
|
||||
module: string;
|
||||
hostName: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function isJavaAttachTarget(arg: unknown): arg is JavaAttachTarget & { process?: number | null } {
|
||||
return !!arg && typeof arg === "object" && "port" in arg && typeof (arg as { port: unknown }).port === "number";
|
||||
}
|
||||
|
||||
/** Attach request for a JVM listening with a JDWP agent. */
|
||||
export async function javaAttachConfig(target: JavaAttachTarget, root: string): Promise<LaunchConfig> {
|
||||
const port = await debugServer(target.module);
|
||||
return {
|
||||
name: target.name,
|
||||
adapter: JAVA_ADAPTER,
|
||||
request: "attach",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd: target.module ? join(root, target.module) : root,
|
||||
env: {},
|
||||
extraJson: JSON.stringify({ debugServer: port, hostName: target.hostName, port: target.port, timeout: 30_000 }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/** Run configuration → DAP launch configuration, per adapter. */
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import type { LaunchConfig, RunConfig } from "@/platform/ipc";
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** A command path with a directory part resolved against `cwd`; bare names stay for PATH lookup. */
|
||||
function resolveCommand(cmd: string, cwd: string) {
|
||||
return cmd.includes("/") ? join(cwd, cmd) : cmd;
|
||||
}
|
||||
|
||||
function splitAtDashDash(args: readonly string[]): [string[], string[]] {
|
||||
const i = args.indexOf("--");
|
||||
return i < 0 ? [[...args], []] : [args.slice(0, i), args.slice(i + 1)];
|
||||
}
|
||||
|
||||
function base(rc: RunConfig, adapter: string, cwd: string): LaunchConfig {
|
||||
return {
|
||||
name: rc.name,
|
||||
adapter,
|
||||
request: "launch",
|
||||
program: null,
|
||||
args: [],
|
||||
cwd,
|
||||
env: { ...rc.env },
|
||||
extraJson: null,
|
||||
};
|
||||
}
|
||||
|
||||
function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
||||
const [cargoArgs, programArgs] = splitAtDashDash(rc.args);
|
||||
const [sub, ...rest] = cargoArgs;
|
||||
let args: string[];
|
||||
let filter: { name?: string; kind?: string } | undefined;
|
||||
if (sub === "test" || sub === "nextest") {
|
||||
args = ["test", "--no-run", ...rest.filter((a) => a !== "--no-run")];
|
||||
} else {
|
||||
args = ["build", ...rest];
|
||||
const bin = rest.indexOf("--bin");
|
||||
if (bin >= 0 && rest[bin + 1]) filter = { name: rest[bin + 1], kind: "bin" };
|
||||
const example = rest.indexOf("--example");
|
||||
if (example >= 0 && rest[example + 1]) filter = { name: rest[example + 1], kind: "example" };
|
||||
if (!filter) filter = { kind: "bin" };
|
||||
}
|
||||
return {
|
||||
...base(rc, adapter, cwd),
|
||||
args: programArgs,
|
||||
extraJson: JSON.stringify({ cargo: filter ? { args, filter } : { args }, sourceLanguages: ["rust"] }),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
// 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";
|
||||
}
|
||||
|
||||
function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const out = base(rc, "debugpy", cwd);
|
||||
const dbg = workflowSettings().debugger;
|
||||
const extra: Record<string, unknown> = {
|
||||
python: pythonFor(rc, cwd),
|
||||
console: "internalConsole",
|
||||
justMyCode: dbg.skipLibraryCode,
|
||||
subProcess: dbg.python.attachSubprocess,
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
out.extraJson = JSON.stringify(extra);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Replaced by the backend with one free local port per launch (`ide-dap` `FREE_PORT_PLACEHOLDER`). */
|
||||
const FREE_PORT = "${freePort}";
|
||||
|
||||
/** Deno subcommands that run code and take `--inspect-wait`. */
|
||||
const DENO_RUNS = new Set(["run", "test", "bench", "serve", "eval"]);
|
||||
|
||||
/**
|
||||
* `deno run …` (and test / bench / serve / eval) under js-debug: Deno waits on its V8
|
||||
* inspector at a free port and js-debug attaches to it (`attachSimplePort`), like the
|
||||
* VS Code Deno extension. `null` for subcommands that can't take the flag (`deno task`).
|
||||
*/
|
||||
function denoRuntime(rc: RunConfig): Record<string, unknown> | null {
|
||||
const [sub, ...rest] = rc.args;
|
||||
if (!sub || !DENO_RUNS.has(sub)) return null;
|
||||
const flags = rest.filter((a) => !/^--inspect(-brk|-wait)?(=|$)/.test(a));
|
||||
return {
|
||||
runtimeExecutable: rc.command,
|
||||
runtimeArgs: [sub, `--inspect-wait=127.0.0.1:${FREE_PORT}`, ...flags],
|
||||
attachSimplePort: FREE_PORT,
|
||||
};
|
||||
}
|
||||
|
||||
function commandName(cmd: string) {
|
||||
return (cmd.split(/[\\/]/).pop() ?? cmd).replace(/\.(exe|cmd)$/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `rc` can't be debugged even though it names a debugger, or null when it can.
|
||||
* Bun's inspector speaks the WebKit Inspector protocol, which js-debug can't attach to.
|
||||
*/
|
||||
export function debugUnsupportedReason(rc: RunConfig): string | null {
|
||||
const name = commandName(rc.command);
|
||||
if (rc.kind === "bun" || (name === "bun" && rc.kind !== "npm"))
|
||||
return (
|
||||
"Bun's inspector speaks the WebKit Inspector protocol, which the JavaScript debugger (vscode-js-debug) " +
|
||||
"can't attach to. Run it with `bun --inspect` and open the printed debug.bun.sh link, or debug the file with Node.js or Deno."
|
||||
);
|
||||
if (rc.kind === "deno" || name === "deno") {
|
||||
if (!denoRuntime(rc))
|
||||
return `\`deno ${rc.args[0] ?? ""}\` can't be started with the debugger. Debug a \`deno run\` or \`deno test\` configuration instead.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsDebugLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const out = base(rc, "js-debug", cwd);
|
||||
const extra: Record<string, unknown> = {
|
||||
type: "pwa-node",
|
||||
console: "internalConsole",
|
||||
outputCapture: "std",
|
||||
skipFiles: workflowSettings().debugger.skipLibraryCode ? ["<node_internals>/**", "**/node_modules/**"] : [],
|
||||
autoAttachChildProcesses: true,
|
||||
};
|
||||
const name = commandName(rc.command);
|
||||
const deno = name === "deno" ? denoRuntime(rc) : null;
|
||||
if (deno) {
|
||||
Object.assign(extra, deno);
|
||||
} else if (name === "node" && rc.args[0] && !rc.args[0].startsWith("-")) {
|
||||
out.program = join(cwd, rc.args[0]);
|
||||
out.args = rc.args.slice(1);
|
||||
// The Node.js the run configuration picked (settings / version manager), not PATH's.
|
||||
if (rc.command !== name) extra.runtimeExecutable = resolveCommand(rc.command, cwd);
|
||||
} else {
|
||||
extra.runtimeExecutable = rc.command;
|
||||
extra.runtimeArgs = [...rc.args];
|
||||
}
|
||||
out.extraJson = JSON.stringify(extra);
|
||||
return out;
|
||||
}
|
||||
|
||||
function delveLaunch(rc: RunConfig, cwd: string): LaunchConfig {
|
||||
const out = base(rc, "delve", cwd);
|
||||
const [sub, ...rest] = rc.args;
|
||||
const [goArgs, programArgs] = splitAtDashDash(rest);
|
||||
if (sub === "test") {
|
||||
// `go test` selection flags become the test binary's (`-run ^TestX$` → `-test.run ^TestX$`).
|
||||
const testFlags: string[] = [];
|
||||
let pkg: string | undefined;
|
||||
for (let i = 0; i < goArgs.length; i++) {
|
||||
const a = goArgs[i]!;
|
||||
const flag = /^--?(run|skip)(?:=(.*))?$/.exec(a);
|
||||
if (flag) {
|
||||
const value = flag[2] ?? goArgs[++i];
|
||||
if (value !== undefined) testFlags.push(`-test.${flag[1]}`, value);
|
||||
} else if (!a.startsWith("-")) pkg ??= a;
|
||||
}
|
||||
pkg ??= ".";
|
||||
out.program = pkg.endsWith("/...") ? cwd : join(cwd, pkg);
|
||||
out.extraJson = JSON.stringify({ mode: "test" });
|
||||
out.args = [...testFlags, ...programArgs];
|
||||
} else {
|
||||
const flags = goArgs.filter((a) => a.startsWith("-"));
|
||||
const positional = goArgs.filter((a) => !a.startsWith("-"));
|
||||
const target = positional[0] ?? ".";
|
||||
out.program = join(cwd, target);
|
||||
out.args = [...positional.slice(1), ...programArgs];
|
||||
out.extraJson = JSON.stringify(flags.length ? { mode: "debug", buildFlags: flags.join(" ") } : { mode: "debug" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
|
||||
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
|
||||
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
|
||||
// lldb-dap takes the environment as `KEY=VALUE` strings.
|
||||
if (adapter === "lldb-dap")
|
||||
out.extraJson = JSON.stringify({ env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`) });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch configuration that debugs `rc`, or `null` when it has no adapter.
|
||||
* `root` is the project root (default working directory).
|
||||
*/
|
||||
export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | null {
|
||||
const adapter = rc.debugAdapter;
|
||||
if (!adapter || debugUnsupportedReason(rc)) return null;
|
||||
const cwd = rc.cwd ? join(root, rc.cwd) : root;
|
||||
if (adapter === "codelldb" || adapter === "lldb-dap") {
|
||||
return rc.kind === "cargo" ? cargoLaunch(rc, cwd, adapter) : nativeLaunch(rc, cwd, adapter);
|
||||
}
|
||||
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
|
||||
if (adapter === "js-debug") return jsDebugLaunch(rc, cwd);
|
||||
if (adapter === "delve") return delveLaunch(rc, cwd);
|
||||
// Adapters without a template (hidden in Edit Configurations): generic arguments.
|
||||
return {
|
||||
...base(rc, adapter, cwd),
|
||||
program: resolveCommand(rc.command, cwd),
|
||||
args: [...rc.args],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* A debug adapter that is not installed: name it and offer the one-click install
|
||||
* (ide-tools) followed by the same launch again, instead of a bare error.
|
||||
*/
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
|
||||
/** Adapters the tools installer can provide (adapter id → tool name). */
|
||||
const INSTALLABLE: Record<string, { tool: string; label: string }> = {
|
||||
debugpy: { tool: "debugpy", label: "debugpy" },
|
||||
"js-debug": { tool: "js-debug", label: "the JavaScript debugger (vscode-js-debug)" },
|
||||
delve: { tool: "delve", label: "Delve" },
|
||||
codelldb: { tool: "codelldb", label: "CodeLLDB" },
|
||||
};
|
||||
|
||||
export interface MissingAdapter {
|
||||
tool: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** The install offer for a failed start, or null when the failure is something else. */
|
||||
export function missingAdapter(adapter: string, e: unknown): MissingAdapter | null {
|
||||
const entry = INSTALLABLE[adapter];
|
||||
if (!entry || !(e instanceof IpcError) || e.kind !== "unavailable") return null;
|
||||
if (adapter === "debugpy" && e.message.startsWith("debugpy is not installed")) {
|
||||
return {
|
||||
tool: entry.tool,
|
||||
title: "debugpy is not installed",
|
||||
body: `${e.message}. OpenIDEA can install a private copy that every Python interpreter uses for debugging.`,
|
||||
};
|
||||
}
|
||||
if (!e.message.startsWith(`debug adapter \`${adapter}\` not found`)) return null;
|
||||
return {
|
||||
tool: entry.tool,
|
||||
title: `${entry.label[0]!.toUpperCase()}${entry.label.slice(1)} is not installed`,
|
||||
body: e.message,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Debug sessions as the UI sees them, fed by `IdeEvent::Debug` and by pulls
|
||||
* (threads, frames, locals) after each stop.
|
||||
*/
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { create } from "zustand";
|
||||
import { setContext } from "@/platform/context";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
isTauri,
|
||||
type DebugCapabilities,
|
||||
type DebugEvent,
|
||||
type DebugSessionId,
|
||||
type LaunchConfig,
|
||||
type RunId,
|
||||
type StackFrame,
|
||||
type ThreadInfo,
|
||||
type Variable,
|
||||
} from "@/platform/ipc";
|
||||
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { consumeTemporary, refreshStates, resetStatuses } from "./breakpoints/store";
|
||||
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
|
||||
import { refreshExceptionFilters } from "./breakpoints/exceptions";
|
||||
import { missingAdapter } from "./missingAdapter";
|
||||
|
||||
export type SessionStatus = "starting" | "running" | "paused" | "terminated";
|
||||
|
||||
export interface DebugSession {
|
||||
id: DebugSessionId;
|
||||
name: string;
|
||||
adapter: string;
|
||||
parent: DebugSessionId | null;
|
||||
/** Launch configuration (root sessions), used by Rerun. */
|
||||
launch: LaunchConfig | null;
|
||||
/** Process the session is attached to (a Run tab's build tool); stopped with the session. */
|
||||
process: RunId | null;
|
||||
status: SessionStatus;
|
||||
stoppedThread: number | null;
|
||||
stopReason: string | null;
|
||||
stopDescription: string | null;
|
||||
threads: ThreadInfo[];
|
||||
selectedThread: number | null;
|
||||
frames: StackFrame[];
|
||||
selectedFrame: number | null;
|
||||
/** Breakpoint hits in this session. */
|
||||
hits: number;
|
||||
exitCode: number | null;
|
||||
/** What the adapter supports (fetched after start). */
|
||||
caps: DebugCapabilities | null;
|
||||
}
|
||||
|
||||
export interface ConsoleLine {
|
||||
id: number;
|
||||
category: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface WatchResult {
|
||||
expression: string;
|
||||
result: Variable | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface DebugState {
|
||||
sessions: Record<number, DebugSession>;
|
||||
/** Session shown in the Debug tool window / title bar. */
|
||||
active: DebugSessionId | null;
|
||||
console: Record<number, ConsoleLine[]>;
|
||||
watches: string[];
|
||||
/** Incremented on every stop; views re-pull data when it changes. */
|
||||
stopSeq: number;
|
||||
}
|
||||
|
||||
export const useDebug = create<DebugState>(() => ({
|
||||
sessions: {},
|
||||
active: null,
|
||||
console: {},
|
||||
watches: [],
|
||||
stopSeq: 0,
|
||||
}));
|
||||
|
||||
/** Execution point of the selected frame, plus the frame's locals for inline values. */
|
||||
interface ExecutionPoint {
|
||||
path: string | null;
|
||||
line: number;
|
||||
/** The selected frame is the top frame (current position, not a caller). */
|
||||
top: boolean;
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export const useExecutionPoint = create<ExecutionPoint>(() => ({ path: null, line: 0, top: true, values: {} }));
|
||||
|
||||
const MAX_CONSOLE_LINES = 5000;
|
||||
let lineSeq = 0;
|
||||
|
||||
// --- accessors ---------------------------------------------------------------
|
||||
|
||||
export function activeSession(): DebugSession | null {
|
||||
const { active, sessions } = useDebug.getState();
|
||||
return active != null ? (sessions[active] ?? null) : null;
|
||||
}
|
||||
|
||||
export function isLive(s: DebugSession | null | undefined): boolean {
|
||||
return !!s && s.status !== "terminated";
|
||||
}
|
||||
|
||||
/** Root (user-started) session of `s`. */
|
||||
export function rootOf(s: DebugSession): DebugSession {
|
||||
const { sessions } = useDebug.getState();
|
||||
let cur = s;
|
||||
while (cur.parent != null && sessions[cur.parent]) cur = sessions[cur.parent]!;
|
||||
return cur;
|
||||
}
|
||||
|
||||
function patch(id: DebugSessionId, p: Partial<DebugSession>) {
|
||||
useDebug.setState((s) => {
|
||||
const cur = s.sessions[id];
|
||||
if (!cur) return s;
|
||||
return { sessions: { ...s.sessions, [id]: { ...cur, ...p } } };
|
||||
});
|
||||
publishContext();
|
||||
}
|
||||
|
||||
function blank(id: DebugSessionId, p: Partial<DebugSession> = {}): DebugSession {
|
||||
return {
|
||||
id,
|
||||
name: "Debug",
|
||||
adapter: "",
|
||||
parent: null,
|
||||
launch: null,
|
||||
process: null,
|
||||
status: "starting",
|
||||
stoppedThread: null,
|
||||
stopReason: null,
|
||||
stopDescription: null,
|
||||
threads: [],
|
||||
selectedThread: null,
|
||||
frames: [],
|
||||
selectedFrame: null,
|
||||
hits: 0,
|
||||
exitCode: null,
|
||||
caps: null,
|
||||
...p,
|
||||
};
|
||||
}
|
||||
|
||||
// Sessions this window owns. The backend sends a session's events only to the window
|
||||
// that started it; as a second line of defence, events of other sessions are dropped.
|
||||
/** `debugStart` calls in flight: their root session's id is not known yet. */
|
||||
let pendingStarts = 0;
|
||||
/** Sessions adopted while a start was in flight, not yet confirmed by its result. */
|
||||
const provisional = new Set<DebugSessionId>();
|
||||
/** Every session id this window has owned (late events of dropped sessions are ignored). */
|
||||
const owned = new Set<DebugSessionId>();
|
||||
|
||||
/** Registers a session as owned by this window (idempotent). */
|
||||
export function adoptSession(id: DebugSessionId, p: Partial<DebugSession> = {}): DebugSession {
|
||||
const existing = useDebug.getState().sessions[id];
|
||||
if (existing) return existing;
|
||||
owned.add(id);
|
||||
const s = blank(id, { status: "running", ...p });
|
||||
void loadCapabilities(id);
|
||||
useDebug.setState((st) => ({
|
||||
sessions: { ...st.sessions, [id]: s },
|
||||
active: st.active == null || !isLive(st.sessions[st.active]) ? id : st.active,
|
||||
}));
|
||||
publishContext();
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session an event belongs to, adopting it when it is this window's: a child
|
||||
* (announced by `started` with a parent) of one of its sessions, or the root
|
||||
* session of a start still in flight here (its build output and `started` arrive
|
||||
* before `debugStart` returns the id). Anything else is not ours.
|
||||
*/
|
||||
function ownSession(id: DebugSessionId, event: DebugEvent): DebugSession | null {
|
||||
const st = useDebug.getState();
|
||||
const existing = st.sessions[id];
|
||||
if (existing) return existing;
|
||||
if (owned.has(id)) return null;
|
||||
if (event.kind === "started" && event.parent != null) {
|
||||
if (!st.sessions[event.parent]) return null;
|
||||
return adoptSession(id, { name: event.name, adapter: event.adapter, parent: event.parent });
|
||||
}
|
||||
if (pendingStarts === 0) return null;
|
||||
provisional.add(id);
|
||||
return event.kind === "started" ? adoptSession(id, { name: event.name, adapter: event.adapter }) : adoptSession(id);
|
||||
}
|
||||
|
||||
/** A start finished (`id` on success): sessions no start claimed are failed starts' leftovers. */
|
||||
function settleStart(id: DebugSessionId | null) {
|
||||
pendingStarts = Math.max(0, pendingStarts - 1);
|
||||
if (id != null) provisional.delete(id);
|
||||
if (pendingStarts > 0) return;
|
||||
const leftovers = [...provisional];
|
||||
provisional.clear();
|
||||
for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s);
|
||||
}
|
||||
|
||||
export function publishContext() {
|
||||
const s = activeSession();
|
||||
const anyLive = Object.values(useDebug.getState().sessions).some(isLive);
|
||||
setContext("debugSessionActive", anyLive);
|
||||
setContext("debugSessionPaused", s?.status === "paused");
|
||||
setContext("debugCanRerun", !!s && !!rootOf(s).launch);
|
||||
setContext(
|
||||
"debugCanHotSwap",
|
||||
Object.values(useDebug.getState().sessions).some((x) => isLive(x) && !!x.caps?.hotCodeReplace),
|
||||
);
|
||||
}
|
||||
|
||||
// --- console -----------------------------------------------------------------
|
||||
|
||||
export function appendConsole(session: DebugSessionId, category: string, text: string) {
|
||||
if (!text) return;
|
||||
useDebug.setState((s) => {
|
||||
const prev = s.console[session] ?? [];
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
// Merge partial lines of the same category.
|
||||
if (last && last.category === category && !last.text.endsWith("\n")) {
|
||||
next[next.length - 1] = { ...last, text: last.text + text };
|
||||
} else {
|
||||
next.push({ id: ++lineSeq, category, text });
|
||||
}
|
||||
if (next.length > MAX_CONSOLE_LINES) next.splice(0, next.length - MAX_CONSOLE_LINES);
|
||||
return { console: { ...s.console, [session]: next } };
|
||||
});
|
||||
}
|
||||
|
||||
export function clearConsole(session: DebugSessionId) {
|
||||
useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } }));
|
||||
}
|
||||
|
||||
// --- lifecycle ---------------------------------------------------------------
|
||||
|
||||
export async function startSession(
|
||||
launch: LaunchConfig,
|
||||
options: {
|
||||
/** A process started for this session (e.g. Maven with a JDWP agent): Stop ends it too. */
|
||||
process?: RunId | null;
|
||||
/** Rerun repeats the launch (false for attaching to a process that is gone afterwards). */
|
||||
rerunnable?: boolean;
|
||||
} = {},
|
||||
): Promise<DebugSessionId | null> {
|
||||
if (!isTauri()) {
|
||||
notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" });
|
||||
return null;
|
||||
}
|
||||
// Drop finished sessions; keep running ones (multiple concurrent sessions are fine).
|
||||
useDebug.setState((s) => {
|
||||
const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v)));
|
||||
const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k]));
|
||||
return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null };
|
||||
});
|
||||
try {
|
||||
if (workflowSettings().run.showOnStart) toolWindowService().show("debug");
|
||||
} catch {
|
||||
// Workbench not ready (tests).
|
||||
}
|
||||
pendingStarts++;
|
||||
try {
|
||||
const id = await call(commands.debugStart(launch));
|
||||
owned.add(id);
|
||||
useDebug.setState((s) => {
|
||||
const cur = s.sessions[id] ?? blank(id, { status: "running" });
|
||||
return {
|
||||
sessions: {
|
||||
...s.sessions,
|
||||
[id]: {
|
||||
...cur,
|
||||
name: launch.name,
|
||||
adapter: launch.adapter,
|
||||
launch: options.rerunnable === false ? null : launch,
|
||||
process: options.process ?? null,
|
||||
status: cur.status === "starting" ? "running" : cur.status,
|
||||
},
|
||||
},
|
||||
active: id,
|
||||
};
|
||||
});
|
||||
settleStart(id);
|
||||
publishContext();
|
||||
void refreshExceptionFilters();
|
||||
void loadCapabilities(id);
|
||||
// Method breakpoints / watchpoints went out during configuration: pull their states.
|
||||
void syncSpecial();
|
||||
return id;
|
||||
} catch (e) {
|
||||
settleStart(null);
|
||||
const missing = missingAdapter(launch.adapter, e);
|
||||
if (missing) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "warning",
|
||||
title: missing.title,
|
||||
body: missing.body,
|
||||
key: "debug.start",
|
||||
sticky: true,
|
||||
actions: [
|
||||
{
|
||||
label: "Install and Debug",
|
||||
run: () => void executeAction("tools.install", missing.tool).then(() => startSession(launch, options)),
|
||||
},
|
||||
],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
title: `Cannot start debugger for ${launch.name}`,
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
key: "debug.start",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopSession(id: DebugSessionId) {
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (!s) return;
|
||||
const root = rootOf(s);
|
||||
try {
|
||||
await call(commands.debugStop(root.id));
|
||||
} catch (e) {
|
||||
// Already gone: mark it finished locally.
|
||||
console.warn("debugStop failed", e);
|
||||
markTerminated(root.id);
|
||||
}
|
||||
// Detaching leaves an attached JVM running: stop the process it belongs to, like IDEA.
|
||||
if (root.process != null) await call(commands.runStop(root.process)).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function rerunSession(id: DebugSessionId) {
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (!s) return;
|
||||
const root = rootOf(s);
|
||||
const launch = root.launch;
|
||||
if (!launch) return;
|
||||
if (isLive(root)) await stopSession(root.id);
|
||||
await startSession(launch);
|
||||
}
|
||||
|
||||
async function loadCapabilities(id: DebugSessionId) {
|
||||
try {
|
||||
const caps = await call(commands.debugCapabilities(id));
|
||||
if (useDebug.getState().sessions[id]) patch(id, { caps });
|
||||
} catch {
|
||||
// Session already gone.
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the active session's adapter supports `cap` (false while unknown). */
|
||||
export function sessionSupports(cap: keyof DebugCapabilities, s: DebugSession | null = activeSession()): boolean {
|
||||
return !!s?.caps?.[cap];
|
||||
}
|
||||
|
||||
function markTerminated(id: DebugSessionId) {
|
||||
patch(id, { status: "terminated", frames: [], selectedFrame: null, stoppedThread: null });
|
||||
const st = useDebug.getState();
|
||||
if (st.active === id) {
|
||||
// Prefer another live session (e.g. the parent of a finished child).
|
||||
const other = Object.values(st.sessions).find(isLive);
|
||||
if (other) useDebug.setState({ active: other.id });
|
||||
}
|
||||
if (!Object.values(useDebug.getState().sessions).some(isLive)) {
|
||||
resetStatuses();
|
||||
useSpecialBreakpoints.setState({ status: {} });
|
||||
if (workflowSettings().debugger.hideOnTermination) {
|
||||
try {
|
||||
toolWindowService().hide("debug");
|
||||
} catch {
|
||||
// Workbench not ready (tests).
|
||||
}
|
||||
}
|
||||
}
|
||||
updateExecutionPoint();
|
||||
publishContext();
|
||||
}
|
||||
|
||||
// --- selection / pulls -------------------------------------------------------
|
||||
|
||||
export function selectSession(id: DebugSessionId) {
|
||||
useDebug.setState({ active: id });
|
||||
publishContext();
|
||||
updateExecutionPoint();
|
||||
}
|
||||
|
||||
export async function selectThread(id: DebugSessionId, threadId: number) {
|
||||
patch(id, { selectedThread: threadId, frames: [], selectedFrame: null });
|
||||
await loadFrames(id, threadId, true);
|
||||
}
|
||||
|
||||
export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) {
|
||||
patch(id, { selectedFrame: frameId });
|
||||
updateExecutionPoint();
|
||||
const s = useDebug.getState().sessions[id];
|
||||
const frame = s?.frames.find((f) => f.id === frameId);
|
||||
if (reveal && frame?.path) {
|
||||
void maybeEditorService()?.openFile(frame.path, {
|
||||
selection: { line: frame.line, col: frame.column },
|
||||
focus: false,
|
||||
});
|
||||
}
|
||||
void loadLocals(id, frameId);
|
||||
}
|
||||
|
||||
async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) {
|
||||
let frames: StackFrame[] = [];
|
||||
try {
|
||||
frames = await call(commands.debugStackTrace(id, threadId));
|
||||
} catch (e) {
|
||||
console.warn("stackTrace failed", e);
|
||||
}
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (!s || s.status !== "paused" || s.selectedThread !== threadId) return;
|
||||
patch(id, { frames });
|
||||
const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0];
|
||||
if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) {
|
||||
consumeTemporary(top.path, top.line);
|
||||
}
|
||||
if (top) selectFrame(id, top.id, reveal);
|
||||
else updateExecutionPoint();
|
||||
}
|
||||
|
||||
async function loadThreads(id: DebugSessionId) {
|
||||
try {
|
||||
const threads = await call(commands.debugThreads(id));
|
||||
patch(id, { threads });
|
||||
} catch (e) {
|
||||
console.warn("threads failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locals of `frameId` (first non-expensive scope) for inline values. */
|
||||
async function loadLocals(id: DebugSessionId, frameId: number) {
|
||||
const values: Record<string, string> = {};
|
||||
try {
|
||||
const scopes = await call(commands.debugScopes(id, frameId));
|
||||
const scope = scopes.find((sc) => !sc.expensive);
|
||||
if (scope) {
|
||||
const vars = await call(commands.debugVariables(id, scope.variablesReference));
|
||||
for (const v of vars) if (v.name && !(v.name in values)) values[v.name] = v.value;
|
||||
}
|
||||
} catch {
|
||||
// No inline values.
|
||||
}
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) return;
|
||||
useExecutionPoint.setState({ values });
|
||||
}
|
||||
|
||||
/** Show Execution Point (⌥F10): back to the suspended thread's top frame, revealed and focused. */
|
||||
export async function showExecutionPoint(id: DebugSessionId) {
|
||||
const s = useDebug.getState().sessions[id];
|
||||
if (!s || s.status !== "paused" || s.stoppedThread == null) return;
|
||||
if (s.selectedThread !== s.stoppedThread) {
|
||||
patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null });
|
||||
await loadFrames(id, s.stoppedThread, false);
|
||||
}
|
||||
const cur = useDebug.getState().sessions[id];
|
||||
const top = cur?.frames.find((f) => f.path && f.presentationHint !== "label");
|
||||
if (!cur || !top) return;
|
||||
selectFrame(id, top.id, false);
|
||||
await revealFrame(top, true);
|
||||
}
|
||||
|
||||
/** Opens a frame's source at its line (Jump to Source, F4). */
|
||||
export async function revealFrame(frame: StackFrame, focus: boolean) {
|
||||
if (!frame.path) return;
|
||||
await maybeEditorService()?.openFile(frame.path, { selection: { line: frame.line, col: frame.column }, focus });
|
||||
}
|
||||
|
||||
export function updateExecutionPoint() {
|
||||
const s = activeSession();
|
||||
const frame = s && s.status === "paused" ? s.frames.find((f) => f.id === s.selectedFrame) : undefined;
|
||||
if (!s || !frame || !frame.path) {
|
||||
if (useExecutionPoint.getState().path !== null) useExecutionPoint.setState({ path: null, values: {} });
|
||||
return;
|
||||
}
|
||||
const top = s.frames.find((f) => f.path && f.presentationHint !== "label")?.id === frame.id;
|
||||
const cur = useExecutionPoint.getState();
|
||||
if (cur.path === frame.path && cur.line === frame.line && cur.top === top) return;
|
||||
useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} });
|
||||
}
|
||||
|
||||
// --- watches -------------------------------------------------------------------
|
||||
|
||||
export function addWatch(expression: string) {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
useDebug.setState((s) => (s.watches.includes(e) ? s : { watches: [...s.watches, e] }));
|
||||
persistWatches();
|
||||
}
|
||||
|
||||
export function removeWatch(expression: string) {
|
||||
useDebug.setState((s) => ({ watches: s.watches.filter((w) => w !== expression) }));
|
||||
persistWatches();
|
||||
}
|
||||
|
||||
const WATCH_KEY = "openidea.debugger.watches";
|
||||
|
||||
function persistWatches() {
|
||||
try {
|
||||
localStorage.setItem(WATCH_KEY, JSON.stringify(useDebug.getState().watches));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function loadWatches() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(WATCH_KEY) ?? "[]") as unknown;
|
||||
if (Array.isArray(raw)) useDebug.setState({ watches: raw.filter((w): w is string => typeof w === "string") });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Set Value" (F2 in the variables tree): `setVariable` in `container`, or
|
||||
* `setExpression` on the evaluate name. Refreshes variables, watches and inline values.
|
||||
*/
|
||||
export async function setVariableValue(
|
||||
target: { container: number; name: string; evaluateName: string | null },
|
||||
value: string,
|
||||
session: DebugSession | null = activeSession(),
|
||||
): Promise<Variable> {
|
||||
if (!session || session.status !== "paused") throw new Error("The debugger is not suspended");
|
||||
const result = await call(
|
||||
commands.debugSetVariable(
|
||||
session.id,
|
||||
target.container,
|
||||
target.name,
|
||||
target.evaluateName,
|
||||
session.selectedFrame,
|
||||
value,
|
||||
),
|
||||
);
|
||||
useDebug.setState((st) => ({ stopSeq: st.stopSeq + 1 }));
|
||||
if (session.selectedFrame != null) void loadLocals(session.id, session.selectedFrame);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function evaluate(
|
||||
expression: string,
|
||||
context: "repl" | "watch" | "hover" | "clipboard",
|
||||
session: DebugSession | null = activeSession(),
|
||||
): Promise<Variable> {
|
||||
if (!session || !isLive(session)) throw new Error("No active debug session");
|
||||
return call(commands.debugEvaluateIn(session.id, session.selectedFrame, expression, context));
|
||||
}
|
||||
|
||||
/** Settings › Debugger › Focus application on breakpoint. */
|
||||
function focusAppWindow() {
|
||||
if (!isTauri()) return;
|
||||
void import("@tauri-apps/api/window")
|
||||
.then(async ({ getCurrentWindow }) => {
|
||||
const w = getCurrentWindow();
|
||||
if (await w.isMinimized()) await w.unminimize();
|
||||
await w.setFocus();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// --- events ----------------------------------------------------------------------
|
||||
|
||||
export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) {
|
||||
const s = ownSession(id, event);
|
||||
if (!s) return;
|
||||
switch (event.kind) {
|
||||
case "started":
|
||||
if (!s.launch) patch(id, { name: event.name, adapter: event.adapter, parent: event.parent });
|
||||
break;
|
||||
case "initialized":
|
||||
if (s.status === "starting") patch(id, { status: "running" });
|
||||
break;
|
||||
case "stopped": {
|
||||
const isBp = event.reason === "breakpoint" || event.reason === "function breakpoint";
|
||||
patch(id, {
|
||||
status: "paused",
|
||||
stoppedThread: event.threadId,
|
||||
selectedThread: event.threadId,
|
||||
stopReason: event.reason,
|
||||
stopDescription: event.description,
|
||||
frames: [],
|
||||
selectedFrame: null,
|
||||
hits: s.hits + (isBp ? 1 : 0),
|
||||
});
|
||||
useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 }));
|
||||
publishContext();
|
||||
void loadThreads(id);
|
||||
void loadFrames(id, event.threadId, true);
|
||||
const dbg = workflowSettings().debugger;
|
||||
try {
|
||||
if (dbg.showOnBreakpoint) toolWindowService().show("debug");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (dbg.focusAppOnBreakpoint) focusAppWindow();
|
||||
break;
|
||||
}
|
||||
case "continued": {
|
||||
const cur = useDebug.getState().sessions[id]!;
|
||||
if (cur.status === "terminated") break;
|
||||
patch(id, {
|
||||
status: "running",
|
||||
frames: [],
|
||||
selectedFrame: null,
|
||||
stoppedThread: null,
|
||||
});
|
||||
updateExecutionPoint();
|
||||
break;
|
||||
}
|
||||
case "output":
|
||||
appendConsole(id, event.category, event.output);
|
||||
break;
|
||||
case "thread":
|
||||
if (s.status === "paused") void loadThreads(id);
|
||||
break;
|
||||
case "breakpointsChanged":
|
||||
void refreshStates(event.path);
|
||||
break;
|
||||
case "exited":
|
||||
patch(id, { exitCode: event.code });
|
||||
appendConsole(id, "system", `\nProcess finished with exit code ${event.code}\n`);
|
||||
break;
|
||||
case "terminated":
|
||||
markTerminated(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Breakpoint ↔ session glue: unverified look only makes sense while debugging. */
|
||||
export function sessionsRunning(): boolean {
|
||||
return Object.values(useDebug.getState().sessions).some(isLive);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sessionTabs {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px 0 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-small);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-ui);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
.tabActive,
|
||||
.tabActive:hover {
|
||||
background: var(--bg-toolbar-selected);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* frames */
|
||||
.frames {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.threadSelect {
|
||||
margin: 0 8px 4px;
|
||||
}
|
||||
|
||||
.frameList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.frame {
|
||||
height: var(--h-row);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-small);
|
||||
text-align: left;
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.frame:hover {
|
||||
background: var(--bg-tree-hover);
|
||||
}
|
||||
.frameSelected,
|
||||
.frameSelected:hover {
|
||||
background: var(--bg-tree-selected-inactive);
|
||||
}
|
||||
.frameList:focus-within .frameSelected {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
.frameLibrary .frameName {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.frameIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.frameName {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.frameFile {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* ", File.java:12" hugs the name, cancelling the row gap. */
|
||||
margin-left: -6px;
|
||||
color: var(--fg-subtle);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hiddenFrames {
|
||||
color: var(--fg-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 4px 12px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
/* variables */
|
||||
.variables {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.evalField {
|
||||
height: 30px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 4px 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.repl {
|
||||
height: 28px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.evalInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--fg-strong);
|
||||
font: var(--fs-ui) var(--font-mono);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.evalInput::placeholder {
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.evalInput:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* console */
|
||||
.console {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.consoleOutput {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.out {
|
||||
color: var(--fg);
|
||||
}
|
||||
.cat-stderr {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cat-console,
|
||||
.cat-system {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.cat-important {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cat-input {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.cat-result {
|
||||
color: var(--fg-emphasis);
|
||||
}
|
||||
|
||||
.repl {
|
||||
margin: 0 14px 2px;
|
||||
border-bottom: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
/** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { evaluateWhen, useContextKeys } from "@/platform/context";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { call, commands, type Scope, type Variable } from "@/platform/ipc";
|
||||
import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Kbd,
|
||||
Menu,
|
||||
Select,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
type TabItem,
|
||||
} from "@/ui";
|
||||
import { setMuted, useBreakpoints } from "../breakpoints/store";
|
||||
import { frameLabel, groupFrames } from "../frames";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import {
|
||||
addWatch,
|
||||
clearConsole,
|
||||
evaluate,
|
||||
isLive,
|
||||
selectFrame,
|
||||
selectSession,
|
||||
selectThread,
|
||||
appendConsole,
|
||||
revealFrame,
|
||||
sessionSupports,
|
||||
useDebug,
|
||||
type DebugSession,
|
||||
} from "../state";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
|
||||
function useActionEnabled(id: string): boolean {
|
||||
const ctx = useContextKeys();
|
||||
const a = getAction(id);
|
||||
return !!a && evaluateWhen(a.when, ctx);
|
||||
}
|
||||
|
||||
function ToolButton({ action, icon, tone }: { action: string; icon: string; tone?: "danger" }) {
|
||||
const enabled = useActionEnabled(action);
|
||||
return (
|
||||
<IconButton
|
||||
icon={icon}
|
||||
action={action}
|
||||
size="small"
|
||||
iconSize={16}
|
||||
disabled={!enabled}
|
||||
tone={tone}
|
||||
className={styles.tool}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's `XDebugger.ToolWindow.TopToolbar3.Extra` ("More" in the debugger toolbar). */
|
||||
const MORE_ITEMS: MenuItem[] = [
|
||||
{ kind: "action", action: "debug.forceStepOver", icon: "force-step-over" },
|
||||
{ kind: "action", action: "debug.forceStepInto", icon: "force-step-into" },
|
||||
{ kind: "action", action: "debug.smartStepInto", icon: "smart-step-into" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "debug.runToCursor", icon: "run-to-cursor" },
|
||||
{ kind: "action", action: "debug.forceRunToCursor", icon: "force-run-to-cursor" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "debug.showExecutionPoint", icon: "show-execution-point" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "debug.evaluate", icon: "evaluate-expression" },
|
||||
{ kind: "action", action: "debug.resetFrame", icon: "reset-frame" },
|
||||
];
|
||||
|
||||
function MoreActions() {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={ref}
|
||||
icon="more"
|
||||
title="More"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
className={styles.tool}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
/>
|
||||
<Menu open={open} anchor={ref} items={MORE_ITEMS} placement="bottom-start" onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "variables" | "console";
|
||||
|
||||
function statusText(s: DebugSession): string {
|
||||
if (s.status === "terminated") return s.exitCode != null ? `Finished · exit code ${s.exitCode}` : "Finished";
|
||||
if (s.status === "starting") return "Starting…";
|
||||
return s.status === "paused" ? "Suspended" : "Running";
|
||||
}
|
||||
|
||||
/** Tool window header: one tab per debug session (`main ×`), like IDEA's content tabs. */
|
||||
export function DebugTabs() {
|
||||
const sessions = useDebug((s) => s.sessions);
|
||||
const active = useDebug((s) => s.active);
|
||||
const list = Object.values(sessions);
|
||||
if (!list.length) return null;
|
||||
const items: TabItem[] = list.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.parent != null ? `${s.name} (child)` : s.name,
|
||||
text: s.name,
|
||||
icon: "debug",
|
||||
italic: s.status === "terminated",
|
||||
tooltip: statusText(s),
|
||||
}));
|
||||
return (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
className={styles.sessionTabs}
|
||||
aria-label="Debug sessions"
|
||||
items={items}
|
||||
activeId={active != null ? String(active) : null}
|
||||
onSelect={(id) => selectSession(+id)}
|
||||
panelIdPrefix="openidea-debug"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugToolWindow() {
|
||||
const active = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined));
|
||||
const muted = useBreakpoints((s) => s.muted);
|
||||
const [tab, setTab] = useState<Tab>("variables");
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<div className={styles.emptyWrap}>
|
||||
<EmptyState title="Not debugging">
|
||||
Start a debug configuration with <Kbd action="run.debug" /> or click a line number to set a breakpoint.
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Debugger actions">
|
||||
<div role="tablist" aria-label="Debug views" className={styles.tabs}>
|
||||
{(
|
||||
[
|
||||
["variables", "Threads & Variables"],
|
||||
["console", "Console"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`debug-tab-${id}`}
|
||||
aria-selected={tab === id}
|
||||
aria-controls={`debug-panel-${id}`}
|
||||
className={clsx(styles.tab, tab === id && styles.tabActive)}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<ToolButton action="debug.rerun" icon="rerun" />
|
||||
<ToolButton action="debug.stop" icon="stop" tone="danger" />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<ToolButton action="debug.resume" icon="resume" />
|
||||
<ToolButton action="debug.pause" icon="pause" />
|
||||
<ToolButton action="debug.stepOver" icon="step-over" />
|
||||
<ToolButton action="debug.stepInto" icon="step-into" />
|
||||
<ToolButton action="debug.stepOut" icon="step-out" />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<IconButton
|
||||
icon="view-breakpoints"
|
||||
action="debug.viewBreakpoints"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
className={styles.tool}
|
||||
/>
|
||||
<IconButton
|
||||
icon="mute-breakpoint"
|
||||
title={muted ? "Unmute Breakpoints" : "Mute Breakpoints"}
|
||||
pressed={muted}
|
||||
size="small"
|
||||
iconSize={16}
|
||||
className={styles.tool}
|
||||
onClick={() => setMuted(!muted)}
|
||||
/>
|
||||
<MoreActions />
|
||||
</div>
|
||||
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
||||
{tab === "variables" ? <ThreadsAndVariables session={active} /> : <ConsoleView session={active} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- threads & frames ----------------------------------------------------------
|
||||
|
||||
function ThreadsAndVariables({ session }: { session: DebugSession }) {
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
|
||||
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
|
||||
<FramesPane session={session} />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="variables" minSize="200px">
|
||||
<VariablesPane session={session} />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function FramesPane({ session }: { session: DebugSession }) {
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const items = useMemo(
|
||||
() => groupFrames(session.frames, root, session.selectedFrame, showAll),
|
||||
[session.frames, root, session.selectedFrame, showAll],
|
||||
);
|
||||
const paused = session.status === "paused";
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
const frames = items.flatMap((i) => (i.kind === "frame" ? [i.frame] : []));
|
||||
const idx = frames.findIndex((f) => f.id === session.selectedFrame);
|
||||
const next = frames[idx + (e.key === "ArrowDown" ? 1 : -1)];
|
||||
if (next) {
|
||||
e.preventDefault();
|
||||
selectFrame(session.id, next.id);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.frames}>
|
||||
{session.threads.length > 0 && (
|
||||
<Select
|
||||
value={session.selectedThread != null ? String(session.selectedThread) : null}
|
||||
options={session.threads.map((t) => ({
|
||||
value: String(t.id),
|
||||
label: t.name,
|
||||
detail: t.id === session.stoppedThread ? "suspended" : undefined,
|
||||
}))}
|
||||
onChange={(v) => void selectThread(session.id, +v)}
|
||||
aria-label="Thread"
|
||||
className={styles.threadSelect}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={styles.frameList}
|
||||
role="listbox"
|
||||
aria-label="Frames"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() =>
|
||||
focusDebugView({
|
||||
view: "frames",
|
||||
jumpToSource: () => {
|
||||
const f = session.frames.find((x) => x.id === session.selectedFrame);
|
||||
if (f) void revealFrame(f, true);
|
||||
},
|
||||
})
|
||||
}
|
||||
onBlur={() => blurDebugView("frames")}
|
||||
>
|
||||
{!paused && <div className={styles.note}>{session.status === "terminated" ? "Finished" : "Running…"}</div>}
|
||||
{items.map((item) =>
|
||||
item.kind === "hidden" ? (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={clsx(styles.frame, styles.hiddenFrames)}
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{item.count} library frames hidden
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={item.frame.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.frame.id === session.selectedFrame}
|
||||
className={clsx(
|
||||
styles.frame,
|
||||
item.frame.id === session.selectedFrame && styles.frameSelected,
|
||||
item.library && styles.frameLibrary,
|
||||
)}
|
||||
onClick={() => selectFrame(session.id, item.frame.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
selectFrame(session.id, item.frame.id, false);
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: "Jump to Source",
|
||||
shortcut: "F4",
|
||||
disabled: !item.frame.path,
|
||||
run: () => void revealFrame(item.frame, true),
|
||||
},
|
||||
{
|
||||
label: "Reset Frame",
|
||||
icon: "reset-frame",
|
||||
disabled: !paused || !sessionSupports("restartFrame", session),
|
||||
run: () => void executeAction("debug.resetFrame"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: showAll ? "Hide Library Frames" : "Show All Frames",
|
||||
run: () => setShowAll((v) => !v),
|
||||
},
|
||||
{
|
||||
label: "Copy Stack",
|
||||
icon: "copy",
|
||||
run: () =>
|
||||
void navigator.clipboard?.writeText(
|
||||
session.frames
|
||||
.map((f) => `${frameLabel(f)}${f.path ? `, ${basename(f.path)}` : ""}`)
|
||||
.join("\n"),
|
||||
),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Icon name="method" size={16} className={styles.frameIcon} />
|
||||
<span className={styles.frameName}>{item.frame.name}</span>
|
||||
{item.frame.path && (
|
||||
<span className={styles.frameFile}>
|
||||
, {basename(item.frame.path)}
|
||||
{item.frame.line >= 0 ? `:${item.frame.line + 1}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- variables -----------------------------------------------------------------
|
||||
|
||||
interface EvalRow {
|
||||
expression: string;
|
||||
result: Variable | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function VariablesPane({ session }: { session: DebugSession }) {
|
||||
const watches = useDebug((s) => s.watches);
|
||||
const stopSeq = useDebug((s) => s.stopSeq);
|
||||
const [expression, setExpression] = useState("");
|
||||
const [lastEval, setLastEval] = useState<EvalRow | null>(null);
|
||||
const [scopeData, setScopeData] = useState<{ frame: number | null; scopes: Scope[]; locals: Variable[] }>({
|
||||
frame: null,
|
||||
scopes: [],
|
||||
locals: [],
|
||||
});
|
||||
const [watchData, setWatchData] = useState<{ frame: number | null; seq: number; rows: EvalRow[] }>({
|
||||
frame: null,
|
||||
seq: -1,
|
||||
rows: [],
|
||||
});
|
||||
const frame = session.status === "paused" ? session.selectedFrame : null;
|
||||
|
||||
// Scopes + first scope's variables of the selected frame.
|
||||
useEffect(() => {
|
||||
if (frame == null) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
let scopes: Scope[] = [];
|
||||
let locals: Variable[] = [];
|
||||
try {
|
||||
scopes = await call(commands.debugScopes(session.id, frame));
|
||||
const first = scopes.find((s) => !s.expensive);
|
||||
if (first) locals = await call(commands.debugVariables(session.id, first.variablesReference));
|
||||
} catch {
|
||||
// Frame went away (resumed).
|
||||
}
|
||||
if (!cancelled) setScopeData({ frame, scopes, locals });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.id, frame, stopSeq]);
|
||||
|
||||
// Watches are re-evaluated on every stop / frame change.
|
||||
useEffect(() => {
|
||||
if (frame == null || !watches.length) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const rows = await Promise.all(
|
||||
watches.map(async (w): Promise<EvalRow> => {
|
||||
try {
|
||||
return { expression: w, result: await evaluate(w, "watch", session), error: null };
|
||||
} catch (e) {
|
||||
return { expression: w, result: null, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) setWatchData({ frame, seq: stopSeq, rows });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `session` identity changes on every patch; frame/stopSeq capture what matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id, frame, stopSeq, watches]);
|
||||
|
||||
const roots = useMemo<VarRoot[]>(() => {
|
||||
const out: VarRoot[] = [];
|
||||
if (lastEval)
|
||||
out.push({
|
||||
key: `eval:${lastEval.expression}`,
|
||||
kind: "eval",
|
||||
name: lastEval.expression,
|
||||
variable: lastEval.result,
|
||||
error: lastEval.error,
|
||||
});
|
||||
const watchRows = watchData.frame === frame ? watchData.rows : [];
|
||||
for (const w of watches) {
|
||||
const r = watchRows.find((x) => x.expression === w);
|
||||
out.push({
|
||||
key: `watch:${w}`,
|
||||
kind: "watch",
|
||||
name: w,
|
||||
variable: r?.result ?? null,
|
||||
error: frame == null ? "Not suspended" : (r?.error ?? null),
|
||||
});
|
||||
}
|
||||
if (frame != null && scopeData.frame === frame) {
|
||||
const firstScope = scopeData.scopes.find((s) => !s.expensive);
|
||||
const seen = new Map<string, number>();
|
||||
for (const v of scopeData.locals) {
|
||||
const n = (seen.get(v.name) ?? 0) + 1;
|
||||
seen.set(v.name, n);
|
||||
out.push({
|
||||
key: `local:${v.name}${n > 1 ? `#${n}` : ""}`,
|
||||
kind: "var",
|
||||
variable: v,
|
||||
container: firstScope?.variablesReference ?? 0,
|
||||
});
|
||||
}
|
||||
for (const s of scopeData.scopes) {
|
||||
if (s === firstScope) continue;
|
||||
out.push({
|
||||
key: `scope:${s.name}`,
|
||||
kind: "scope",
|
||||
name: s.name,
|
||||
reference: s.variablesReference,
|
||||
expensive: s.expensive,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [lastEval, watches, watchData, scopeData, frame]);
|
||||
|
||||
const run = async () => {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
try {
|
||||
setLastEval({ expression: e, result: await evaluate(e, "repl", session), error: null });
|
||||
} catch (err) {
|
||||
setLastEval({ expression: e, result: null, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.variables}>
|
||||
<div className={styles.evalField}>
|
||||
<input
|
||||
className={styles.evalInput}
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addWatch(expression);
|
||||
setExpression("");
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
}}
|
||||
placeholder={frame != null ? "Evaluate expression (↩) or add a watch (⇧⌘↩)" : "Evaluate expression"}
|
||||
aria-label="Evaluate expression"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={frame == null}
|
||||
/>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add to Watches"
|
||||
shortcut="⇧⌘↩"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
disabled={!expression.trim()}
|
||||
onClick={() => {
|
||||
addWatch(expression);
|
||||
setExpression("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{frame == null && !watches.length && !lastEval ? (
|
||||
<div className={styles.note}>
|
||||
{session.status === "terminated" ? "The session has finished." : "Variables are available while suspended."}
|
||||
</div>
|
||||
) : (
|
||||
<VariablesView
|
||||
roots={roots}
|
||||
session={session.id}
|
||||
generation={stopSeq * 100000 + (frame ?? 0)}
|
||||
aria-label="Variables"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- console -------------------------------------------------------------------
|
||||
|
||||
function ConsoleView({ session }: { session: DebugSession }) {
|
||||
const lines = useDebug((s) => s.console[session.id]);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const [input, setInput] = useState("");
|
||||
const history = useRef<string[]>([]);
|
||||
const historyPos = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [lines]);
|
||||
|
||||
const submit = async () => {
|
||||
const e = input.trim();
|
||||
if (!e) return;
|
||||
history.current = [e, ...history.current.filter((h) => h !== e)].slice(0, 100);
|
||||
historyPos.current = -1;
|
||||
setInput("");
|
||||
appendConsole(session.id, "input", `> ${e}\n`);
|
||||
try {
|
||||
const v = await evaluate(e, "repl", session);
|
||||
appendConsole(session.id, "result", `${v.value}\n`);
|
||||
} catch (err) {
|
||||
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const h = history.current;
|
||||
if (!h.length) return;
|
||||
e.preventDefault();
|
||||
const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1)));
|
||||
historyPos.current = pos;
|
||||
setInput(pos < 0 ? "" : h[pos]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.console}>
|
||||
<div
|
||||
ref={scroller}
|
||||
className={styles.consoleOutput}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
|
||||
}}
|
||||
role="log"
|
||||
aria-label="Debug console"
|
||||
tabIndex={0}
|
||||
>
|
||||
{(lines ?? []).map((l) => (
|
||||
<span key={l.id} className={clsx(styles.out, styles[`cat-${l.category}`])}>
|
||||
{l.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<label className={styles.repl}>
|
||||
<span className={styles.prompt} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
<input
|
||||
className={styles.evalInput}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
|
||||
disabled={!isLive(session)}
|
||||
aria-label="Debug console input"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Clear Console"
|
||||
size="small"
|
||||
iconSize={12}
|
||||
onClick={() => clearConsole(session.id)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--fs-ui);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
/* breakpoint popover */
|
||||
.bpEditor {
|
||||
width: 340px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bpTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-emphasis);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bpFile {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Long file names truncate; the actions keep their width. */
|
||||
.bpTitle > button {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bpDot {
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bpDot[data-look="off"] {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--danger);
|
||||
}
|
||||
.bpDot[data-look="unverified"] {
|
||||
background: var(--fg-gutter);
|
||||
}
|
||||
|
||||
.bpFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* breakpoints dialog */
|
||||
.bpDialog {
|
||||
display: flex;
|
||||
height: 420px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bpListPane {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-popup);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bpListHeader {
|
||||
height: var(--h-header);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px 0 12px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
}
|
||||
|
||||
.bpList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bpRow,
|
||||
.chooserRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.bpName {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-emphasis);
|
||||
}
|
||||
|
||||
.bpDir {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bpCond {
|
||||
flex: none;
|
||||
color: var(--syn-keyword);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
|
||||
.bpDetail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.statusLine {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
/* evaluate dialog */
|
||||
.evalBody {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.evalResult {
|
||||
height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 4px 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chooser {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.exceptions {
|
||||
flex: none;
|
||||
max-height: 40%;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--border-popup);
|
||||
padding: 6px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.exceptionsTitle {
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
.exceptionsEmpty {
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.exceptionGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.exceptionAdapter {
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.exceptionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.attachList {
|
||||
height: 360px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.attachPid {
|
||||
min-width: 48px;
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
/** Debugger popovers and dialogs: breakpoint properties, View Breakpoints, Evaluate, config chooser. */
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { basename, relativePath, useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, type AttachableProcess, type RunConfig, type Variable } from "@/platform/ipc";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Popup, Toggle } from "@/ui";
|
||||
import { isConditional, type BreakpointEntry } from "../breakpoints/model";
|
||||
import {
|
||||
removeAllBreakpoints,
|
||||
removeBreakpoint,
|
||||
setMuted,
|
||||
updateBreakpoint,
|
||||
useBreakpoints,
|
||||
} from "../breakpoints/store";
|
||||
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state";
|
||||
import {
|
||||
addMethodBreakpoint,
|
||||
removeAllSpecial,
|
||||
removeSpecial,
|
||||
updateSpecial,
|
||||
useSpecialBreakpoints,
|
||||
type SpecialBreakpoint,
|
||||
} from "../breakpoints/special";
|
||||
import { attachConfig, attachDetail, attachRows, type AttachRow } from "../attach";
|
||||
import {
|
||||
exceptionGroups,
|
||||
refreshExceptionFilters,
|
||||
setExceptionFilter,
|
||||
useExceptionBreakpoints,
|
||||
} from "../breakpoints/exceptions";
|
||||
import { closeBreakpointPopover, useOverlays } from "./popoverStore";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import styles from "./Overlays.module.css";
|
||||
|
||||
export function DebuggerOverlays() {
|
||||
return (
|
||||
<>
|
||||
<BreakpointPopover />
|
||||
<BreakpointsDialog />
|
||||
<EvaluateDialog />
|
||||
<ConfigChooser />
|
||||
<AttachPopup />
|
||||
<SmartStepChooser />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// --- breakpoint properties popover ------------------------------------------
|
||||
|
||||
function BreakpointPopover() {
|
||||
const popover = useOverlays((s) => s.popover);
|
||||
const bp = useBreakpoints((s) => (popover ? s.byFile[popover.path]?.find((b) => b.id === popover.id) : undefined));
|
||||
const special = useSpecialBreakpoints((s) =>
|
||||
popover ? [...s.methods, ...s.fields].find((b) => b.id === popover.id) : undefined,
|
||||
);
|
||||
if (popover && special) return <SpecialEditor key={special.id} bp={special} at={popover} />;
|
||||
if (!popover || !bp) return null;
|
||||
return <BreakpointEditor key={popover.id} path={popover.path} bp={bp} at={popover} focusLog={popover.focusLog} />;
|
||||
}
|
||||
|
||||
function BreakpointEditor({
|
||||
path,
|
||||
bp,
|
||||
at,
|
||||
focusLog,
|
||||
}: {
|
||||
path: string;
|
||||
bp: BreakpointEntry;
|
||||
at: { x: number; y: number };
|
||||
focusLog: boolean;
|
||||
}) {
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
const [logEnabled, setLogEnabled] = useState(focusLog || !!bp.logMessage);
|
||||
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
|
||||
const draft = useRef({ condition, hitCondition, logEnabled, logMessage });
|
||||
useEffect(() => {
|
||||
draft.current = { condition, hitCondition, logEnabled, logMessage };
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
const d = draft.current;
|
||||
updateBreakpoint(path, bp.id, {
|
||||
condition: d.condition.trim() || null,
|
||||
hitCondition: d.hitCondition.trim() || null,
|
||||
logMessage: d.logEnabled ? d.logMessage.trim() || null : null,
|
||||
});
|
||||
};
|
||||
const close = () => {
|
||||
commit();
|
||||
closeBreakpointPopover();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup open onClose={close} anchor={at} placement="bottom-start" trapFocus aria-label="Breakpoint properties">
|
||||
<div className={styles.bpEditor}>
|
||||
<div className={styles.bpTitle}>
|
||||
<span className={styles.bpDot} data-look={bp.enabled ? "on" : "off"} aria-hidden />
|
||||
<span className={styles.bpFile}>
|
||||
{basename(path)}:{bp.line + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={bp.enabled}
|
||||
onChange={(enabled) => updateBreakpoint(path, bp.id, { enabled })}
|
||||
label="Enabled"
|
||||
/>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="e.g. order.total > 100"
|
||||
data-autofocus={focusLog ? undefined : ""}
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="e.g. 5 or >= 3"
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
<Checkbox checked={logEnabled} onChange={setLogEnabled} label="Log message instead of suspending" />
|
||||
{logEnabled && (
|
||||
<Input
|
||||
mono
|
||||
value={logMessage}
|
||||
onChange={(e) => setLogMessage(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="Value: {expression}"
|
||||
data-autofocus={focusLog ? "" : undefined}
|
||||
aria-label="Log message"
|
||||
/>
|
||||
)}
|
||||
<div className={styles.bpFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => {
|
||||
close();
|
||||
void executeAction("debug.viewBreakpoints");
|
||||
}}
|
||||
>
|
||||
More <Kbd action="debug.viewBreakpoints" />
|
||||
</button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
removeBreakpoint(path, bp.id);
|
||||
closeBreakpointPopover();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function specialTitle(b: SpecialBreakpoint): string {
|
||||
return b.kind === "method" ? b.name || "(no method)" : b.label;
|
||||
}
|
||||
|
||||
/** Properties popover of a method breakpoint / field watchpoint (right-click on its gutter icon). */
|
||||
function SpecialEditor({ bp, at }: { bp: SpecialBreakpoint; at: { x: number; y: number } }) {
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
const close = () => {
|
||||
updateSpecial(bp.id, { condition: condition.trim() || null, hitCondition: hitCondition.trim() || null });
|
||||
closeBreakpointPopover();
|
||||
};
|
||||
return (
|
||||
<Popup open onClose={close} anchor={at} placement="bottom-start" trapFocus aria-label="Breakpoint properties">
|
||||
<div className={styles.bpEditor}>
|
||||
<div className={styles.bpTitle}>
|
||||
<Icon name={bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
|
||||
<span className={styles.bpFile}>{specialTitle(bp)}</span>
|
||||
</div>
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => updateSpecial(bp.id, { enabled })} label="Enabled" />
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && close()}
|
||||
data-autofocus=""
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && close()}
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.bpFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => {
|
||||
close();
|
||||
void executeAction("debug.viewBreakpoints");
|
||||
}}
|
||||
>
|
||||
More <Kbd action="debug.viewBreakpoints" />
|
||||
</button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
removeSpecial(bp.id);
|
||||
closeBreakpointPopover();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// --- View Breakpoints dialog --------------------------------------------------
|
||||
|
||||
interface BpRow {
|
||||
path: string;
|
||||
bp: BreakpointEntry;
|
||||
}
|
||||
|
||||
type DialogRow =
|
||||
| { type: "section"; key: string; label: string }
|
||||
| ({ type: "line"; key: string } & BpRow)
|
||||
| { type: "special"; key: string; bp: SpecialBreakpoint };
|
||||
|
||||
function BreakpointsDialog() {
|
||||
const state = useOverlays((s) => s.breakpoints);
|
||||
const byFile = useBreakpoints((s) => s.byFile);
|
||||
const status = useBreakpoints((s) => s.status);
|
||||
const muted = useBreakpoints((s) => s.muted);
|
||||
const methods = useSpecialBreakpoints((s) => s.methods);
|
||||
const fields = useSpecialBreakpoints((s) => s.fields);
|
||||
const specialStatus = useSpecialBreakpoints((s) => s.status);
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const rows = useMemo<DialogRow[]>(() => {
|
||||
const lines: DialogRow[] = Object.keys(byFile)
|
||||
.sort()
|
||||
.flatMap((path) => byFile[path]!.map((bp) => ({ type: "line" as const, key: bp.id, path, bp })));
|
||||
const out: DialogRow[] = [];
|
||||
const section = (label: string, list: DialogRow[]) => {
|
||||
if (list.length) out.push({ type: "section", key: `section:${label}`, label }, ...list);
|
||||
};
|
||||
section("Line Breakpoints", lines);
|
||||
section("Method Breakpoints", methods.map((bp) => ({ type: "special" as const, key: bp.id, bp })));
|
||||
section("Field Watchpoints", fields.map((bp) => ({ type: "special" as const, key: bp.id, bp })));
|
||||
return out;
|
||||
}, [byFile, methods, fields]);
|
||||
const items = rows.filter((r) => r.type !== "section");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const open = !!state;
|
||||
const current = items.find((r) => r.key === (selected ?? state?.id)) ?? items[0];
|
||||
const close = () => {
|
||||
setSelected(null);
|
||||
useOverlays.setState({ breakpoints: null });
|
||||
};
|
||||
const goTo = (r: DialogRow) => {
|
||||
const path = r.type === "line" ? r.path : r.type === "special" ? r.bp.path : null;
|
||||
const line = r.type === "line" ? r.bp.line : r.type === "special" ? r.bp.line : null;
|
||||
if (!path || line == null) return;
|
||||
close();
|
||||
void maybeEditorService()?.openFile(path, { selection: { line, col: 0 } });
|
||||
};
|
||||
const removeAll = () => {
|
||||
removeAllBreakpoints();
|
||||
removeAllSpecial();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Breakpoints"
|
||||
width={760}
|
||||
aria-label="Breakpoints"
|
||||
buttons={[{ label: "Done", role: "default", onClick: close }]}
|
||||
>
|
||||
<div className={styles.bpDialog}>
|
||||
<div className={styles.bpListPane}>
|
||||
<div className={styles.bpListHeader}>
|
||||
<Toggle checked={muted} onChange={setMuted} label="Mute breakpoints" />
|
||||
<div className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add Method Breakpoint"
|
||||
size="small"
|
||||
onClick={() => setSelected(addMethodBreakpoint("").id)}
|
||||
/>
|
||||
<IconButton icon="trash" title="Remove All Breakpoints" size="small" disabled={!items.length} onClick={removeAll} />
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
isSection={(r) => r.type === "section"}
|
||||
renderSection={(r) => <div className={styles.exceptionAdapter}>{r.type === "section" ? r.label : ""}</div>}
|
||||
activeIndex={current ? rows.indexOf(current) : -1}
|
||||
onActiveChange={(i) => setSelected(rows[i]?.key ?? null)}
|
||||
onActivate={(r) => goTo(r)}
|
||||
aria-label="All breakpoints"
|
||||
className={styles.bpList}
|
||||
empty={<div className={styles.empty}>No breakpoints. Click a line number to add one.</div>}
|
||||
renderItem={(r) => {
|
||||
if (r.type === "section") return null;
|
||||
if (r.type === "special") {
|
||||
const st = specialStatus[r.bp.id];
|
||||
return (
|
||||
<div className={styles.bpRow}>
|
||||
<Checkbox
|
||||
checked={r.bp.enabled}
|
||||
onChange={(enabled) => updateSpecial(r.bp.id, { enabled })}
|
||||
aria-label={`Enable ${specialTitle(r.bp)}`}
|
||||
/>
|
||||
<Icon name={r.bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
|
||||
<span className={styles.bpName}>{specialTitle(r.bp)}</span>
|
||||
{r.bp.path && (
|
||||
<span className={styles.bpDir}>
|
||||
{basename(r.bp.path)}
|
||||
{r.bp.line != null ? `:${r.bp.line + 1}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{st && !st.verified && isLive(activeSession()) && <span className={styles.bpCond}>unverified</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const st = status[r.bp.id];
|
||||
return (
|
||||
<div className={styles.bpRow}>
|
||||
<Checkbox
|
||||
checked={r.bp.enabled}
|
||||
onChange={(enabled) => updateBreakpoint(r.path, r.bp.id, { enabled })}
|
||||
aria-label={`Enable breakpoint at ${basename(r.path)}:${r.bp.line + 1}`}
|
||||
/>
|
||||
<span
|
||||
className={styles.bpDot}
|
||||
data-look={
|
||||
!r.bp.enabled || muted
|
||||
? "off"
|
||||
: st && !st.verified && isLive(activeSession())
|
||||
? "unverified"
|
||||
: "on"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={styles.bpName}>
|
||||
{basename(r.path)}:{r.bp.line + 1}
|
||||
</span>
|
||||
<span className={styles.bpDir}>{relativePath(root, r.path)}</span>
|
||||
{isConditional(r.bp) && <span className={styles.bpCond}>{r.bp.logMessage ? "log" : "if"}</span>}
|
||||
{r.bp.temporary && <span className={styles.bpCond}>temp</span>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<ExceptionBreakpoints open={open} />
|
||||
</div>
|
||||
<div className={styles.bpDetail}>
|
||||
{current?.type === "line" ? (
|
||||
<BreakpointDetail key={current.key} row={current} onGo={() => goTo(current)} />
|
||||
) : current?.type === "special" ? (
|
||||
<SpecialDetail key={current.key} bp={current.bp} onGo={() => goTo(current)} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Exception breakpoint filters offered by the debug adapters (checkbox per filter). */
|
||||
function ExceptionBreakpoints({ open }: { open: boolean }) {
|
||||
const known = useExceptionBreakpoints((s) => s.known);
|
||||
const choices = useExceptionBreakpoints((s) => s.choices);
|
||||
const groups = useMemo(() => exceptionGroups(known, choices), [known, choices]);
|
||||
useEffect(() => {
|
||||
if (open) void refreshExceptionFilters();
|
||||
}, [open]);
|
||||
return (
|
||||
<section className={styles.exceptions} aria-label="Exception breakpoints">
|
||||
<div className={styles.exceptionsTitle}>Exception Breakpoints</div>
|
||||
{groups.length === 0 ? (
|
||||
<div className={styles.exceptionsEmpty}>Start a debug session to see the exception filters of its adapter.</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.adapter} className={styles.exceptionGroup} role="group" aria-label={`${g.adapter} exception filters`}>
|
||||
{groups.length > 1 && <div className={styles.exceptionAdapter}>{g.adapter}</div>}
|
||||
{g.filters.map((f) => (
|
||||
<span key={f.filter} title={f.description ?? undefined} className={styles.exceptionRow}>
|
||||
<Checkbox checked={f.enabled} onChange={(on) => setExceptionFilter(g.adapter, f.filter, on)} label={f.label} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
|
||||
const { path, bp } = row;
|
||||
const st = useBreakpoints((s) => s.status[bp.id]);
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
|
||||
const commit = (patch: Partial<BreakpointEntry>) => updateBreakpoint(path, bp.id, patch);
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.bpTitle}>
|
||||
<span className={styles.bpFile}>
|
||||
{basename(path)}:{bp.line + 1}
|
||||
</span>
|
||||
<div className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" onClick={onGo}>
|
||||
Go to Source
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => removeBreakpoint(path, bp.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => commit({ enabled })} label="Enabled" />
|
||||
<Checkbox checked={!!bp.temporary} onChange={(temporary) => commit({ temporary })} label="Remove once hit" />
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onBlur={() => commit({ condition: condition.trim() || null })}
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onBlur={() => commit({ hitCondition: hitCondition.trim() || null })}
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Log message</span>
|
||||
<Input
|
||||
mono
|
||||
value={logMessage}
|
||||
onChange={(e) => setLogMessage(e.target.value)}
|
||||
onBlur={() => commit({ logMessage: logMessage.trim() || null })}
|
||||
placeholder="Suspends when empty"
|
||||
aria-label="Log message"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialDetail({ bp, onGo }: { bp: SpecialBreakpoint; onGo: () => void }) {
|
||||
const st = useSpecialBreakpoints((s) => s.status[bp.id]);
|
||||
const [name, setName] = useState(bp.kind === "method" ? bp.name : bp.label);
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.bpTitle}>
|
||||
<Icon name={bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
|
||||
<span className={styles.bpFile}>{specialTitle(bp)}</span>
|
||||
<div className={styles.spacer} />
|
||||
{bp.path && (
|
||||
<Button size="small" variant="ghost" onClick={onGo}>
|
||||
Go to Source
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="ghost" onClick={() => removeSpecial(bp.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => updateSpecial(bp.id, { enabled })} label="Enabled" />
|
||||
{bp.kind === "method" ? (
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Method</span>
|
||||
<Input
|
||||
mono
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => updateSpecial(bp.id, { name: name.trim() })}
|
||||
placeholder="com.example.Service#process, main.run, parse"
|
||||
data-autofocus={bp.name ? undefined : ""}
|
||||
aria-label="Method name"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.statusLine}>Watching {bp.dataId}</div>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Suspend on</span>
|
||||
<span className={styles.exceptionGroup}>
|
||||
<Checkbox
|
||||
checked={bp.accessType !== "read"}
|
||||
onChange={(on) =>
|
||||
updateSpecial(bp.id, { accessType: on ? (bp.accessType === "read" ? "readWrite" : "write") : "read" })
|
||||
}
|
||||
label="Field modification"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={bp.accessType === "read" || bp.accessType === "readWrite"}
|
||||
onChange={(on) => updateSpecial(bp.id, { accessType: on ? "readWrite" : "write" })}
|
||||
label="Field access"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onBlur={() => updateSpecial(bp.id, { condition: condition.trim() || null })}
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onBlur={() => updateSpecial(bp.id, { hitCondition: hitCondition.trim() || null })}
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Evaluate dialog -----------------------------------------------------------
|
||||
|
||||
function EvaluateDialog() {
|
||||
const state = useOverlays((s) => s.evaluate);
|
||||
if (!state) return null;
|
||||
return <EvaluateDialogInner initial={state.expression} />;
|
||||
}
|
||||
|
||||
function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
const [expression, setExpression] = useState(initial);
|
||||
const [result, setResult] = useState<VarRoot | null>(null);
|
||||
const session = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined));
|
||||
const paused = session?.status === "paused";
|
||||
const close = () => useOverlays.setState({ evaluate: null });
|
||||
const run = async () => {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
try {
|
||||
const v: Variable = await evaluate(e, "repl", session ?? null);
|
||||
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: v, error: null });
|
||||
} catch (err) {
|
||||
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: null, error: errorText(err) });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
title="Evaluate"
|
||||
width={620}
|
||||
aria-label="Evaluate expression"
|
||||
footerStart={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
addWatch(expression);
|
||||
close();
|
||||
}}
|
||||
disabled={!expression.trim()}
|
||||
>
|
||||
Add to Watches
|
||||
</Button>
|
||||
}
|
||||
buttons={[
|
||||
{ label: "Evaluate", role: "default", disabled: !paused || !expression.trim(), onClick: () => void run() },
|
||||
{ label: "Close", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.evalBody}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Expression</span>
|
||||
<Input
|
||||
mono
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
}}
|
||||
data-autofocus=""
|
||||
aria-label="Expression"
|
||||
placeholder={paused ? "Expression to evaluate in the selected frame" : "Pause the program to evaluate"}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.evalResult}>
|
||||
{result ? (
|
||||
<VariablesView roots={[result]} session={session?.id ?? null} aria-label="Evaluation result" />
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
{paused ? "Press Enter to evaluate" : "Evaluation is available while the program is suspended"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- debug configuration chooser -----------------------------------------------
|
||||
|
||||
function ConfigChooser() {
|
||||
const state = useOverlays((s) => s.chooser);
|
||||
const close = () => useOverlays.setState({ chooser: null });
|
||||
const pick = (c: RunConfig) => {
|
||||
close();
|
||||
void executeAction("debug.startConfig", c);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={!!state}
|
||||
onClose={close}
|
||||
title="Debug"
|
||||
width={420}
|
||||
position="top"
|
||||
aria-label="Choose configuration to debug"
|
||||
>
|
||||
<List
|
||||
items={state?.configs ?? []}
|
||||
getKey={(c) => c.id}
|
||||
defaultActiveIndex={0}
|
||||
onActivate={(c) => pick(c)}
|
||||
aria-label="Debuggable configurations"
|
||||
className={styles.chooser}
|
||||
renderItem={(c) => (
|
||||
<div className={styles.chooserRow}>
|
||||
<Icon name="debug" size={14} />
|
||||
<span className={styles.bpName}>{c.name}</span>
|
||||
<span className={styles.bpDir}>{c.debugAdapter}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Attach to Process ------------------------------------------------------------
|
||||
|
||||
function AttachPopup() {
|
||||
const state = useOverlays((s) => s.attach);
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const rows = useMemo(() => attachRows(state?.processes ?? [], filter), [state?.processes, filter]);
|
||||
const firstItem = rows.findIndex((r) => r.kind === "process");
|
||||
const activeIndex = rows[active]?.kind === "process" ? active : firstItem;
|
||||
if (!state) return null;
|
||||
const close = () => {
|
||||
setFilter("");
|
||||
setActive(0);
|
||||
useOverlays.setState({ attach: null });
|
||||
};
|
||||
const attach = async (p: AttachableProcess) => {
|
||||
close();
|
||||
if (!root) return;
|
||||
try {
|
||||
await startSession(await attachConfig(p, root), { rerunnable: false });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
key: "debug.attach",
|
||||
title: `Cannot attach to ${p.name} (${p.pid})`,
|
||||
body: errorText(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
const move = (dir: 1 | -1) => {
|
||||
for (let i = activeIndex + dir; i >= 0 && i < rows.length; i += dir) {
|
||||
if (rows[i]!.kind === "process") {
|
||||
setActive(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={close} title="Attach to Process" width={640} position="top" aria-label="Attach to process">
|
||||
<div className={styles.evalBody}>
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
move(e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const row = rows[activeIndex];
|
||||
if (row?.kind === "process") void attach(row.process);
|
||||
}
|
||||
}}
|
||||
placeholder="Filter by name, PID or command line"
|
||||
data-autofocus=""
|
||||
aria-label="Filter processes"
|
||||
/>
|
||||
{state.processes == null ? (
|
||||
<div className={styles.empty}>Loading processes…</div>
|
||||
) : state.error ? (
|
||||
<div className={styles.empty}>{state.error}</div>
|
||||
) : (
|
||||
<List<AttachRow>
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
isSection={(r) => r.kind === "group"}
|
||||
renderSection={(r) =>
|
||||
r.kind === "group" ? (
|
||||
<div className={styles.exceptionAdapter}>
|
||||
{r.label} · {r.count}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(r) => r.kind === "process" && void attach(r.process)}
|
||||
focusable={false}
|
||||
aria-label="Processes"
|
||||
className={styles.attachList}
|
||||
empty={<div className={styles.empty}>No attachable processes{filter ? " match the filter" : ""}.</div>}
|
||||
renderItem={(r) =>
|
||||
r.kind === "process" ? (
|
||||
<div className={styles.chooserRow} title={r.process.commandLine}>
|
||||
<span className={styles.attachPid}>{r.process.pid}</span>
|
||||
<span className={styles.bpName}>{r.process.name}</span>
|
||||
<span className={styles.bpDir}>{r.process.commandLine}</span>
|
||||
<span className={styles.bpCond}>{attachDetail(r.process)}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Smart Step Into -------------------------------------------------------------
|
||||
|
||||
function SmartStepChooser() {
|
||||
const state = useOverlays((s) => s.smartStep);
|
||||
const close = () => useOverlays.setState({ smartStep: null });
|
||||
const pick = (targetId: number) => {
|
||||
if (!state) return;
|
||||
close();
|
||||
void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) =>
|
||||
notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Dialog open={!!state} onClose={close} title="Step Into" width={420} position="top" aria-label="Smart Step Into">
|
||||
<List
|
||||
items={state?.targets ?? []}
|
||||
getKey={(t) => String(t.id)}
|
||||
defaultActiveIndex={0}
|
||||
onActivate={(t) => pick(t.id)}
|
||||
aria-label="Calls on the current line"
|
||||
className={styles.chooser}
|
||||
renderItem={(t) => (
|
||||
<div className={styles.chooserRow}>
|
||||
<Icon name="method" size={14} />
|
||||
<span className={styles.bpName}>{t.label}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
white-space: pre;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.varName {
|
||||
color: var(--debug-name, var(--syn-property));
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
flex: none;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.eq {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.number {
|
||||
color: var(--syn-number);
|
||||
}
|
||||
.string {
|
||||
color: var(--syn-string);
|
||||
}
|
||||
.keyword {
|
||||
color: var(--syn-keyword);
|
||||
}
|
||||
.object {
|
||||
color: var(--fg);
|
||||
}
|
||||
.plain {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.type {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.scope {
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
|
||||
.editInput {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-small);
|
||||
outline: none;
|
||||
}
|
||||
.editInvalid {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/** Lazy variables tree (scopes, locals, watches, evaluation results). */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, type DebugSessionId, type Variable } from "@/platform/ipc";
|
||||
import { Icon, Tree, acquirePopup, showContextMenu, type MenuItem, type TreeHandle } from "@/ui";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { addFieldWatchpoint } from "../breakpoints/special";
|
||||
import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue } from "../values";
|
||||
import styles from "./VariablesView.module.css";
|
||||
|
||||
export type VarRoot =
|
||||
| { key: string; kind: "eval" | "watch"; name: string; variable: Variable | null; error: string | null }
|
||||
| { key: string; kind: "scope"; name: string; reference: number; expensive: boolean }
|
||||
/** `container`: variablesReference of the scope / object holding the variable (for Set Value). */
|
||||
| { key: string; kind: "var"; variable: Variable; container?: number };
|
||||
|
||||
interface VarNode {
|
||||
key: string;
|
||||
kind: "eval" | "watch" | "scope" | "var" | "message";
|
||||
name: string;
|
||||
value: string;
|
||||
typeName: string | null;
|
||||
reference: number;
|
||||
evaluateName: string | null;
|
||||
error: string | null;
|
||||
depth0: boolean;
|
||||
/** variablesReference of the parent (0 when unknown). */
|
||||
container: number;
|
||||
}
|
||||
|
||||
function fromRoot(r: VarRoot): VarNode {
|
||||
switch (r.kind) {
|
||||
case "scope":
|
||||
return {
|
||||
key: r.key,
|
||||
kind: "scope",
|
||||
name: r.name,
|
||||
value: "",
|
||||
typeName: null,
|
||||
reference: r.reference,
|
||||
evaluateName: null,
|
||||
error: null,
|
||||
depth0: true,
|
||||
container: 0,
|
||||
};
|
||||
case "var":
|
||||
return fromVariable(r.key, r.variable, r.container ?? 0, true);
|
||||
default:
|
||||
return {
|
||||
key: r.key,
|
||||
kind: r.kind,
|
||||
name: r.name,
|
||||
value: r.variable?.value ?? "",
|
||||
typeName: r.variable?.typeName ?? null,
|
||||
reference: r.variable?.variablesReference ?? 0,
|
||||
evaluateName: r.variable?.evaluateName ?? r.name,
|
||||
error: r.error,
|
||||
depth0: true,
|
||||
container: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function fromVariable(key: string, v: Variable, container: number, depth0 = false): VarNode {
|
||||
return {
|
||||
key,
|
||||
kind: "var",
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
typeName: v.typeName,
|
||||
reference: v.variablesReference,
|
||||
evaluateName: v.evaluateName,
|
||||
error: null,
|
||||
depth0,
|
||||
container,
|
||||
};
|
||||
}
|
||||
|
||||
function message(key: string, text: string): VarNode {
|
||||
return {
|
||||
key,
|
||||
kind: "message",
|
||||
name: text,
|
||||
value: "",
|
||||
typeName: null,
|
||||
reference: 0,
|
||||
evaluateName: null,
|
||||
error: null,
|
||||
depth0: false,
|
||||
container: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VariablesViewProps {
|
||||
roots: VarRoot[];
|
||||
session: DebugSessionId | null;
|
||||
/** Changing it drops loaded children (new stop); expansion is kept and re-loaded. */
|
||||
generation?: number;
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VariablesView({ roots, session, generation = 0, className, ...aria }: VariablesViewProps) {
|
||||
const [cache, setCache] = useState<{ gen: number; map: Map<string, VarNode[]> }>({ gen: generation, map: new Map() });
|
||||
const children = cache.gen === generation ? cache.map : null;
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const loading = useRef(new Set<string>());
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
|
||||
const nodes = useMemo(() => roots.map(fromRoot), [roots]);
|
||||
const byKey = useMemo(() => {
|
||||
const m = new Map<string, VarNode>();
|
||||
const walk = (list: readonly VarNode[]) =>
|
||||
list.forEach((n) => {
|
||||
m.set(n.key, n);
|
||||
const c = children?.get(n.key);
|
||||
if (c) walk(c);
|
||||
});
|
||||
walk(nodes);
|
||||
return m;
|
||||
}, [nodes, children]);
|
||||
|
||||
const load = useCallback(
|
||||
async (node: VarNode) => {
|
||||
if (session == null || node.reference <= 0) return;
|
||||
const tag = `${generation}:${node.key}`;
|
||||
if (loading.current.has(tag)) return;
|
||||
loading.current.add(tag);
|
||||
let list: VarNode[];
|
||||
try {
|
||||
const vars = await call(commands.debugVariables(session, node.reference));
|
||||
const seen = new Map<string, number>();
|
||||
list = vars.map((v) => {
|
||||
const n = (seen.get(v.name) ?? 0) + 1;
|
||||
seen.set(v.name, n);
|
||||
return fromVariable(`${node.key}/${v.name}${n > 1 ? `#${n}` : ""}`, v, node.reference);
|
||||
});
|
||||
if (!list.length) list = [message(`${node.key}/\u0000empty`, "No variables")];
|
||||
} catch (e) {
|
||||
list = [message(`${node.key}/\u0000error`, e instanceof Error ? e.message : String(e))];
|
||||
} finally {
|
||||
loading.current.delete(tag);
|
||||
}
|
||||
setCache((c) => {
|
||||
const map = new Map(c.gen === generation ? c.map : []);
|
||||
map.set(node.key, list);
|
||||
return { gen: generation, map };
|
||||
});
|
||||
},
|
||||
[session, generation],
|
||||
);
|
||||
|
||||
// Re-load children of nodes that stay expanded across steps (and non-expensive scopes on first show).
|
||||
useEffect(() => {
|
||||
for (const n of byKey.values()) {
|
||||
const wants = expanded.has(n.key);
|
||||
if (wants && n.reference > 0 && !children?.has(n.key)) void load(n);
|
||||
}
|
||||
}, [byKey, expanded, children, load]);
|
||||
|
||||
const [editing, setEditing] = useState<{ key: string; value: string; error: string | null } | null>(null);
|
||||
const editable = (n: VarNode | undefined): n is VarNode =>
|
||||
!!n && session != null && (n.kind === "var" || n.kind === "watch") && (n.container > 0 || !!n.evaluateName);
|
||||
const startEdit = (n: VarNode) => setEditing({ key: n.key, value: n.value, error: null });
|
||||
const commitEdit = async () => {
|
||||
const e = editing;
|
||||
const node = e ? byKey.get(e.key) : undefined;
|
||||
if (!e || !node) return setEditing(null);
|
||||
if (e.value === node.value) {
|
||||
setEditing(null);
|
||||
tree.current?.focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setVariableValue({ container: node.container, name: node.name, evaluateName: node.evaluateName }, e.value);
|
||||
setEditing(null);
|
||||
tree.current?.focus();
|
||||
} catch (err) {
|
||||
setEditing({ ...e, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const leadNode = () => {
|
||||
const lead = tree.current?.leadKey();
|
||||
return lead ? byKey.get(lead) : undefined;
|
||||
};
|
||||
const viewCommands = () => ({
|
||||
view: "variables" as const,
|
||||
setValue: () => {
|
||||
const node = leadNode();
|
||||
if (editable(node)) startEdit(node);
|
||||
},
|
||||
jumpToSource: () => {
|
||||
const node = leadNode();
|
||||
if (node) void jumpToDeclaration(node);
|
||||
},
|
||||
});
|
||||
const canWatch = (n: VarNode) =>
|
||||
session != null && n.kind === "var" && n.container > 0 && sessionSupports("dataBreakpoints");
|
||||
|
||||
const onContextMenu = (node: VarNode | null, _sel: Set<string>, e: React.MouseEvent) => {
|
||||
if (!node || node.kind === "message") return;
|
||||
e.preventDefault();
|
||||
const items: MenuItem[] = [];
|
||||
if (editable(node)) items.push({ label: "Set Value…", icon: "edit", shortcut: "F2", run: () => startEdit(node) }, { kind: "separator" });
|
||||
if (node.kind === "watch") items.push({ label: "Remove Watch", icon: "minus", run: () => removeWatch(node.name) });
|
||||
if (node.kind !== "scope") {
|
||||
items.push({ label: "Copy Value", icon: "copy", run: () => void navigator.clipboard?.writeText(node.value) });
|
||||
items.push({ label: "Copy Name", run: () => void navigator.clipboard?.writeText(node.name) });
|
||||
if (node.kind === "var" && node.evaluateName)
|
||||
items.push({ label: "Add to Watches", icon: "eye", run: () => addWatch(node.evaluateName!) });
|
||||
if (canWatch(node))
|
||||
items.push({
|
||||
label: "Add Field Watchpoint",
|
||||
icon: "breakpoint-field",
|
||||
run: () => void addWatchpointFor(session!, node),
|
||||
});
|
||||
items.push({ kind: "separator" }, { label: "Jump to Source", shortcut: "F4", run: () => void jumpToDeclaration(node) });
|
||||
}
|
||||
if (items.length) showContextMenu(e, items);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (editing) return;
|
||||
const lead = tree.current?.leadKey();
|
||||
const node = lead ? byKey.get(lead) : undefined;
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (node?.kind === "watch") {
|
||||
e.preventDefault();
|
||||
removeWatch(node.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.root, className)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => focusDebugView(viewCommands())}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) blurDebugView("variables");
|
||||
}}
|
||||
>
|
||||
<Tree<VarNode>
|
||||
roots={nodes}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => children?.get(n.key)}
|
||||
isExpandable={(n) => n.reference > 0}
|
||||
loadChildren={(n) => load(n)}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(next) => setExpanded(next)}
|
||||
multiSelect={false}
|
||||
onContextMenu={onContextMenu}
|
||||
handleRef={tree}
|
||||
aria-label={aria["aria-label"] ?? "Variables"}
|
||||
renderNode={(n) =>
|
||||
editing?.key === n.key ? (
|
||||
<VarEditRow
|
||||
node={n}
|
||||
value={editing.value}
|
||||
error={editing.error}
|
||||
onChange={(value) => setEditing({ ...editing, value, error: null })}
|
||||
onCommit={() => void commitEdit()}
|
||||
onCancel={() => {
|
||||
setEditing(null);
|
||||
tree.current?.focus();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<VarRow node={n} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VarEditRow({
|
||||
node,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
node: VarNode;
|
||||
value: string;
|
||||
error: string | null;
|
||||
onChange: (v: string) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
// While editing, Escape belongs to the editor row: holding a popup layer keeps
|
||||
// the global "Escape → focus editor" binding (`!popupOpen`) from firing.
|
||||
useEffect(() => acquirePopup(), []);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={styles.name}>{node.name}</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
<input
|
||||
className={clsx(styles.editInput, error && styles.editInvalid)}
|
||||
value={value}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-label={`New value of ${node.name}`}
|
||||
aria-invalid={!!error || undefined}
|
||||
title={error ?? undefined}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onBlur={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onCommit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeIcon(node: VarNode): string {
|
||||
if (node.kind === "watch") return "eye";
|
||||
if (node.kind === "eval") return "evaluate-expression";
|
||||
return node.depth0 ? "variable" : "field";
|
||||
}
|
||||
|
||||
function VarRow({ node }: { node: VarNode }) {
|
||||
if (node.kind === "message") return <span className={styles.message}>{node.name}</span>;
|
||||
if (node.kind === "scope")
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name="folder" size={16} className={styles.nodeIcon} />
|
||||
<span className={styles.scope}>{node.name}</span>
|
||||
</span>
|
||||
);
|
||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName)}>{node.name}</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
{node.error ? (
|
||||
<span className={styles.error}>{node.error}</span>
|
||||
) : (
|
||||
<>
|
||||
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||
<span className={clsx(styles.value, kind && styles[kind])}>{node.value}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Add Field Watchpoint: asks the adapter for a data breakpoint id for the value. */
|
||||
async function addWatchpointFor(session: DebugSessionId, node: VarNode) {
|
||||
try {
|
||||
const frame = activeSession()?.selectedFrame ?? null;
|
||||
const info = await call(commands.debugDataBreakpointInfo(session, node.container, node.name, frame));
|
||||
if (!info.dataId) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
key: "debug.watchpoint",
|
||||
title: `“${node.name}” can't be watched`,
|
||||
body: info.description || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const access = info.accessTypes.includes("write") || !info.accessTypes.length ? "write" : null;
|
||||
addFieldWatchpoint({ dataId: info.dataId, label: node.evaluateName ?? node.name, accessType: access });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
key: "debug.watchpoint",
|
||||
title: "Cannot add a field watchpoint",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to Source on a value: the selected frame's source; for a local, its first mention
|
||||
* in the enclosing function (declaration or parameter, as far as text can tell).
|
||||
*/
|
||||
async function jumpToDeclaration(node: VarNode) {
|
||||
const s = activeSession();
|
||||
const frame = s?.frames.find((f) => f.id === s.selectedFrame);
|
||||
if (!frame?.path) return;
|
||||
await revealFrame(frame, true);
|
||||
if (!node.depth0 || node.kind !== "var" || !/^[A-Za-z_$][\w$]*$/.test(node.name)) return;
|
||||
const ed = activeEditor();
|
||||
if (!ed || ed.ctx.path !== frame.path) return;
|
||||
const doc = ed.view.state.doc;
|
||||
const lines = doc.toString().split("\n");
|
||||
const re = new RegExp(`(^|[^\\w$])${node.name.replace(/\$/g, "\\$")}(?![\\w$])`);
|
||||
let found: { line: number; col: number } | null = null;
|
||||
for (let n = Math.min(frame.line, lines.length - 1); n >= 0; n--) {
|
||||
const m = re.exec(lines[n]!);
|
||||
if (m) found = { line: n, col: m.index + m[1]!.length };
|
||||
if (methodAt(ed.ctx.language, lines, n)) break;
|
||||
}
|
||||
if (!found) return;
|
||||
const pos = doc.line(found.line + 1).from + found.col;
|
||||
ed.view.dispatch({ selection: { anchor: pos, head: pos + node.name.length }, scrollIntoView: true });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The debugger view (Frames or Variables) that has keyboard focus, so view-local
|
||||
* actions bound to global keys (F2 Set Value, F4 Jump to Source) reach it, like
|
||||
* IDEA's per-component shortcuts.
|
||||
*/
|
||||
import { setContext } from "@/platform/context";
|
||||
|
||||
export interface DebugViewCommands {
|
||||
view: "frames" | "variables";
|
||||
setValue?: () => void;
|
||||
jumpToSource?: () => void;
|
||||
}
|
||||
|
||||
let current: DebugViewCommands | null = null;
|
||||
|
||||
export function focusDebugView(commands: DebugViewCommands) {
|
||||
current = commands;
|
||||
setContext("debugFramesFocus", commands.view === "frames");
|
||||
setContext("debugVariablesFocus", commands.view === "variables");
|
||||
}
|
||||
|
||||
export function blurDebugView(view: DebugViewCommands["view"]) {
|
||||
if (current?.view !== view) return;
|
||||
current = null;
|
||||
setContext("debugFramesFocus", false);
|
||||
setContext("debugVariablesFocus", false);
|
||||
}
|
||||
|
||||
export function focusedDebugView(): DebugViewCommands | null {
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Open/close state of the debugger's overlays (rendered by `Overlays.tsx`). */
|
||||
import { create } from "zustand";
|
||||
import type { AttachableProcess, DebugSessionId, RunConfig, StepInTarget } from "@/platform/ipc";
|
||||
|
||||
interface OverlayState {
|
||||
popover: { path: string; id: string; x: number; y: number; focusLog: boolean } | null;
|
||||
evaluate: { expression: string } | null;
|
||||
breakpoints: { path?: string; id?: string } | null;
|
||||
chooser: { configs: RunConfig[] } | null;
|
||||
/** Attach to Process popup; `processes` is null while loading. */
|
||||
attach: { processes: AttachableProcess[] | null; error: string | null } | null;
|
||||
/** Smart Step Into chooser. */
|
||||
smartStep: { session: DebugSessionId; thread: number; targets: StepInTarget[] } | null;
|
||||
}
|
||||
|
||||
export const useOverlays = create<OverlayState>(() => ({
|
||||
popover: null,
|
||||
evaluate: null,
|
||||
breakpoints: null,
|
||||
chooser: null,
|
||||
attach: null,
|
||||
smartStep: null,
|
||||
}));
|
||||
|
||||
export function openBreakpointPopover(path: string, id: string, at: { x: number; y: number }, focusLog = false) {
|
||||
useOverlays.setState({ popover: { path, id, x: at.x, y: at.y, focusLog } });
|
||||
}
|
||||
|
||||
export function closeBreakpointPopover() {
|
||||
useOverlays.setState({ popover: null });
|
||||
}
|
||||
|
||||
export function openEvaluateDialog(expression = "") {
|
||||
useOverlays.setState({ evaluate: { expression } });
|
||||
}
|
||||
|
||||
export function openBreakpointsDialog(select?: { path: string; id: string }) {
|
||||
useOverlays.setState({ breakpoints: select ?? {} });
|
||||
}
|
||||
|
||||
export function openConfigChooser(configs: RunConfig[]) {
|
||||
useOverlays.setState({ chooser: { configs } });
|
||||
}
|
||||
|
||||
export function openSmartStepChooser(session: DebugSessionId, thread: number, targets: StepInTarget[]) {
|
||||
useOverlays.setState({ smartStep: { session, thread, targets } });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Presentation helpers for debugger values. */
|
||||
|
||||
export type ValueKind = "number" | "string" | "keyword" | "object" | "plain";
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
"null",
|
||||
"nil",
|
||||
"None",
|
||||
"undefined",
|
||||
"true",
|
||||
"false",
|
||||
"True",
|
||||
"False",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"-Infinity",
|
||||
"nullptr",
|
||||
"<nil>",
|
||||
]);
|
||||
|
||||
const NUMBER = /^[-+]?(?:0x[\da-f]+|0b[01]+|0o[0-7]+|(?:\d[\d_]*\.?[\d_]*|\.\d+)(?:e[-+]?\d+)?)(?:[a-z]\w*)?$/i;
|
||||
|
||||
/** Classifies a value string for colouring: numbers, strings, keywords (null/true…), objects (`{…}`, `[…]`). */
|
||||
export function classifyValue(value: string, typeName?: string | null): ValueKind {
|
||||
const v = value.trim();
|
||||
if (!v) return "plain";
|
||||
if (KEYWORDS.has(v)) return "keyword";
|
||||
if (/^(['"`]).*\1$/s.test(v) || (/^[bru]?['"]/.test(v) && /['"]$/.test(v))) return "string";
|
||||
if (NUMBER.test(v)) return "number";
|
||||
if (/^[{[(]/.test(v) || /^[\w$.:<>]+\s*[{(@]/.test(v) || /^<.*>$/.test(v)) return "object";
|
||||
const t = typeName?.toLowerCase() ?? "";
|
||||
if (/\b(str|string|char|&str)\b/.test(t)) return "string";
|
||||
return "plain";
|
||||
}
|
||||
|
||||
/** Single-line, length-limited value for inline display. */
|
||||
export function inlineValue(value: string, max = 60): string {
|
||||
const flat = value.replace(/\s*\n\s*/g, " ");
|
||||
return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
|
||||
}
|
||||
|
||||
/** Identifiers in a line of code (for matching inline values). */
|
||||
export function identifiersIn(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Skip string literal contents roughly so `"total"` doesn't count.
|
||||
const code = text.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, " ");
|
||||
for (const m of code.matchAll(/[A-Za-z_$][\w$]*/g)) {
|
||||
if (!seen.has(m[0])) {
|
||||
seen.add(m[0]);
|
||||
out.push(m[0]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача