Files
openidea/src/features/debugger/ui/DebugToolWindow.tsx
T
l0ng-ai fe0ee338f4 feat(debugger): completion in Evaluate, the Variables evaluate field and the Debug console (#1102)
While suspended, the debugger's expression fields complete from the debug adapter (DAP completions) in the selected frame: Evaluate, the Variables evaluate field and the Debug console share one hook. The list opens while typing an identifier or after '.', or on Ctrl+Space; Up/Down, Enter/Tab, Escape. The Evaluate dialog is resizable and its result tree grows with it. Backend: ide-dap completions (skipped without supportsCompletionsRequest), debug_completions on the host and the desktop app.
2026-11-04 04:47:12 +08:00

565 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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,
T,
} from "@/ui";
import { setMuted, useBreakpoints } from "../breakpoints/store";
import { frameLabel, groupFrames } from "../frames";
import { blurDebugView, focusDebugView } from "./focusedView";
import {
addWatch,
evaluate,
selectFrame,
selectSession,
selectThread,
revealFrame,
sessionSupports,
useDebug,
type DebugSession,
} from "../state";
import { DebugConsole } from "./DebugConsole";
import { VariablesView, type VarRoot } from "./VariablesView";
import { useExpressionCompletion } from "./ExpressionCompletion";
import { sortVariables } from "../values";
import { workflowSettings } from "@/platform/workflowSettings";
import styles from "./DebugToolWindow.module.css";
import { tx } from "@/platform/i18n";
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 ? tx`${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 [view, setView] = useState<{
session: number | undefined;
status: DebugSession["status"] | undefined;
tab: Tab;
}>({ session: undefined, status: undefined, tab: "variables" });
if (active && (view.session !== active.startedAt || view.status !== active.status))
setView({
session: active.startedAt,
status: active.status,
tab: active.status === "starting" ? "console" : active.status === "paused" ? "variables" : view.tab,
});
const tab = view.tab;
if (!active) {
return (
<div className={styles.emptyWrap}>
<EmptyState title="Not debugging">
<T>Start a debug configuration with</T> <Kbd action="run.debug" />{" "}
<T>or click a line number to set a breakpoint.</T>
</EmptyState>
</div>
);
}
return (
<div className={styles.root}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
<div role="tablist" aria-label={tx("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={() => setView({ session: active.startedAt, status: active.status, tab: 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} />
) : (
<DebugConsole key={active.id} 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={tx("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" ? tx("Finished") : tx("Running…")}</div>
)}
{items.map((item) =>
item.kind === "hidden" ? (
<button
key={item.key}
type="button"
className={clsx(styles.frame, styles.hiddenFrames)}
onClick={() => setShowAll(true)}
>
{tx`${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 evalField = useRef<HTMLInputElement>(null);
const completion = useExpressionCompletion(evalField, expression, setExpression, session);
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;
// Same thread and stack depth across stops: compare like with like.
const changeScope = `${session.id}:${session.selectedThread}:${session.frames.findIndex((f) => f.id === frame)}`;
// 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) {
const vars = await call(commands.debugVariables(session.id, first.variablesReference));
locals = workflowSettings().debugger.sortValues ? sortVariables(vars) : vars;
}
} 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
ref={evalField}
className={styles.evalInput}
value={expression}
onChange={(e) => {
setExpression(e.target.value);
completion.onInput();
}}
onBlur={completion.close}
onKeyDown={(e) => {
if (completion.onKeyDown(e)) return;
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={tx("Evaluate expression")}
spellCheck={false}
autoComplete="off"
disabled={frame == null}
/>
{completion.popup}
<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"
? tx("The session has finished.")
: tx("Variables are available while suspended.")}
</div>
) : (
<VariablesView
roots={roots}
session={session.id}
generation={stopSeq * 100000 + (frame ?? 0)}
changes={{ epoch: stopSeq, scope: changeScope }}
aria-label="Variables"
/>
)}
</div>
);
}