/** 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, closeSessionTab, evaluate, selectFrame, selectSession, selectThread, revealFrame, rootOf, sessionSupports, useDebug, type DebugSession, } from "../state"; import { remoteWaitingNote } from "../remote"; 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 ( ); } /** 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(null); const [open, setOpen] = useState(false); return ( <> setOpen((v) => !v)} /> 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", closable: true, tooltip: statusText(s), })); return ( selectSession(+id)} onClose={(id) => void closeSessionTab(+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 ( Start a debug configuration with {" "} or click a line number to set a breakpoint. ); } return ( {( [ ["variables", "Threads & Variables"], ["console", "Console"], ] as const ).map(([id, label]) => ( setView({ session: active.startedAt, status: active.status, tab: id })} > {label} ))} {/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */} {rootOf(active).adapter === "dart" && ( <> > )} setMuted(!muted)} /> {tab === "variables" ? ( ) : ( )} ); } // --- threads & frames ---------------------------------------------------------- function ThreadsAndVariables({ session }: { session: DebugSession }) { return ( ); } 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 ( {session.threads.length > 0 && ( ({ 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} /> )} focusDebugView({ view: "frames", jumpToSource: () => { const f = session.frames.find((x) => x.id === session.selectedFrame); if (f) void revealFrame(f, true); }, }) } onBlur={() => blurDebugView("frames")} > {!paused && ( {session.status === "terminated" ? tx("Finished") : (remoteWaitingNote(rootOf(session).runConfig) ?? tx("Running…"))} )} {items.map((item) => item.kind === "hidden" ? ( setShowAll(true)} > {tx`${item.count} library frames hidden`} ) : ( 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"), ), }, ]); }} > {item.frame.name} {item.frame.path && ( , {basename(item.frame.path)} {item.frame.line >= 0 ? `:${item.frame.line + 1}` : ""} )} ), )} ); } // --- 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(null); const completion = useExpressionCompletion(evalField, expression, setExpression, session); const [lastEval, setLastEval] = useState(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 => { 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(() => { 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(); 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 ( { 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} { addWatch(expression); setExpression(""); }} /> {frame == null && !watches.length && !lastEval ? ( {session.status === "terminated" ? tx("The session has finished.") : tx("Variables are available while suspended.")} ) : ( )} ); }