дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -0,0 +1,271 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sessionTabs {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 8px 0 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-small);
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-ui);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover {
|
||||
background: var(--bg-toolbar-hover);
|
||||
}
|
||||
.tabActive,
|
||||
.tabActive:hover {
|
||||
background: var(--bg-toolbar-selected);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* frames */
|
||||
.frames {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.threadSelect {
|
||||
margin: 0 8px 4px;
|
||||
}
|
||||
|
||||
.frameList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.frame {
|
||||
height: var(--h-row);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--radius-small);
|
||||
text-align: left;
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.frame:hover {
|
||||
background: var(--bg-tree-hover);
|
||||
}
|
||||
.frameSelected,
|
||||
.frameSelected:hover {
|
||||
background: var(--bg-tree-selected-inactive);
|
||||
}
|
||||
.frameList:focus-within .frameSelected {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
.frameLibrary .frameName {
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.frameIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.frameName {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.frameFile {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* ", File.java:12" hugs the name, cancelling the row gap. */
|
||||
margin-left: -6px;
|
||||
color: var(--fg-subtle);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.hiddenFrames {
|
||||
color: var(--fg-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 4px 12px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
/* variables */
|
||||
.variables {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.evalField {
|
||||
height: 30px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 4px 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.repl {
|
||||
height: 28px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.evalInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--fg-strong);
|
||||
font: var(--fs-ui) var(--font-mono);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.evalInput::placeholder {
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.evalInput:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* console */
|
||||
.console {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.consoleOutput {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.out {
|
||||
color: var(--fg);
|
||||
}
|
||||
.cat-stderr {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cat-console,
|
||||
.cat-system {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.cat-important {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cat-input {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.cat-result {
|
||||
color: var(--fg-emphasis);
|
||||
}
|
||||
|
||||
.repl {
|
||||
margin: 0 14px 2px;
|
||||
border-bottom: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
/** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { evaluateWhen, useContextKeys } from "@/platform/context";
|
||||
import { basename, useProject } from "@/platform/project";
|
||||
import { call, commands, type Scope, type Variable } from "@/platform/ipc";
|
||||
import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
Kbd,
|
||||
Menu,
|
||||
Select,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
type TabItem,
|
||||
} from "@/ui";
|
||||
import { setMuted, useBreakpoints } from "../breakpoints/store";
|
||||
import { frameLabel, groupFrames } from "../frames";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import {
|
||||
addWatch,
|
||||
clearConsole,
|
||||
evaluate,
|
||||
isLive,
|
||||
selectFrame,
|
||||
selectSession,
|
||||
selectThread,
|
||||
appendConsole,
|
||||
revealFrame,
|
||||
sessionSupports,
|
||||
useDebug,
|
||||
type DebugSession,
|
||||
} from "../state";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import styles from "./DebugToolWindow.module.css";
|
||||
|
||||
function useActionEnabled(id: string): boolean {
|
||||
const ctx = useContextKeys();
|
||||
const a = getAction(id);
|
||||
return !!a && evaluateWhen(a.when, ctx);
|
||||
}
|
||||
|
||||
function ToolButton({ action, icon, tone }: { action: string; icon: string; tone?: "danger" }) {
|
||||
const enabled = useActionEnabled(action);
|
||||
return (
|
||||
<IconButton
|
||||
icon={icon}
|
||||
action={action}
|
||||
size="small"
|
||||
iconSize={16}
|
||||
disabled={!enabled}
|
||||
tone={tone}
|
||||
className={styles.tool}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** IDEA's `XDebugger.ToolWindow.TopToolbar3.Extra` ("More" in the debugger toolbar). */
|
||||
const MORE_ITEMS: MenuItem[] = [
|
||||
{ kind: "action", action: "debug.forceStepOver", icon: "force-step-over" },
|
||||
{ kind: "action", action: "debug.forceStepInto", icon: "force-step-into" },
|
||||
{ kind: "action", action: "debug.smartStepInto", icon: "smart-step-into" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "debug.runToCursor", icon: "run-to-cursor" },
|
||||
{ kind: "action", action: "debug.forceRunToCursor", icon: "force-run-to-cursor" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "debug.showExecutionPoint", icon: "show-execution-point" },
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "debug.evaluate", icon: "evaluate-expression" },
|
||||
{ kind: "action", action: "debug.resetFrame", icon: "reset-frame" },
|
||||
];
|
||||
|
||||
function MoreActions() {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={ref}
|
||||
icon="more"
|
||||
title="More"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
className={styles.tool}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
/>
|
||||
<Menu open={open} anchor={ref} items={MORE_ITEMS} placement="bottom-start" onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "variables" | "console";
|
||||
|
||||
function statusText(s: DebugSession): string {
|
||||
if (s.status === "terminated") return s.exitCode != null ? `Finished · exit code ${s.exitCode}` : "Finished";
|
||||
if (s.status === "starting") return "Starting…";
|
||||
return s.status === "paused" ? "Suspended" : "Running";
|
||||
}
|
||||
|
||||
/** Tool window header: one tab per debug session (`main ×`), like IDEA's content tabs. */
|
||||
export function DebugTabs() {
|
||||
const sessions = useDebug((s) => s.sessions);
|
||||
const active = useDebug((s) => s.active);
|
||||
const list = Object.values(sessions);
|
||||
if (!list.length) return null;
|
||||
const items: TabItem[] = list.map((s) => ({
|
||||
id: String(s.id),
|
||||
label: s.parent != null ? `${s.name} (child)` : s.name,
|
||||
text: s.name,
|
||||
icon: "debug",
|
||||
italic: s.status === "terminated",
|
||||
tooltip: statusText(s),
|
||||
}));
|
||||
return (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
className={styles.sessionTabs}
|
||||
aria-label="Debug sessions"
|
||||
items={items}
|
||||
activeId={active != null ? String(active) : null}
|
||||
onSelect={(id) => selectSession(+id)}
|
||||
panelIdPrefix="openidea-debug"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugToolWindow() {
|
||||
const active = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined));
|
||||
const muted = useBreakpoints((s) => s.muted);
|
||||
const [tab, setTab] = useState<Tab>("variables");
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<div className={styles.emptyWrap}>
|
||||
<EmptyState title="Not debugging">
|
||||
Start a debug configuration with <Kbd action="run.debug" /> or click a line number to set a breakpoint.
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Debugger actions">
|
||||
<div role="tablist" aria-label="Debug views" className={styles.tabs}>
|
||||
{(
|
||||
[
|
||||
["variables", "Threads & Variables"],
|
||||
["console", "Console"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`debug-tab-${id}`}
|
||||
aria-selected={tab === id}
|
||||
aria-controls={`debug-panel-${id}`}
|
||||
className={clsx(styles.tab, tab === id && styles.tabActive)}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<ToolButton action="debug.rerun" icon="rerun" />
|
||||
<ToolButton action="debug.stop" icon="stop" tone="danger" />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<ToolButton action="debug.resume" icon="resume" />
|
||||
<ToolButton action="debug.pause" icon="pause" />
|
||||
<ToolButton action="debug.stepOver" icon="step-over" />
|
||||
<ToolButton action="debug.stepInto" icon="step-into" />
|
||||
<ToolButton action="debug.stepOut" icon="step-out" />
|
||||
<span className={styles.sep} aria-hidden />
|
||||
<IconButton
|
||||
icon="view-breakpoints"
|
||||
action="debug.viewBreakpoints"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
className={styles.tool}
|
||||
/>
|
||||
<IconButton
|
||||
icon="mute-breakpoint"
|
||||
title={muted ? "Unmute Breakpoints" : "Mute Breakpoints"}
|
||||
pressed={muted}
|
||||
size="small"
|
||||
iconSize={16}
|
||||
className={styles.tool}
|
||||
onClick={() => setMuted(!muted)}
|
||||
/>
|
||||
<MoreActions />
|
||||
</div>
|
||||
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
||||
{tab === "variables" ? <ThreadsAndVariables session={active} /> : <ConsoleView session={active} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- threads & frames ----------------------------------------------------------
|
||||
|
||||
function ThreadsAndVariables({ session }: { session: DebugSession }) {
|
||||
return (
|
||||
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
|
||||
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
|
||||
<FramesPane session={session} />
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="variables" minSize="200px">
|
||||
<VariablesPane session={session} />
|
||||
</SplitPanel>
|
||||
</SplitGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function FramesPane({ session }: { session: DebugSession }) {
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const items = useMemo(
|
||||
() => groupFrames(session.frames, root, session.selectedFrame, showAll),
|
||||
[session.frames, root, session.selectedFrame, showAll],
|
||||
);
|
||||
const paused = session.status === "paused";
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
const frames = items.flatMap((i) => (i.kind === "frame" ? [i.frame] : []));
|
||||
const idx = frames.findIndex((f) => f.id === session.selectedFrame);
|
||||
const next = frames[idx + (e.key === "ArrowDown" ? 1 : -1)];
|
||||
if (next) {
|
||||
e.preventDefault();
|
||||
selectFrame(session.id, next.id);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={styles.frames}>
|
||||
{session.threads.length > 0 && (
|
||||
<Select
|
||||
value={session.selectedThread != null ? String(session.selectedThread) : null}
|
||||
options={session.threads.map((t) => ({
|
||||
value: String(t.id),
|
||||
label: t.name,
|
||||
detail: t.id === session.stoppedThread ? "suspended" : undefined,
|
||||
}))}
|
||||
onChange={(v) => void selectThread(session.id, +v)}
|
||||
aria-label="Thread"
|
||||
className={styles.threadSelect}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={styles.frameList}
|
||||
role="listbox"
|
||||
aria-label="Frames"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() =>
|
||||
focusDebugView({
|
||||
view: "frames",
|
||||
jumpToSource: () => {
|
||||
const f = session.frames.find((x) => x.id === session.selectedFrame);
|
||||
if (f) void revealFrame(f, true);
|
||||
},
|
||||
})
|
||||
}
|
||||
onBlur={() => blurDebugView("frames")}
|
||||
>
|
||||
{!paused && <div className={styles.note}>{session.status === "terminated" ? "Finished" : "Running…"}</div>}
|
||||
{items.map((item) =>
|
||||
item.kind === "hidden" ? (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={clsx(styles.frame, styles.hiddenFrames)}
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{item.count} library frames hidden
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={item.frame.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item.frame.id === session.selectedFrame}
|
||||
className={clsx(
|
||||
styles.frame,
|
||||
item.frame.id === session.selectedFrame && styles.frameSelected,
|
||||
item.library && styles.frameLibrary,
|
||||
)}
|
||||
onClick={() => selectFrame(session.id, item.frame.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
selectFrame(session.id, item.frame.id, false);
|
||||
showContextMenu(e, [
|
||||
{
|
||||
label: "Jump to Source",
|
||||
shortcut: "F4",
|
||||
disabled: !item.frame.path,
|
||||
run: () => void revealFrame(item.frame, true),
|
||||
},
|
||||
{
|
||||
label: "Reset Frame",
|
||||
icon: "reset-frame",
|
||||
disabled: !paused || !sessionSupports("restartFrame", session),
|
||||
run: () => void executeAction("debug.resetFrame"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
label: showAll ? "Hide Library Frames" : "Show All Frames",
|
||||
run: () => setShowAll((v) => !v),
|
||||
},
|
||||
{
|
||||
label: "Copy Stack",
|
||||
icon: "copy",
|
||||
run: () =>
|
||||
void navigator.clipboard?.writeText(
|
||||
session.frames
|
||||
.map((f) => `${frameLabel(f)}${f.path ? `, ${basename(f.path)}` : ""}`)
|
||||
.join("\n"),
|
||||
),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Icon name="method" size={16} className={styles.frameIcon} />
|
||||
<span className={styles.frameName}>{item.frame.name}</span>
|
||||
{item.frame.path && (
|
||||
<span className={styles.frameFile}>
|
||||
, {basename(item.frame.path)}
|
||||
{item.frame.line >= 0 ? `:${item.frame.line + 1}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- variables -----------------------------------------------------------------
|
||||
|
||||
interface EvalRow {
|
||||
expression: string;
|
||||
result: Variable | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function VariablesPane({ session }: { session: DebugSession }) {
|
||||
const watches = useDebug((s) => s.watches);
|
||||
const stopSeq = useDebug((s) => s.stopSeq);
|
||||
const [expression, setExpression] = useState("");
|
||||
const [lastEval, setLastEval] = useState<EvalRow | null>(null);
|
||||
const [scopeData, setScopeData] = useState<{ frame: number | null; scopes: Scope[]; locals: Variable[] }>({
|
||||
frame: null,
|
||||
scopes: [],
|
||||
locals: [],
|
||||
});
|
||||
const [watchData, setWatchData] = useState<{ frame: number | null; seq: number; rows: EvalRow[] }>({
|
||||
frame: null,
|
||||
seq: -1,
|
||||
rows: [],
|
||||
});
|
||||
const frame = session.status === "paused" ? session.selectedFrame : null;
|
||||
|
||||
// Scopes + first scope's variables of the selected frame.
|
||||
useEffect(() => {
|
||||
if (frame == null) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
let scopes: Scope[] = [];
|
||||
let locals: Variable[] = [];
|
||||
try {
|
||||
scopes = await call(commands.debugScopes(session.id, frame));
|
||||
const first = scopes.find((s) => !s.expensive);
|
||||
if (first) locals = await call(commands.debugVariables(session.id, first.variablesReference));
|
||||
} catch {
|
||||
// Frame went away (resumed).
|
||||
}
|
||||
if (!cancelled) setScopeData({ frame, scopes, locals });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session.id, frame, stopSeq]);
|
||||
|
||||
// Watches are re-evaluated on every stop / frame change.
|
||||
useEffect(() => {
|
||||
if (frame == null || !watches.length) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const rows = await Promise.all(
|
||||
watches.map(async (w): Promise<EvalRow> => {
|
||||
try {
|
||||
return { expression: w, result: await evaluate(w, "watch", session), error: null };
|
||||
} catch (e) {
|
||||
return { expression: w, result: null, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cancelled) setWatchData({ frame, seq: stopSeq, rows });
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `session` identity changes on every patch; frame/stopSeq capture what matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id, frame, stopSeq, watches]);
|
||||
|
||||
const roots = useMemo<VarRoot[]>(() => {
|
||||
const out: VarRoot[] = [];
|
||||
if (lastEval)
|
||||
out.push({
|
||||
key: `eval:${lastEval.expression}`,
|
||||
kind: "eval",
|
||||
name: lastEval.expression,
|
||||
variable: lastEval.result,
|
||||
error: lastEval.error,
|
||||
});
|
||||
const watchRows = watchData.frame === frame ? watchData.rows : [];
|
||||
for (const w of watches) {
|
||||
const r = watchRows.find((x) => x.expression === w);
|
||||
out.push({
|
||||
key: `watch:${w}`,
|
||||
kind: "watch",
|
||||
name: w,
|
||||
variable: r?.result ?? null,
|
||||
error: frame == null ? "Not suspended" : (r?.error ?? null),
|
||||
});
|
||||
}
|
||||
if (frame != null && scopeData.frame === frame) {
|
||||
const firstScope = scopeData.scopes.find((s) => !s.expensive);
|
||||
const seen = new Map<string, number>();
|
||||
for (const v of scopeData.locals) {
|
||||
const n = (seen.get(v.name) ?? 0) + 1;
|
||||
seen.set(v.name, n);
|
||||
out.push({
|
||||
key: `local:${v.name}${n > 1 ? `#${n}` : ""}`,
|
||||
kind: "var",
|
||||
variable: v,
|
||||
container: firstScope?.variablesReference ?? 0,
|
||||
});
|
||||
}
|
||||
for (const s of scopeData.scopes) {
|
||||
if (s === firstScope) continue;
|
||||
out.push({
|
||||
key: `scope:${s.name}`,
|
||||
kind: "scope",
|
||||
name: s.name,
|
||||
reference: s.variablesReference,
|
||||
expensive: s.expensive,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [lastEval, watches, watchData, scopeData, frame]);
|
||||
|
||||
const run = async () => {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
try {
|
||||
setLastEval({ expression: e, result: await evaluate(e, "repl", session), error: null });
|
||||
} catch (err) {
|
||||
setLastEval({ expression: e, result: null, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.variables}>
|
||||
<div className={styles.evalField}>
|
||||
<input
|
||||
className={styles.evalInput}
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addWatch(expression);
|
||||
setExpression("");
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
}}
|
||||
placeholder={frame != null ? "Evaluate expression (↩) or add a watch (⇧⌘↩)" : "Evaluate expression"}
|
||||
aria-label="Evaluate expression"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={frame == null}
|
||||
/>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add to Watches"
|
||||
shortcut="⇧⌘↩"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
disabled={!expression.trim()}
|
||||
onClick={() => {
|
||||
addWatch(expression);
|
||||
setExpression("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{frame == null && !watches.length && !lastEval ? (
|
||||
<div className={styles.note}>
|
||||
{session.status === "terminated" ? "The session has finished." : "Variables are available while suspended."}
|
||||
</div>
|
||||
) : (
|
||||
<VariablesView
|
||||
roots={roots}
|
||||
session={session.id}
|
||||
generation={stopSeq * 100000 + (frame ?? 0)}
|
||||
aria-label="Variables"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- console -------------------------------------------------------------------
|
||||
|
||||
function ConsoleView({ session }: { session: DebugSession }) {
|
||||
const lines = useDebug((s) => s.console[session.id]);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const stick = useRef(true);
|
||||
const [input, setInput] = useState("");
|
||||
const history = useRef<string[]>([]);
|
||||
const historyPos = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scroller.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
}, [lines]);
|
||||
|
||||
const submit = async () => {
|
||||
const e = input.trim();
|
||||
if (!e) return;
|
||||
history.current = [e, ...history.current.filter((h) => h !== e)].slice(0, 100);
|
||||
historyPos.current = -1;
|
||||
setInput("");
|
||||
appendConsole(session.id, "input", `> ${e}\n`);
|
||||
try {
|
||||
const v = await evaluate(e, "repl", session);
|
||||
appendConsole(session.id, "result", `${v.value}\n`);
|
||||
} catch (err) {
|
||||
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const h = history.current;
|
||||
if (!h.length) return;
|
||||
e.preventDefault();
|
||||
const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1)));
|
||||
historyPos.current = pos;
|
||||
setInput(pos < 0 ? "" : h[pos]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.console}>
|
||||
<div
|
||||
ref={scroller}
|
||||
className={styles.consoleOutput}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
|
||||
}}
|
||||
role="log"
|
||||
aria-label="Debug console"
|
||||
tabIndex={0}
|
||||
>
|
||||
{(lines ?? []).map((l) => (
|
||||
<span key={l.id} className={clsx(styles.out, styles[`cat-${l.category}`])}>
|
||||
{l.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<label className={styles.repl}>
|
||||
<span className={styles.prompt} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
<input
|
||||
className={styles.evalInput}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
|
||||
disabled={!isLive(session)}
|
||||
aria-label="Debug console input"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
title="Clear Console"
|
||||
size="small"
|
||||
iconSize={12}
|
||||
onClick={() => clearConsole(session.id)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--fs-ui);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
/* breakpoint popover */
|
||||
.bpEditor {
|
||||
width: 340px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bpTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-emphasis);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bpFile {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Long file names truncate; the actions keep their width. */
|
||||
.bpTitle > button {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bpDot {
|
||||
flex: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.bpDot[data-look="off"] {
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--danger);
|
||||
}
|
||||
.bpDot[data-look="unverified"] {
|
||||
background: var(--fg-gutter);
|
||||
}
|
||||
|
||||
.bpFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent);
|
||||
font-size: var(--fs-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* breakpoints dialog */
|
||||
.bpDialog {
|
||||
display: flex;
|
||||
height: 420px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bpListPane {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-popup);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bpListHeader {
|
||||
height: var(--h-header);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px 0 12px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
}
|
||||
|
||||
.bpList {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bpRow,
|
||||
.chooserRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.bpName {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-emphasis);
|
||||
}
|
||||
|
||||
.bpDir {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.bpCond {
|
||||
flex: none;
|
||||
color: var(--syn-keyword);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
|
||||
.bpDetail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.statusLine {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
|
||||
/* evaluate dialog */
|
||||
.evalBody {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.evalResult {
|
||||
height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-popup);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 4px 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chooser {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.exceptions {
|
||||
flex: none;
|
||||
max-height: 40%;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--border-popup);
|
||||
padding: 6px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.exceptionsTitle {
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
color: var(--fg-muted);
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
.exceptionsEmpty {
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.exceptionGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.exceptionAdapter {
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.exceptionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.attachList {
|
||||
height: 360px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.attachPid {
|
||||
min-width: 48px;
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
/** Debugger popovers and dialogs: breakpoint properties, View Breakpoints, Evaluate, config chooser. */
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { basename, relativePath, useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, type AttachableProcess, type RunConfig, type Variable } from "@/platform/ipc";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Popup, Toggle } from "@/ui";
|
||||
import { isConditional, type BreakpointEntry } from "../breakpoints/model";
|
||||
import {
|
||||
removeAllBreakpoints,
|
||||
removeBreakpoint,
|
||||
setMuted,
|
||||
updateBreakpoint,
|
||||
useBreakpoints,
|
||||
} from "../breakpoints/store";
|
||||
import { activeSession, addWatch, evaluate, isLive, startSession, useDebug } from "../state";
|
||||
import {
|
||||
addMethodBreakpoint,
|
||||
removeAllSpecial,
|
||||
removeSpecial,
|
||||
updateSpecial,
|
||||
useSpecialBreakpoints,
|
||||
type SpecialBreakpoint,
|
||||
} from "../breakpoints/special";
|
||||
import { attachConfig, attachDetail, attachRows, type AttachRow } from "../attach";
|
||||
import {
|
||||
exceptionGroups,
|
||||
refreshExceptionFilters,
|
||||
setExceptionFilter,
|
||||
useExceptionBreakpoints,
|
||||
} from "../breakpoints/exceptions";
|
||||
import { closeBreakpointPopover, useOverlays } from "./popoverStore";
|
||||
import { VariablesView, type VarRoot } from "./VariablesView";
|
||||
import styles from "./Overlays.module.css";
|
||||
|
||||
export function DebuggerOverlays() {
|
||||
return (
|
||||
<>
|
||||
<BreakpointPopover />
|
||||
<BreakpointsDialog />
|
||||
<EvaluateDialog />
|
||||
<ConfigChooser />
|
||||
<AttachPopup />
|
||||
<SmartStepChooser />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// --- breakpoint properties popover ------------------------------------------
|
||||
|
||||
function BreakpointPopover() {
|
||||
const popover = useOverlays((s) => s.popover);
|
||||
const bp = useBreakpoints((s) => (popover ? s.byFile[popover.path]?.find((b) => b.id === popover.id) : undefined));
|
||||
const special = useSpecialBreakpoints((s) =>
|
||||
popover ? [...s.methods, ...s.fields].find((b) => b.id === popover.id) : undefined,
|
||||
);
|
||||
if (popover && special) return <SpecialEditor key={special.id} bp={special} at={popover} />;
|
||||
if (!popover || !bp) return null;
|
||||
return <BreakpointEditor key={popover.id} path={popover.path} bp={bp} at={popover} focusLog={popover.focusLog} />;
|
||||
}
|
||||
|
||||
function BreakpointEditor({
|
||||
path,
|
||||
bp,
|
||||
at,
|
||||
focusLog,
|
||||
}: {
|
||||
path: string;
|
||||
bp: BreakpointEntry;
|
||||
at: { x: number; y: number };
|
||||
focusLog: boolean;
|
||||
}) {
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
const [logEnabled, setLogEnabled] = useState(focusLog || !!bp.logMessage);
|
||||
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
|
||||
const draft = useRef({ condition, hitCondition, logEnabled, logMessage });
|
||||
useEffect(() => {
|
||||
draft.current = { condition, hitCondition, logEnabled, logMessage };
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
const d = draft.current;
|
||||
updateBreakpoint(path, bp.id, {
|
||||
condition: d.condition.trim() || null,
|
||||
hitCondition: d.hitCondition.trim() || null,
|
||||
logMessage: d.logEnabled ? d.logMessage.trim() || null : null,
|
||||
});
|
||||
};
|
||||
const close = () => {
|
||||
commit();
|
||||
closeBreakpointPopover();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup open onClose={close} anchor={at} placement="bottom-start" trapFocus aria-label="Breakpoint properties">
|
||||
<div className={styles.bpEditor}>
|
||||
<div className={styles.bpTitle}>
|
||||
<span className={styles.bpDot} data-look={bp.enabled ? "on" : "off"} aria-hidden />
|
||||
<span className={styles.bpFile}>
|
||||
{basename(path)}:{bp.line + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={bp.enabled}
|
||||
onChange={(enabled) => updateBreakpoint(path, bp.id, { enabled })}
|
||||
label="Enabled"
|
||||
/>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="e.g. order.total > 100"
|
||||
data-autofocus={focusLog ? undefined : ""}
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="e.g. 5 or >= 3"
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
<Checkbox checked={logEnabled} onChange={setLogEnabled} label="Log message instead of suspending" />
|
||||
{logEnabled && (
|
||||
<Input
|
||||
mono
|
||||
value={logMessage}
|
||||
onChange={(e) => setLogMessage(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="Value: {expression}"
|
||||
data-autofocus={focusLog ? "" : undefined}
|
||||
aria-label="Log message"
|
||||
/>
|
||||
)}
|
||||
<div className={styles.bpFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => {
|
||||
close();
|
||||
void executeAction("debug.viewBreakpoints");
|
||||
}}
|
||||
>
|
||||
More <Kbd action="debug.viewBreakpoints" />
|
||||
</button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
removeBreakpoint(path, bp.id);
|
||||
closeBreakpointPopover();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function specialTitle(b: SpecialBreakpoint): string {
|
||||
return b.kind === "method" ? b.name || "(no method)" : b.label;
|
||||
}
|
||||
|
||||
/** Properties popover of a method breakpoint / field watchpoint (right-click on its gutter icon). */
|
||||
function SpecialEditor({ bp, at }: { bp: SpecialBreakpoint; at: { x: number; y: number } }) {
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
const close = () => {
|
||||
updateSpecial(bp.id, { condition: condition.trim() || null, hitCondition: hitCondition.trim() || null });
|
||||
closeBreakpointPopover();
|
||||
};
|
||||
return (
|
||||
<Popup open onClose={close} anchor={at} placement="bottom-start" trapFocus aria-label="Breakpoint properties">
|
||||
<div className={styles.bpEditor}>
|
||||
<div className={styles.bpTitle}>
|
||||
<Icon name={bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
|
||||
<span className={styles.bpFile}>{specialTitle(bp)}</span>
|
||||
</div>
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => updateSpecial(bp.id, { enabled })} label="Enabled" />
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && close()}
|
||||
data-autofocus=""
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && close()}
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.bpFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
onClick={() => {
|
||||
close();
|
||||
void executeAction("debug.viewBreakpoints");
|
||||
}}
|
||||
>
|
||||
More <Kbd action="debug.viewBreakpoints" />
|
||||
</button>
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
removeSpecial(bp.id);
|
||||
closeBreakpointPopover();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<Button size="small" variant="primary" onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// --- View Breakpoints dialog --------------------------------------------------
|
||||
|
||||
interface BpRow {
|
||||
path: string;
|
||||
bp: BreakpointEntry;
|
||||
}
|
||||
|
||||
type DialogRow =
|
||||
| { type: "section"; key: string; label: string }
|
||||
| ({ type: "line"; key: string } & BpRow)
|
||||
| { type: "special"; key: string; bp: SpecialBreakpoint };
|
||||
|
||||
function BreakpointsDialog() {
|
||||
const state = useOverlays((s) => s.breakpoints);
|
||||
const byFile = useBreakpoints((s) => s.byFile);
|
||||
const status = useBreakpoints((s) => s.status);
|
||||
const muted = useBreakpoints((s) => s.muted);
|
||||
const methods = useSpecialBreakpoints((s) => s.methods);
|
||||
const fields = useSpecialBreakpoints((s) => s.fields);
|
||||
const specialStatus = useSpecialBreakpoints((s) => s.status);
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const rows = useMemo<DialogRow[]>(() => {
|
||||
const lines: DialogRow[] = Object.keys(byFile)
|
||||
.sort()
|
||||
.flatMap((path) => byFile[path]!.map((bp) => ({ type: "line" as const, key: bp.id, path, bp })));
|
||||
const out: DialogRow[] = [];
|
||||
const section = (label: string, list: DialogRow[]) => {
|
||||
if (list.length) out.push({ type: "section", key: `section:${label}`, label }, ...list);
|
||||
};
|
||||
section("Line Breakpoints", lines);
|
||||
section("Method Breakpoints", methods.map((bp) => ({ type: "special" as const, key: bp.id, bp })));
|
||||
section("Field Watchpoints", fields.map((bp) => ({ type: "special" as const, key: bp.id, bp })));
|
||||
return out;
|
||||
}, [byFile, methods, fields]);
|
||||
const items = rows.filter((r) => r.type !== "section");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const open = !!state;
|
||||
const current = items.find((r) => r.key === (selected ?? state?.id)) ?? items[0];
|
||||
const close = () => {
|
||||
setSelected(null);
|
||||
useOverlays.setState({ breakpoints: null });
|
||||
};
|
||||
const goTo = (r: DialogRow) => {
|
||||
const path = r.type === "line" ? r.path : r.type === "special" ? r.bp.path : null;
|
||||
const line = r.type === "line" ? r.bp.line : r.type === "special" ? r.bp.line : null;
|
||||
if (!path || line == null) return;
|
||||
close();
|
||||
void maybeEditorService()?.openFile(path, { selection: { line, col: 0 } });
|
||||
};
|
||||
const removeAll = () => {
|
||||
removeAllBreakpoints();
|
||||
removeAllSpecial();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Breakpoints"
|
||||
width={760}
|
||||
aria-label="Breakpoints"
|
||||
buttons={[{ label: "Done", role: "default", onClick: close }]}
|
||||
>
|
||||
<div className={styles.bpDialog}>
|
||||
<div className={styles.bpListPane}>
|
||||
<div className={styles.bpListHeader}>
|
||||
<Toggle checked={muted} onChange={setMuted} label="Mute breakpoints" />
|
||||
<div className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add Method Breakpoint"
|
||||
size="small"
|
||||
onClick={() => setSelected(addMethodBreakpoint("").id)}
|
||||
/>
|
||||
<IconButton icon="trash" title="Remove All Breakpoints" size="small" disabled={!items.length} onClick={removeAll} />
|
||||
</div>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
isSection={(r) => r.type === "section"}
|
||||
renderSection={(r) => <div className={styles.exceptionAdapter}>{r.type === "section" ? r.label : ""}</div>}
|
||||
activeIndex={current ? rows.indexOf(current) : -1}
|
||||
onActiveChange={(i) => setSelected(rows[i]?.key ?? null)}
|
||||
onActivate={(r) => goTo(r)}
|
||||
aria-label="All breakpoints"
|
||||
className={styles.bpList}
|
||||
empty={<div className={styles.empty}>No breakpoints. Click a line number to add one.</div>}
|
||||
renderItem={(r) => {
|
||||
if (r.type === "section") return null;
|
||||
if (r.type === "special") {
|
||||
const st = specialStatus[r.bp.id];
|
||||
return (
|
||||
<div className={styles.bpRow}>
|
||||
<Checkbox
|
||||
checked={r.bp.enabled}
|
||||
onChange={(enabled) => updateSpecial(r.bp.id, { enabled })}
|
||||
aria-label={`Enable ${specialTitle(r.bp)}`}
|
||||
/>
|
||||
<Icon name={r.bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
|
||||
<span className={styles.bpName}>{specialTitle(r.bp)}</span>
|
||||
{r.bp.path && (
|
||||
<span className={styles.bpDir}>
|
||||
{basename(r.bp.path)}
|
||||
{r.bp.line != null ? `:${r.bp.line + 1}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{st && !st.verified && isLive(activeSession()) && <span className={styles.bpCond}>unverified</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const st = status[r.bp.id];
|
||||
return (
|
||||
<div className={styles.bpRow}>
|
||||
<Checkbox
|
||||
checked={r.bp.enabled}
|
||||
onChange={(enabled) => updateBreakpoint(r.path, r.bp.id, { enabled })}
|
||||
aria-label={`Enable breakpoint at ${basename(r.path)}:${r.bp.line + 1}`}
|
||||
/>
|
||||
<span
|
||||
className={styles.bpDot}
|
||||
data-look={
|
||||
!r.bp.enabled || muted
|
||||
? "off"
|
||||
: st && !st.verified && isLive(activeSession())
|
||||
? "unverified"
|
||||
: "on"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={styles.bpName}>
|
||||
{basename(r.path)}:{r.bp.line + 1}
|
||||
</span>
|
||||
<span className={styles.bpDir}>{relativePath(root, r.path)}</span>
|
||||
{isConditional(r.bp) && <span className={styles.bpCond}>{r.bp.logMessage ? "log" : "if"}</span>}
|
||||
{r.bp.temporary && <span className={styles.bpCond}>temp</span>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<ExceptionBreakpoints open={open} />
|
||||
</div>
|
||||
<div className={styles.bpDetail}>
|
||||
{current?.type === "line" ? (
|
||||
<BreakpointDetail key={current.key} row={current} onGo={() => goTo(current)} />
|
||||
) : current?.type === "special" ? (
|
||||
<SpecialDetail key={current.key} bp={current.bp} onGo={() => goTo(current)} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Exception breakpoint filters offered by the debug adapters (checkbox per filter). */
|
||||
function ExceptionBreakpoints({ open }: { open: boolean }) {
|
||||
const known = useExceptionBreakpoints((s) => s.known);
|
||||
const choices = useExceptionBreakpoints((s) => s.choices);
|
||||
const groups = useMemo(() => exceptionGroups(known, choices), [known, choices]);
|
||||
useEffect(() => {
|
||||
if (open) void refreshExceptionFilters();
|
||||
}, [open]);
|
||||
return (
|
||||
<section className={styles.exceptions} aria-label="Exception breakpoints">
|
||||
<div className={styles.exceptionsTitle}>Exception Breakpoints</div>
|
||||
{groups.length === 0 ? (
|
||||
<div className={styles.exceptionsEmpty}>Start a debug session to see the exception filters of its adapter.</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<div key={g.adapter} className={styles.exceptionGroup} role="group" aria-label={`${g.adapter} exception filters`}>
|
||||
{groups.length > 1 && <div className={styles.exceptionAdapter}>{g.adapter}</div>}
|
||||
{g.filters.map((f) => (
|
||||
<span key={f.filter} title={f.description ?? undefined} className={styles.exceptionRow}>
|
||||
<Checkbox checked={f.enabled} onChange={(on) => setExceptionFilter(g.adapter, f.filter, on)} label={f.label} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
|
||||
const { path, bp } = row;
|
||||
const st = useBreakpoints((s) => s.status[bp.id]);
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
|
||||
const commit = (patch: Partial<BreakpointEntry>) => updateBreakpoint(path, bp.id, patch);
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.bpTitle}>
|
||||
<span className={styles.bpFile}>
|
||||
{basename(path)}:{bp.line + 1}
|
||||
</span>
|
||||
<div className={styles.spacer} />
|
||||
<Button size="small" variant="ghost" onClick={onGo}>
|
||||
Go to Source
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => removeBreakpoint(path, bp.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => commit({ enabled })} label="Enabled" />
|
||||
<Checkbox checked={!!bp.temporary} onChange={(temporary) => commit({ temporary })} label="Remove once hit" />
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onBlur={() => commit({ condition: condition.trim() || null })}
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onBlur={() => commit({ hitCondition: hitCondition.trim() || null })}
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Log message</span>
|
||||
<Input
|
||||
mono
|
||||
value={logMessage}
|
||||
onChange={(e) => setLogMessage(e.target.value)}
|
||||
onBlur={() => commit({ logMessage: logMessage.trim() || null })}
|
||||
placeholder="Suspends when empty"
|
||||
aria-label="Log message"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialDetail({ bp, onGo }: { bp: SpecialBreakpoint; onGo: () => void }) {
|
||||
const st = useSpecialBreakpoints((s) => s.status[bp.id]);
|
||||
const [name, setName] = useState(bp.kind === "method" ? bp.name : bp.label);
|
||||
const [condition, setCondition] = useState(bp.condition ?? "");
|
||||
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.bpTitle}>
|
||||
<Icon name={bp.kind === "method" ? "breakpoint-method" : "breakpoint-field"} size={14} />
|
||||
<span className={styles.bpFile}>{specialTitle(bp)}</span>
|
||||
<div className={styles.spacer} />
|
||||
{bp.path && (
|
||||
<Button size="small" variant="ghost" onClick={onGo}>
|
||||
Go to Source
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="ghost" onClick={() => removeSpecial(bp.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
|
||||
<Checkbox checked={bp.enabled} onChange={(enabled) => updateSpecial(bp.id, { enabled })} label="Enabled" />
|
||||
{bp.kind === "method" ? (
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Method</span>
|
||||
<Input
|
||||
mono
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => updateSpecial(bp.id, { name: name.trim() })}
|
||||
placeholder="com.example.Service#process, main.run, parse"
|
||||
data-autofocus={bp.name ? undefined : ""}
|
||||
aria-label="Method name"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.statusLine}>Watching {bp.dataId}</div>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Suspend on</span>
|
||||
<span className={styles.exceptionGroup}>
|
||||
<Checkbox
|
||||
checked={bp.accessType !== "read"}
|
||||
onChange={(on) =>
|
||||
updateSpecial(bp.id, { accessType: on ? (bp.accessType === "read" ? "readWrite" : "write") : "read" })
|
||||
}
|
||||
label="Field modification"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={bp.accessType === "read" || bp.accessType === "readWrite"}
|
||||
onChange={(on) => updateSpecial(bp.id, { accessType: on ? "readWrite" : "write" })}
|
||||
label="Field access"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Condition</span>
|
||||
<Input
|
||||
mono
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
onBlur={() => updateSpecial(bp.id, { condition: condition.trim() || null })}
|
||||
aria-label="Condition"
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Hit count</span>
|
||||
<Input
|
||||
mono
|
||||
value={hitCondition}
|
||||
onChange={(e) => setHitCondition(e.target.value)}
|
||||
onBlur={() => updateSpecial(bp.id, { hitCondition: hitCondition.trim() || null })}
|
||||
aria-label="Hit count condition"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Evaluate dialog -----------------------------------------------------------
|
||||
|
||||
function EvaluateDialog() {
|
||||
const state = useOverlays((s) => s.evaluate);
|
||||
if (!state) return null;
|
||||
return <EvaluateDialogInner initial={state.expression} />;
|
||||
}
|
||||
|
||||
function EvaluateDialogInner({ initial }: { initial: string }) {
|
||||
const [expression, setExpression] = useState(initial);
|
||||
const [result, setResult] = useState<VarRoot | null>(null);
|
||||
const session = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined));
|
||||
const paused = session?.status === "paused";
|
||||
const close = () => useOverlays.setState({ evaluate: null });
|
||||
const run = async () => {
|
||||
const e = expression.trim();
|
||||
if (!e) return;
|
||||
try {
|
||||
const v: Variable = await evaluate(e, "repl", session ?? null);
|
||||
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: v, error: null });
|
||||
} catch (err) {
|
||||
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: null, error: errorText(err) });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={close}
|
||||
title="Evaluate"
|
||||
width={620}
|
||||
aria-label="Evaluate expression"
|
||||
footerStart={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
addWatch(expression);
|
||||
close();
|
||||
}}
|
||||
disabled={!expression.trim()}
|
||||
>
|
||||
Add to Watches
|
||||
</Button>
|
||||
}
|
||||
buttons={[
|
||||
{ label: "Evaluate", role: "default", disabled: !paused || !expression.trim(), onClick: () => void run() },
|
||||
{ label: "Close", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<div className={styles.evalBody}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.label}>Expression</span>
|
||||
<Input
|
||||
mono
|
||||
value={expression}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void run();
|
||||
}
|
||||
}}
|
||||
data-autofocus=""
|
||||
aria-label="Expression"
|
||||
placeholder={paused ? "Expression to evaluate in the selected frame" : "Pause the program to evaluate"}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.evalResult}>
|
||||
{result ? (
|
||||
<VariablesView roots={[result]} session={session?.id ?? null} aria-label="Evaluation result" />
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
{paused ? "Press Enter to evaluate" : "Evaluation is available while the program is suspended"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- debug configuration chooser -----------------------------------------------
|
||||
|
||||
function ConfigChooser() {
|
||||
const state = useOverlays((s) => s.chooser);
|
||||
const close = () => useOverlays.setState({ chooser: null });
|
||||
const pick = (c: RunConfig) => {
|
||||
close();
|
||||
void executeAction("debug.startConfig", c);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={!!state}
|
||||
onClose={close}
|
||||
title="Debug"
|
||||
width={420}
|
||||
position="top"
|
||||
aria-label="Choose configuration to debug"
|
||||
>
|
||||
<List
|
||||
items={state?.configs ?? []}
|
||||
getKey={(c) => c.id}
|
||||
defaultActiveIndex={0}
|
||||
onActivate={(c) => pick(c)}
|
||||
aria-label="Debuggable configurations"
|
||||
className={styles.chooser}
|
||||
renderItem={(c) => (
|
||||
<div className={styles.chooserRow}>
|
||||
<Icon name="debug" size={14} />
|
||||
<span className={styles.bpName}>{c.name}</span>
|
||||
<span className={styles.bpDir}>{c.debugAdapter}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Attach to Process ------------------------------------------------------------
|
||||
|
||||
function AttachPopup() {
|
||||
const state = useOverlays((s) => s.attach);
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const rows = useMemo(() => attachRows(state?.processes ?? [], filter), [state?.processes, filter]);
|
||||
const firstItem = rows.findIndex((r) => r.kind === "process");
|
||||
const activeIndex = rows[active]?.kind === "process" ? active : firstItem;
|
||||
if (!state) return null;
|
||||
const close = () => {
|
||||
setFilter("");
|
||||
setActive(0);
|
||||
useOverlays.setState({ attach: null });
|
||||
};
|
||||
const attach = async (p: AttachableProcess) => {
|
||||
close();
|
||||
if (!root) return;
|
||||
try {
|
||||
await startSession(await attachConfig(p, root), { rerunnable: false });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
key: "debug.attach",
|
||||
title: `Cannot attach to ${p.name} (${p.pid})`,
|
||||
body: errorText(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
const move = (dir: 1 | -1) => {
|
||||
for (let i = activeIndex + dir; i >= 0 && i < rows.length; i += dir) {
|
||||
if (rows[i]!.kind === "process") {
|
||||
setActive(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={close} title="Attach to Process" width={640} position="top" aria-label="Attach to process">
|
||||
<div className={styles.evalBody}>
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
setActive(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
move(e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const row = rows[activeIndex];
|
||||
if (row?.kind === "process") void attach(row.process);
|
||||
}
|
||||
}}
|
||||
placeholder="Filter by name, PID or command line"
|
||||
data-autofocus=""
|
||||
aria-label="Filter processes"
|
||||
/>
|
||||
{state.processes == null ? (
|
||||
<div className={styles.empty}>Loading processes…</div>
|
||||
) : state.error ? (
|
||||
<div className={styles.empty}>{state.error}</div>
|
||||
) : (
|
||||
<List<AttachRow>
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
isSection={(r) => r.kind === "group"}
|
||||
renderSection={(r) =>
|
||||
r.kind === "group" ? (
|
||||
<div className={styles.exceptionAdapter}>
|
||||
{r.label} · {r.count}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(r) => r.kind === "process" && void attach(r.process)}
|
||||
focusable={false}
|
||||
aria-label="Processes"
|
||||
className={styles.attachList}
|
||||
empty={<div className={styles.empty}>No attachable processes{filter ? " match the filter" : ""}.</div>}
|
||||
renderItem={(r) =>
|
||||
r.kind === "process" ? (
|
||||
<div className={styles.chooserRow} title={r.process.commandLine}>
|
||||
<span className={styles.attachPid}>{r.process.pid}</span>
|
||||
<span className={styles.bpName}>{r.process.name}</span>
|
||||
<span className={styles.bpDir}>{r.process.commandLine}</span>
|
||||
<span className={styles.bpCond}>{attachDetail(r.process)}</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Smart Step Into -------------------------------------------------------------
|
||||
|
||||
function SmartStepChooser() {
|
||||
const state = useOverlays((s) => s.smartStep);
|
||||
const close = () => useOverlays.setState({ smartStep: null });
|
||||
const pick = (targetId: number) => {
|
||||
if (!state) return;
|
||||
close();
|
||||
void call(commands.debugStepInTarget(state.session, state.thread, targetId)).catch((e) =>
|
||||
notifications().notify({ group: "run", level: "error", title: "Smart Step Into failed", body: errorText(e) }),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Dialog open={!!state} onClose={close} title="Step Into" width={420} position="top" aria-label="Smart Step Into">
|
||||
<List
|
||||
items={state?.targets ?? []}
|
||||
getKey={(t) => String(t.id)}
|
||||
defaultActiveIndex={0}
|
||||
onActivate={(t) => pick(t.id)}
|
||||
aria-label="Calls on the current line"
|
||||
className={styles.chooser}
|
||||
renderItem={(t) => (
|
||||
<div className={styles.chooserRow}>
|
||||
<Icon name="method" size={14} />
|
||||
<span className={styles.bpName}>{t.label}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-ui);
|
||||
white-space: pre;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.varName {
|
||||
color: var(--debug-name, var(--syn-property));
|
||||
}
|
||||
|
||||
.nodeIcon {
|
||||
flex: none;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
.eq {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.number {
|
||||
color: var(--syn-number);
|
||||
}
|
||||
.string {
|
||||
color: var(--syn-string);
|
||||
}
|
||||
.keyword {
|
||||
color: var(--syn-keyword);
|
||||
}
|
||||
.object {
|
||||
color: var(--fg);
|
||||
}
|
||||
.plain {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.type {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.scope {
|
||||
color: var(--fg);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
|
||||
|
||||
.editInput {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font: inherit;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg-strong);
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-small);
|
||||
outline: none;
|
||||
}
|
||||
.editInvalid {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/** Lazy variables tree (scopes, locals, watches, evaluation results). */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, type DebugSessionId, type Variable } from "@/platform/ipc";
|
||||
import { Icon, Tree, acquirePopup, showContextMenu, type MenuItem, type TreeHandle } from "@/ui";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { addFieldWatchpoint } from "../breakpoints/special";
|
||||
import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue } from "../values";
|
||||
import styles from "./VariablesView.module.css";
|
||||
|
||||
export type VarRoot =
|
||||
| { key: string; kind: "eval" | "watch"; name: string; variable: Variable | null; error: string | null }
|
||||
| { key: string; kind: "scope"; name: string; reference: number; expensive: boolean }
|
||||
/** `container`: variablesReference of the scope / object holding the variable (for Set Value). */
|
||||
| { key: string; kind: "var"; variable: Variable; container?: number };
|
||||
|
||||
interface VarNode {
|
||||
key: string;
|
||||
kind: "eval" | "watch" | "scope" | "var" | "message";
|
||||
name: string;
|
||||
value: string;
|
||||
typeName: string | null;
|
||||
reference: number;
|
||||
evaluateName: string | null;
|
||||
error: string | null;
|
||||
depth0: boolean;
|
||||
/** variablesReference of the parent (0 when unknown). */
|
||||
container: number;
|
||||
}
|
||||
|
||||
function fromRoot(r: VarRoot): VarNode {
|
||||
switch (r.kind) {
|
||||
case "scope":
|
||||
return {
|
||||
key: r.key,
|
||||
kind: "scope",
|
||||
name: r.name,
|
||||
value: "",
|
||||
typeName: null,
|
||||
reference: r.reference,
|
||||
evaluateName: null,
|
||||
error: null,
|
||||
depth0: true,
|
||||
container: 0,
|
||||
};
|
||||
case "var":
|
||||
return fromVariable(r.key, r.variable, r.container ?? 0, true);
|
||||
default:
|
||||
return {
|
||||
key: r.key,
|
||||
kind: r.kind,
|
||||
name: r.name,
|
||||
value: r.variable?.value ?? "",
|
||||
typeName: r.variable?.typeName ?? null,
|
||||
reference: r.variable?.variablesReference ?? 0,
|
||||
evaluateName: r.variable?.evaluateName ?? r.name,
|
||||
error: r.error,
|
||||
depth0: true,
|
||||
container: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function fromVariable(key: string, v: Variable, container: number, depth0 = false): VarNode {
|
||||
return {
|
||||
key,
|
||||
kind: "var",
|
||||
name: v.name,
|
||||
value: v.value,
|
||||
typeName: v.typeName,
|
||||
reference: v.variablesReference,
|
||||
evaluateName: v.evaluateName,
|
||||
error: null,
|
||||
depth0,
|
||||
container,
|
||||
};
|
||||
}
|
||||
|
||||
function message(key: string, text: string): VarNode {
|
||||
return {
|
||||
key,
|
||||
kind: "message",
|
||||
name: text,
|
||||
value: "",
|
||||
typeName: null,
|
||||
reference: 0,
|
||||
evaluateName: null,
|
||||
error: null,
|
||||
depth0: false,
|
||||
container: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VariablesViewProps {
|
||||
roots: VarRoot[];
|
||||
session: DebugSessionId | null;
|
||||
/** Changing it drops loaded children (new stop); expansion is kept and re-loaded. */
|
||||
generation?: number;
|
||||
"aria-label"?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VariablesView({ roots, session, generation = 0, className, ...aria }: VariablesViewProps) {
|
||||
const [cache, setCache] = useState<{ gen: number; map: Map<string, VarNode[]> }>({ gen: generation, map: new Map() });
|
||||
const children = cache.gen === generation ? cache.map : null;
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const loading = useRef(new Set<string>());
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
|
||||
const nodes = useMemo(() => roots.map(fromRoot), [roots]);
|
||||
const byKey = useMemo(() => {
|
||||
const m = new Map<string, VarNode>();
|
||||
const walk = (list: readonly VarNode[]) =>
|
||||
list.forEach((n) => {
|
||||
m.set(n.key, n);
|
||||
const c = children?.get(n.key);
|
||||
if (c) walk(c);
|
||||
});
|
||||
walk(nodes);
|
||||
return m;
|
||||
}, [nodes, children]);
|
||||
|
||||
const load = useCallback(
|
||||
async (node: VarNode) => {
|
||||
if (session == null || node.reference <= 0) return;
|
||||
const tag = `${generation}:${node.key}`;
|
||||
if (loading.current.has(tag)) return;
|
||||
loading.current.add(tag);
|
||||
let list: VarNode[];
|
||||
try {
|
||||
const vars = await call(commands.debugVariables(session, node.reference));
|
||||
const seen = new Map<string, number>();
|
||||
list = vars.map((v) => {
|
||||
const n = (seen.get(v.name) ?? 0) + 1;
|
||||
seen.set(v.name, n);
|
||||
return fromVariable(`${node.key}/${v.name}${n > 1 ? `#${n}` : ""}`, v, node.reference);
|
||||
});
|
||||
if (!list.length) list = [message(`${node.key}/\u0000empty`, "No variables")];
|
||||
} catch (e) {
|
||||
list = [message(`${node.key}/\u0000error`, e instanceof Error ? e.message : String(e))];
|
||||
} finally {
|
||||
loading.current.delete(tag);
|
||||
}
|
||||
setCache((c) => {
|
||||
const map = new Map(c.gen === generation ? c.map : []);
|
||||
map.set(node.key, list);
|
||||
return { gen: generation, map };
|
||||
});
|
||||
},
|
||||
[session, generation],
|
||||
);
|
||||
|
||||
// Re-load children of nodes that stay expanded across steps (and non-expensive scopes on first show).
|
||||
useEffect(() => {
|
||||
for (const n of byKey.values()) {
|
||||
const wants = expanded.has(n.key);
|
||||
if (wants && n.reference > 0 && !children?.has(n.key)) void load(n);
|
||||
}
|
||||
}, [byKey, expanded, children, load]);
|
||||
|
||||
const [editing, setEditing] = useState<{ key: string; value: string; error: string | null } | null>(null);
|
||||
const editable = (n: VarNode | undefined): n is VarNode =>
|
||||
!!n && session != null && (n.kind === "var" || n.kind === "watch") && (n.container > 0 || !!n.evaluateName);
|
||||
const startEdit = (n: VarNode) => setEditing({ key: n.key, value: n.value, error: null });
|
||||
const commitEdit = async () => {
|
||||
const e = editing;
|
||||
const node = e ? byKey.get(e.key) : undefined;
|
||||
if (!e || !node) return setEditing(null);
|
||||
if (e.value === node.value) {
|
||||
setEditing(null);
|
||||
tree.current?.focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setVariableValue({ container: node.container, name: node.name, evaluateName: node.evaluateName }, e.value);
|
||||
setEditing(null);
|
||||
tree.current?.focus();
|
||||
} catch (err) {
|
||||
setEditing({ ...e, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const leadNode = () => {
|
||||
const lead = tree.current?.leadKey();
|
||||
return lead ? byKey.get(lead) : undefined;
|
||||
};
|
||||
const viewCommands = () => ({
|
||||
view: "variables" as const,
|
||||
setValue: () => {
|
||||
const node = leadNode();
|
||||
if (editable(node)) startEdit(node);
|
||||
},
|
||||
jumpToSource: () => {
|
||||
const node = leadNode();
|
||||
if (node) void jumpToDeclaration(node);
|
||||
},
|
||||
});
|
||||
const canWatch = (n: VarNode) =>
|
||||
session != null && n.kind === "var" && n.container > 0 && sessionSupports("dataBreakpoints");
|
||||
|
||||
const onContextMenu = (node: VarNode | null, _sel: Set<string>, e: React.MouseEvent) => {
|
||||
if (!node || node.kind === "message") return;
|
||||
e.preventDefault();
|
||||
const items: MenuItem[] = [];
|
||||
if (editable(node)) items.push({ label: "Set Value…", icon: "edit", shortcut: "F2", run: () => startEdit(node) }, { kind: "separator" });
|
||||
if (node.kind === "watch") items.push({ label: "Remove Watch", icon: "minus", run: () => removeWatch(node.name) });
|
||||
if (node.kind !== "scope") {
|
||||
items.push({ label: "Copy Value", icon: "copy", run: () => void navigator.clipboard?.writeText(node.value) });
|
||||
items.push({ label: "Copy Name", run: () => void navigator.clipboard?.writeText(node.name) });
|
||||
if (node.kind === "var" && node.evaluateName)
|
||||
items.push({ label: "Add to Watches", icon: "eye", run: () => addWatch(node.evaluateName!) });
|
||||
if (canWatch(node))
|
||||
items.push({
|
||||
label: "Add Field Watchpoint",
|
||||
icon: "breakpoint-field",
|
||||
run: () => void addWatchpointFor(session!, node),
|
||||
});
|
||||
items.push({ kind: "separator" }, { label: "Jump to Source", shortcut: "F4", run: () => void jumpToDeclaration(node) });
|
||||
}
|
||||
if (items.length) showContextMenu(e, items);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (editing) return;
|
||||
const lead = tree.current?.leadKey();
|
||||
const node = lead ? byKey.get(lead) : undefined;
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (node?.kind === "watch") {
|
||||
e.preventDefault();
|
||||
removeWatch(node.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.root, className)}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => focusDebugView(viewCommands())}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) blurDebugView("variables");
|
||||
}}
|
||||
>
|
||||
<Tree<VarNode>
|
||||
roots={nodes}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.name}
|
||||
getChildren={(n) => children?.get(n.key)}
|
||||
isExpandable={(n) => n.reference > 0}
|
||||
loadChildren={(n) => load(n)}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(next) => setExpanded(next)}
|
||||
multiSelect={false}
|
||||
onContextMenu={onContextMenu}
|
||||
handleRef={tree}
|
||||
aria-label={aria["aria-label"] ?? "Variables"}
|
||||
renderNode={(n) =>
|
||||
editing?.key === n.key ? (
|
||||
<VarEditRow
|
||||
node={n}
|
||||
value={editing.value}
|
||||
error={editing.error}
|
||||
onChange={(value) => setEditing({ ...editing, value, error: null })}
|
||||
onCommit={() => void commitEdit()}
|
||||
onCancel={() => {
|
||||
setEditing(null);
|
||||
tree.current?.focus();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<VarRow node={n} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VarEditRow({
|
||||
node,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
node: VarNode;
|
||||
value: string;
|
||||
error: string | null;
|
||||
onChange: (v: string) => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
// While editing, Escape belongs to the editor row: holding a popup layer keeps
|
||||
// the global "Escape → focus editor" binding (`!popupOpen`) from firing.
|
||||
useEffect(() => acquirePopup(), []);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={styles.name}>{node.name}</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
<input
|
||||
className={clsx(styles.editInput, error && styles.editInvalid)}
|
||||
value={value}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-label={`New value of ${node.name}`}
|
||||
aria-invalid={!!error || undefined}
|
||||
title={error ?? undefined}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onBlur={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onCommit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeIcon(node: VarNode): string {
|
||||
if (node.kind === "watch") return "eye";
|
||||
if (node.kind === "eval") return "evaluate-expression";
|
||||
return node.depth0 ? "variable" : "field";
|
||||
}
|
||||
|
||||
function VarRow({ node }: { node: VarNode }) {
|
||||
if (node.kind === "message") return <span className={styles.message}>{node.name}</span>;
|
||||
if (node.kind === "scope")
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name="folder" size={16} className={styles.nodeIcon} />
|
||||
<span className={styles.scope}>{node.name}</span>
|
||||
</span>
|
||||
);
|
||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName)}>{node.name}</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
{node.error ? (
|
||||
<span className={styles.error}>{node.error}</span>
|
||||
) : (
|
||||
<>
|
||||
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||
<span className={clsx(styles.value, kind && styles[kind])}>{node.value}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Add Field Watchpoint: asks the adapter for a data breakpoint id for the value. */
|
||||
async function addWatchpointFor(session: DebugSessionId, node: VarNode) {
|
||||
try {
|
||||
const frame = activeSession()?.selectedFrame ?? null;
|
||||
const info = await call(commands.debugDataBreakpointInfo(session, node.container, node.name, frame));
|
||||
if (!info.dataId) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
key: "debug.watchpoint",
|
||||
title: `“${node.name}” can't be watched`,
|
||||
body: info.description || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const access = info.accessTypes.includes("write") || !info.accessTypes.length ? "write" : null;
|
||||
addFieldWatchpoint({ dataId: info.dataId, label: node.evaluateName ?? node.name, accessType: access });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
key: "debug.watchpoint",
|
||||
title: "Cannot add a field watchpoint",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to Source on a value: the selected frame's source; for a local, its first mention
|
||||
* in the enclosing function (declaration or parameter, as far as text can tell).
|
||||
*/
|
||||
async function jumpToDeclaration(node: VarNode) {
|
||||
const s = activeSession();
|
||||
const frame = s?.frames.find((f) => f.id === s.selectedFrame);
|
||||
if (!frame?.path) return;
|
||||
await revealFrame(frame, true);
|
||||
if (!node.depth0 || node.kind !== "var" || !/^[A-Za-z_$][\w$]*$/.test(node.name)) return;
|
||||
const ed = activeEditor();
|
||||
if (!ed || ed.ctx.path !== frame.path) return;
|
||||
const doc = ed.view.state.doc;
|
||||
const lines = doc.toString().split("\n");
|
||||
const re = new RegExp(`(^|[^\\w$])${node.name.replace(/\$/g, "\\$")}(?![\\w$])`);
|
||||
let found: { line: number; col: number } | null = null;
|
||||
for (let n = Math.min(frame.line, lines.length - 1); n >= 0; n--) {
|
||||
const m = re.exec(lines[n]!);
|
||||
if (m) found = { line: n, col: m.index + m[1]!.length };
|
||||
if (methodAt(ed.ctx.language, lines, n)) break;
|
||||
}
|
||||
if (!found) return;
|
||||
const pos = doc.line(found.line + 1).from + found.col;
|
||||
ed.view.dispatch({ selection: { anchor: pos, head: pos + node.name.length }, scrollIntoView: true });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The debugger view (Frames or Variables) that has keyboard focus, so view-local
|
||||
* actions bound to global keys (F2 Set Value, F4 Jump to Source) reach it, like
|
||||
* IDEA's per-component shortcuts.
|
||||
*/
|
||||
import { setContext } from "@/platform/context";
|
||||
|
||||
export interface DebugViewCommands {
|
||||
view: "frames" | "variables";
|
||||
setValue?: () => void;
|
||||
jumpToSource?: () => void;
|
||||
}
|
||||
|
||||
let current: DebugViewCommands | null = null;
|
||||
|
||||
export function focusDebugView(commands: DebugViewCommands) {
|
||||
current = commands;
|
||||
setContext("debugFramesFocus", commands.view === "frames");
|
||||
setContext("debugVariablesFocus", commands.view === "variables");
|
||||
}
|
||||
|
||||
export function blurDebugView(view: DebugViewCommands["view"]) {
|
||||
if (current?.view !== view) return;
|
||||
current = null;
|
||||
setContext("debugFramesFocus", false);
|
||||
setContext("debugVariablesFocus", false);
|
||||
}
|
||||
|
||||
export function focusedDebugView(): DebugViewCommands | null {
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Open/close state of the debugger's overlays (rendered by `Overlays.tsx`). */
|
||||
import { create } from "zustand";
|
||||
import type { AttachableProcess, DebugSessionId, RunConfig, StepInTarget } from "@/platform/ipc";
|
||||
|
||||
interface OverlayState {
|
||||
popover: { path: string; id: string; x: number; y: number; focusLog: boolean } | null;
|
||||
evaluate: { expression: string } | null;
|
||||
breakpoints: { path?: string; id?: string } | null;
|
||||
chooser: { configs: RunConfig[] } | null;
|
||||
/** Attach to Process popup; `processes` is null while loading. */
|
||||
attach: { processes: AttachableProcess[] | null; error: string | null } | null;
|
||||
/** Smart Step Into chooser. */
|
||||
smartStep: { session: DebugSessionId; thread: number; targets: StepInTarget[] } | null;
|
||||
}
|
||||
|
||||
export const useOverlays = create<OverlayState>(() => ({
|
||||
popover: null,
|
||||
evaluate: null,
|
||||
breakpoints: null,
|
||||
chooser: null,
|
||||
attach: null,
|
||||
smartStep: null,
|
||||
}));
|
||||
|
||||
export function openBreakpointPopover(path: string, id: string, at: { x: number; y: number }, focusLog = false) {
|
||||
useOverlays.setState({ popover: { path, id, x: at.x, y: at.y, focusLog } });
|
||||
}
|
||||
|
||||
export function closeBreakpointPopover() {
|
||||
useOverlays.setState({ popover: null });
|
||||
}
|
||||
|
||||
export function openEvaluateDialog(expression = "") {
|
||||
useOverlays.setState({ evaluate: { expression } });
|
||||
}
|
||||
|
||||
export function openBreakpointsDialog(select?: { path: string; id: string }) {
|
||||
useOverlays.setState({ breakpoints: select ?? {} });
|
||||
}
|
||||
|
||||
export function openConfigChooser(configs: RunConfig[]) {
|
||||
useOverlays.setState({ chooser: { configs } });
|
||||
}
|
||||
|
||||
export function openSmartStepChooser(session: DebugSessionId, thread: number, targets: StepInTarget[]) {
|
||||
useOverlays.setState({ smartStep: { session, thread, targets } });
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача