дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
7 files changed
+264
-36
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Заблокувати користувача