Initial open-source release

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

No files matched your search

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