diff --git a/src/features/debugger/__tests__/values.test.ts b/src/features/debugger/__tests__/values.test.ts index 15ee72a71..f6faaf418 100644 --- a/src/features/debugger/__tests__/values.test.ts +++ b/src/features/debugger/__tests__/values.test.ts @@ -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) => + 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(); }); }); diff --git a/src/features/debugger/editor/editor.module.css b/src/features/debugger/editor/editor.module.css index 1ab48dc63..8f8d191b7 100644 --- a/src/features/debugger/editor/editor.module.css +++ b/src/features/debugger/editor/editor.module.css @@ -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; diff --git a/src/features/debugger/editor/execution.ts b/src/features/debugger/editor/execution.ts index 3751f4c42..5ee0b3dc2 100644 --- a/src/features/debugger/editor/execution.ts +++ b/src/features/debugger/editor/execution.ts @@ -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 | null { - const parts: string[] = []; +export function inlineValuesFor( + text: string, + values: Record, + variables?: Record, +): 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); diff --git a/src/features/debugger/editor/valueTooltip.ts b/src/features/debugger/editor/valueTooltip.ts index 32d12d51e..999a7e3fd 100644 --- a/src/features/debugger/editor/valueTooltip.ts +++ b/src/features/debugger/editor/valueTooltip.ts @@ -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!; diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 9fd19afdb..4a5205573 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -100,9 +100,17 @@ interface ExecutionPoint { /** The selected frame is the top frame (current position, not a caller). */ top: boolean; values: Record; + /** Full variables behind `values`, for the click-to-show popup. */ + variables?: Record; } -export const useExecutionPoint = create(() => ({ path: null, line: 0, top: true, values: {} })); +export const useExecutionPoint = create(() => ({ + 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 = Object.create(null); + const variables: Record = 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(); } diff --git a/src/features/debugger/ui/VariablesView.module.css b/src/features/debugger/ui/VariablesView.module.css index 86d699f4f..96bc2752c 100644 --- a/src/features/debugger/ui/VariablesView.module.css +++ b/src/features/debugger/ui/VariablesView.module.css @@ -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; } diff --git a/src/features/debugger/ui/VariablesView.tsx b/src/features/debugger/ui/VariablesView.tsx index ae54b1cad..af82bf4e5 100644 --- a/src/features/debugger/ui/VariablesView.tsx +++ b/src/features/debugger/ui/VariablesView.tsx @@ -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(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({ }} /> ) : ( - + ) } /> + {valuePopup && setValuePopup(null)} />} ); } +function ValueDialog({ node, onClose }: { node: VarNode; onClose: () => void }) { + return ( + +
+
{node.value}
+
+
+ ); +} + 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(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 {node.name}; if (node.kind === "scope") return ( @@ -432,7 +477,22 @@ function VarRow({ node, changed }: { node: VarNode; changed: boolean }) { ) : ( <> {node.typeName && {`{${node.typeName}}`}} - {oneLine(node.value)} + + {oneLine(node.value)} + + {overflow && ( + + )} )}