feat(debugger): show full values on click in Variables and inline values (#1699)

* feat(debugger): show full values on click in Variables and inline values

* fix(debugger): fix localization and inline value test regressions
Цей коміт міститься в:
ppxb authored and GitHub committed 2026-11-07 02:05:04 +08:00
1 джерело d36cf83f78
коміт 64398dde3a
7 files changed
+264 -36

No files matched your search

+22 -8
Переглянути файл
@@ -46,17 +46,31 @@ describe("value classification", () => {
});
it("formats inline values for known locals on a line", () => {
const values = { req: "CreateOrderRequest@4127", total: "3", unused: "1" };
expect(inlineValuesFor("fun create(req: CreateOrderRequest): OrderDto {", values)).toBe(
"req: CreateOrderRequest@4127",
);
expect(inlineValuesFor("return 1", values)).toBeNull();
const values = {
req: "CreateOrderRequest@4127",
total: "3",
unused: "1",
};
const locals = (v: Record<string, string>) =>
Object.fromEntries(
Object.entries(v).map(([name, value]) => [
name,
{ name, value, typeName: null, variablesReference: 0, evaluateName: name },
]),
);
expect(
inlineValuesFor("fun create(req: CreateOrderRequest): OrderDto {", locals(values))
?.map((p) => p.text)
.join(", "),
).toBe("req: CreateOrderRequest@4127");
expect(inlineValuesFor("return 1", locals(values))).toBeNull();
});
it.each(Object.getOwnPropertyNames(Object.prototype))("only displays an own local named %s", (name) => {
expect(inlineValuesFor(name, { count: "40" })).toBeNull();
expect(inlineValuesFor(name, Object.fromEntries([[name, "known"]]))).toBe(`${name}: known`);
expect(inlineValuesFor(name, Object.create({ [name]: "inherited" }))).toBeNull();
const variable = (value: string) => ({ name, value, typeName: null, variablesReference: 0, evaluateName: name });
expect(inlineValuesFor(name, Object.fromEntries([["count", variable("40")]]))).toBeNull();
expect(inlineValuesFor(name, Object.fromEntries([[name, variable("known")]]))?.[0]?.text).toBe(`${name}: known`);
expect(inlineValuesFor(name, Object.create(Object.fromEntries([[name, variable("inherited")]])))).toBeNull();
});
});
+25
Переглянути файл
@@ -144,6 +144,31 @@
user-select: none;
}
.inlineValuePart {
pointer-events: auto;
cursor: pointer;
}
.inlineValuePart:hover {
text-decoration: underline;
}
.inlineShow {
margin-left: 2px;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
pointer-events: auto;
}
.inlineShow:hover {
color: var(--link-hover);
text-decoration: underline;
}
/* Data Views › Show value tooltip on mouse hover. */
.valueTooltip {
max-width: 520px;
+81 -15
Переглянути файл
@@ -5,27 +5,74 @@
import { StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { useExecutionPoint } from "../state";
import type { Variable } from "@/platform/ipc";
import { showHint } from "@/editor/lsp/hint";
import { activeSession, useExecutionPoint } from "../state";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { tx } from "@/platform/i18n";
import { identifiersIn, inlineValue } from "../values";
import { valueHint } from "./valueTooltip";
import styles from "./editor.module.css";
/** How far above the execution point inline values are shown. */
const INLINE_SPAN = 60;
const MAX_PER_LINE = 4;
interface InlinePart {
name: string;
text: string;
variable: Variable;
truncated: boolean;
}
class InlineValues extends WidgetType {
constructor(readonly text: string) {
constructor(readonly parts: InlinePart[]) {
super();
}
eq(o: InlineValues) {
return o.text === this.text;
return (
o.parts.length === this.parts.length &&
o.parts.every((part, i) => part.name === this.parts[i]?.name && part.text === this.parts[i]?.text)
);
}
toDOM() {
toDOM(view: EditorView) {
const span = document.createElement("span");
span.className = styles.inlineValue!;
span.textContent = this.text;
span.setAttribute("aria-hidden", "true");
for (const [index, part] of this.parts.entries()) {
if (index > 0) span.append(document.createTextNode(", "));
const piece = document.createElement("span");
piece.className = styles.inlineValuePart!;
piece.textContent = part.text;
piece.title = `${part.name} = ${part.variable.value}`;
piece.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
const session = activeSession();
if (!session || session.status !== "paused") return;
showHint(view, () => valueHint(part.name, part.variable, session.id, null), {
pos: view.posAtDOM(piece),
timeout: 60_000,
});
});
span.append(piece);
if (part.truncated) {
const show = document.createElement("button");
show.type = "button";
show.className = styles.inlineShow!;
show.textContent = `…${tx("Show")}`;
show.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
const session = activeSession();
if (!session || session.status !== "paused") return;
showHint(view, () => valueHint(part.name, part.variable, session.id, null), {
pos: view.posAtDOM(show),
timeout: 60_000,
});
});
span.append(show);
}
}
return span;
}
ignoreEvent() {
@@ -46,16 +93,35 @@ function currentExec(path: string): Exec | null {
}
/** `name: value` pairs for identifiers on `text` that are known locals. */
export function inlineValuesFor(text: string, values: Record<string, string>): string | null {
const parts: string[] = [];
export function inlineValuesFor(
text: string,
values: Record<string, string | Variable>,
variables?: Record<string, Variable>,
): InlinePart[] | null {
const parts: InlinePart[] = [];
for (const id of identifiersIn(text)) {
if (!Object.hasOwn(values, id)) continue;
const v = values[id];
if (v === undefined) continue;
parts.push(`${id}: ${inlineValue(v, 40)}`);
const raw = values[id];
if (raw === undefined) continue;
const variable =
typeof raw === "string"
? (variables?.[id] ?? {
name: id,
value: raw,
typeName: null,
variablesReference: 0,
evaluateName: id,
})
: raw;
parts.push({
name: id,
text: `${id}: ${inlineValue(variable.value, 40)}`,
variable,
truncated: variable.value.length > 40,
});
if (parts.length >= MAX_PER_LINE) break;
}
return parts.length ? parts.join(", ") : null;
return parts.length ? parts : null;
}
function lineDecorations(state: EditorState, exec: Exec | null): DecorationSet {
@@ -133,7 +199,7 @@ export function executionPoint(ctx: EditorDocContext): Extension {
build(): DecorationSet {
const exec = this.view.state.field(execField);
if (!workflowSettings().debugger.inlineValues) return Decoration.none;
const values = useExecutionPoint.getState().values;
const { values, variables } = useExecutionPoint.getState();
if (!exec || !Object.keys(values).length) return Decoration.none;
const doc = this.view.state.doc;
const first = Math.max(0, exec.line - INLINE_SPAN);
@@ -143,8 +209,8 @@ export function executionPoint(ctx: EditorDocContext): Extension {
const endLine = Math.min(doc.lineAt(to).number - 1, exec.line);
for (let l = startLine; l <= endLine; l++) {
const line = doc.line(l + 1);
const text = inlineValuesFor(line.text, values);
if (text) widgets.push(Decoration.widget({ widget: new InlineValues(text), side: 1 }).range(line.to));
const parts = inlineValuesFor(line.text, values, variables);
if (parts) widgets.push(Decoration.widget({ widget: new InlineValues(parts), side: 1 }).range(line.to));
}
}
return Decoration.set(widgets, true);
+2 -1
Переглянути файл
@@ -44,6 +44,7 @@ export function valueHint(
expression: string,
value: Variable,
session: DebugSessionId,
max: number | null = 400,
): { dom: HTMLElement; destroy: () => void } {
const dom = document.createElement("div");
dom.className = `${styles.valueTooltip} ${selectableText}`;
@@ -69,7 +70,7 @@ export function valueHint(
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expression} = `;
dom.append(name, inlineValue(value.value, 400));
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
if (value.typeName) {
const type = document.createElement("span");
type.className = styles.valueType!;
+20 -6
Переглянути файл
@@ -100,9 +100,17 @@ interface ExecutionPoint {
/** The selected frame is the top frame (current position, not a caller). */
top: boolean;
values: Record<string, string>;
/** Full variables behind `values`, for the click-to-show popup. */
variables?: Record<string, Variable>;
}
export const useExecutionPoint = create<ExecutionPoint>(() => ({ path: null, line: 0, top: true, values: {} }));
export const useExecutionPoint = create<ExecutionPoint>(() => ({
path: null,
line: 0,
top: true,
values: {},
variables: {},
}));
/** Console buffer: at most this many physical lines per session. */
const MAX_CONSOLE_LINES = 50_000;
@@ -814,12 +822,17 @@ async function loadThreads(id: DebugSessionId) {
async function loadLocals(id: DebugSessionId, frameId: number) {
const frames = useDebug.getState().sessions[id]?.frames;
const values: Record<string, string> = Object.create(null);
const variables: Record<string, Variable> = Object.create(null);
try {
const scopes = await call(commands.debugScopes(id, frameId));
const scope = scopes.find((sc) => !sc.expensive);
if (scope) {
const vars = await call(commands.debugVariables(id, scope.variablesReference));
for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value;
for (const v of vars)
if (v.name && !Object.hasOwn(values, v.name)) {
values[v.name] = v.value;
variables[v.name] = v;
}
}
} catch {
// No inline values.
@@ -827,7 +840,7 @@ async function loadLocals(id: DebugSessionId, frameId: number) {
const s = useDebug.getState().sessions[id];
if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames)
return;
useExecutionPoint.setState({ values });
useExecutionPoint.setState({ values, variables });
}
/** Show Execution Point (⌥F10): back to the suspended thread's top frame, revealed and focused. */
@@ -856,13 +869,14 @@ export function updateExecutionPoint() {
const s = activeSession();
const frame = s && s.status === "paused" ? s.frames.find((f) => f.id === s.selectedFrame) : undefined;
if (!s || !frame || !frame.path) {
if (useExecutionPoint.getState().path !== null) useExecutionPoint.setState({ path: null, values: {} });
if (useExecutionPoint.getState().path !== null)
useExecutionPoint.setState({ path: null, values: {}, variables: {} });
return;
}
const top = s.frames.find((f) => f.path && f.presentationHint !== "label")?.id === frame.id;
const cur = useExecutionPoint.getState();
if (cur.path === frame.path && cur.line === frame.line && cur.top === top) return;
useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} });
useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {}, variables: {} });
}
/** Invalidate suspended data before sending a command that resumes execution. */
@@ -1065,7 +1079,7 @@ export function resetSessions() {
cancelOutputFlush();
ansiStreams.clear();
useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {}, variables: {} });
publishContext();
}
+49 -1
Переглянути файл
@@ -1,7 +1,9 @@
.root {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
width: 100%;
flex: 1;
}
@@ -10,6 +12,9 @@
align-items: center;
gap: 5px;
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
font-family: var(--font-mono);
font-size: var(--fs-ui);
white-space: pre;
@@ -38,10 +43,49 @@
.value {
min-width: 0;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
}
.showValue {
flex: none;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.showValue:hover {
color: var(--link-hover);
text-decoration: underline;
}
.showValue:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-small);
}
.valuePopupContent {
padding: 12px 16px;
}
.valuePopupValue {
max-height: min(420px, calc(100vh - 120px));
margin: 0;
overflow: auto;
color: var(--fg);
font-family: var(--font-mono);
font-size: var(--fs-code);
font-weight: var(--fw-code);
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
.number {
color: var(--syn-number);
}
@@ -59,7 +103,10 @@
}
.type {
flex: none;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-subtle);
}
@@ -105,6 +152,7 @@
.hint {
display: flex;
flex-direction: column;
min-width: 0;
width: 460px;
height: 260px;
}
+65 -5
Переглянути файл
@@ -1,8 +1,9 @@
/** Lazy variables tree (scopes, locals, watches, evaluation results). */
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useCallback, useEffect, useLayoutEffect, 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 { Dialog, Icon, Tree, acquirePopup, showContextMenu, type MenuItem, type TreeHandle } from "@/ui";
import { formatMessage, tx } from "@/platform/i18n";
import { activeEditor } from "@/editor/api";
import { notifications } from "@/workbench/contributions";
import { addFieldWatchpoint } from "../breakpoints/special";
@@ -229,6 +230,7 @@ export function VariablesView({
}, [byKey, expanded, children, load]);
const [editing, setEditing] = useState<{ key: string; value: string; error: string | null } | null>(null);
const [valuePopup, setValuePopup] = useState<VarNode | 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 });
@@ -344,14 +346,34 @@ export function VariablesView({
}}
/>
) : (
<VarRow node={n} changed={isChanged(n)} />
<VarRow node={n} changed={isChanged(n)} onShowValue={setValuePopup} />
)
}
/>
{valuePopup && <ValueDialog node={valuePopup} onClose={() => setValuePopup(null)} />}
</div>
);
}
function ValueDialog({ node, onClose }: { node: VarNode; onClose: () => void }) {
return (
<Dialog
open
onClose={onClose}
title={`${node.name}${node.typeName ? ` {${node.typeName}}` : ""}`}
width={720}
minHeight={260}
maxHeight={560}
resizable="both"
aria-label={formatMessage(tx("Value of {label}"), { label: node.name })}
>
<div className={styles.valuePopupContent}>
<pre className={styles.valuePopupValue}>{node.value}</pre>
</div>
</Dialog>
);
}
function VarEditRow({
node,
value,
@@ -410,7 +432,30 @@ function nodeIcon(node: VarNode): string {
return node.depth0 ? "variable" : "field";
}
function VarRow({ node, changed }: { node: VarNode; changed: boolean }) {
function VarRow({
node,
changed,
onShowValue,
}: {
node: VarNode;
changed: boolean;
onShowValue: (node: VarNode) => void;
}) {
const valueRef = useRef<HTMLSpanElement>(null);
const [overflow, setOverflow] = useState(false);
useLayoutEffect(() => {
const el = valueRef.current;
if (!el) {
setOverflow(false);
return;
}
const update = () => setOverflow(el.scrollWidth > el.clientWidth + 1);
update();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, [node.value]);
if (node.kind === "message") return <span className={styles.message}>{node.name}</span>;
if (node.kind === "scope")
return (
@@ -432,7 +477,22 @@ function VarRow({ node, changed }: { node: VarNode; changed: boolean }) {
) : (
<>
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
<span className={clsx(styles.value, kind && styles[kind])}>{oneLine(node.value)}</span>
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
{oneLine(node.value)}
</span>
{overflow && (
<button
type="button"
className={styles.showValue}
title={tx("Show")}
onClick={(e) => {
e.stopPropagation();
onShowValue(node);
}}
>
…{tx("Show")}
</button>
)}
</>
)}
</span>