Initial open-source release

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-22 18:55:10 +08:00
коміт 9a2025b76e
2034 files changed
+243381

No files matched your search

+253
Переглянути файл
@@ -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]);
});
});
+90
Переглянути файл
@@ -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 });
});
});
+137
Переглянути файл
@@ -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]);
});
});
+167
Переглянути файл
@@ -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);
});
});
+50
Переглянути файл
@@ -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")] });
});
});
+104
Переглянути файл
@@ -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);
});
});
+76
Переглянути файл
@@ -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 });
});
});
+200
Переглянути файл
@@ -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"] });
});
});
+29
Переглянути файл
@@ -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();
});
});
+101
Переглянути файл
@@ -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([]);
});
});
+64
Переглянути файл
@@ -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();
});
});
+61
Переглянути файл
@@ -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();
});
});
+95
Переглянути файл
@@ -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");
});
});
+534
Переглянути файл
@@ -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),
},
]);
}
+92
Переглянути файл
@@ -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 });
}
}
+170
Переглянути файл
@@ -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);
}
}
+35
Переглянути файл
@@ -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);
}
+115
Переглянути файл
@@ -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
);
});
}
+272
Переглянути файл
@@ -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();
});
+233
Переглянути файл
@@ -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 })));
}
+218
Переглянути файл
@@ -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);
}
+29
Переглянути файл
@@ -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);
}
+301
Переглянути файл
@@ -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" },
}),
];
}
+152
Переглянути файл
@@ -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);
}
+154
Переглянути файл
@@ -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];
}
+75
Переглянути файл
@@ -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 },
);
}
+51
Переглянути файл
@@ -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;
}
+105
Переглянути файл
@@ -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;
}
}
+60
Переглянути файл
@@ -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());
}
+106
Переглянути файл
@@ -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 }),
};
}
+228
Переглянути файл
@@ -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],
};
}
+38
Переглянути файл
@@ -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,
};
}
+641
Переглянути файл
@@ -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);
}
+271
Переглянути файл
@@ -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);
}
+621
Переглянути файл
@@ -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>
);
}
+242
Переглянути файл
@@ -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;
}
+828
Переглянути файл
@@ -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>
);
}
+98
Переглянути файл
@@ -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);
}
+419
Переглянути файл
@@ -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 });
}
+31
Переглянути файл
@@ -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;
}
+47
Переглянути файл
@@ -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 } });
}
+55
Переглянути файл
@@ -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;
}