fix(debugger): copy structured variable values with their contents (#1904) (#1917)

Цей коміт міститься в:
Jaw authored and GitHub committed 2026-11-07 13:15:27 +08:00
1 джерело 8d909598d5
коміт 8ca6e4bc01
5 files changed
+119 -3

No files matched your search

+41
Переглянути файл
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { Variable } from "@/platform/ipc";
import { valueText } from "../valueText";
const v = (name: string, value: string, variablesReference = 0): Variable =>
({ name, value, variablesReference, typeName: null, evaluateName: null }) as Variable;
const fetcher = (tree: Record<number, Variable[]>) => async (ref: number) => tree[ref] ?? [];
describe("valueText", () => {
it("returns a scalar as is", async () => {
expect(await valueText({ value: "42", reference: 0 }, fetcher({}))).toBe("42");
});
it("copies a PHP array with its nested elements", async () => {
const tree = {
1: [v("name", '"Ann"'), v("tags", "array(2)", 2)],
2: [v("0", '"a"'), v("1", '"b"')],
};
expect(await valueText({ value: "array(2)", reference: 1 }, fetcher(tree))).toBe(
["array(2)", ' name: "Ann"', " tags: array(2)", ' 0: "a"', ' 1: "b"'].join("\n"),
);
});
it("stops at a cycle", async () => {
const tree = { 1: [v("self", "Node", 1)] };
expect(await valueText({ value: "Node", reference: 1 }, fetcher(tree))).toBe("Node\n self: Node");
});
it("truncates past the entry limit", async () => {
const tree = { 1: [v("0", "a"), v("1", "b"), v("2", "c")] };
const text = await valueText({ value: "arr", reference: 1 }, fetcher(tree), { depth: 8, entries: 2 });
expect(text).toBe("arr\n 0: a\n 1: b\n …");
});
it("truncates past the depth limit", async () => {
const tree = { 1: [v("a", "x", 2)], 2: [v("b", "y", 3)], 3: [v("c", "z")] };
const text = await valueText({ value: "r", reference: 1 }, fetcher(tree), { depth: 2, entries: 100 });
expect(text).toBe("r\n a: x\n b: y\n …");
});
});
+21 -1
Переглянути файл
@@ -11,6 +11,7 @@ import { methodAt } from "../breakpoints/symbols";
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
import { blurDebugView, focusDebugView } from "./focusedView";
import { classifyValue, oneLine, sortVariables } from "../values";
import { valueText } from "../valueText";
import { workflowSettings } from "@/platform/workflowSettings";
import styles from "./VariablesView.module.css";
@@ -270,6 +271,25 @@ export function VariablesView({
const canWatch = (n: VarNode) =>
session != null && n.kind === "var" && n.container > 0 && sessionSupports("dataBreakpoints");
const copyValue = async (node: VarNode) => {
let text = node.value;
if (session != null && node.reference > 0) {
try {
text = await valueText(node, (ref) => call(commands.debugVariables(session, ref)));
} catch (e) {
notifications().notify({
group: "run",
level: "error",
key: "debug.copyValue",
title: "Cannot copy the value",
body: e instanceof Error ? e.message : String(e),
});
return;
}
}
await navigator.clipboard?.writeText(text);
};
const onContextMenu = (node: VarNode | null, _sel: Set<string>, e: React.MouseEvent) => {
if (!node || node.kind === "message") return;
e.preventDefault();
@@ -281,7 +301,7 @@ export function VariablesView({
);
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 Value", icon: "copy", run: () => void copyValue(node) });
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!) });
+53
Переглянути файл
@@ -0,0 +1,53 @@
/** Text for "Copy Value": a structured value (array, object, map) is copied with its contents, not its summary. */
import type { Variable } from "@/platform/ipc";
export interface ValueTextRoot {
value: string;
reference: number;
}
export interface ValueTextLimits {
depth: number;
entries: number;
}
export const VALUE_TEXT_LIMITS: ValueTextLimits = { depth: 8, entries: 2000 };
/**
* `value` alone for a scalar; for an expandable value the summary line followed by its children,
* one per line and indented by depth. Cycles and anything past the limits end in an ellipsis line.
*/
export async function valueText(
root: ValueTextRoot,
fetchChildren: (reference: number) => Promise<Variable[]>,
limits: ValueTextLimits = VALUE_TEXT_LIMITS,
): Promise<string> {
if (root.reference <= 0) return root.value;
const lines = [root.value];
let budget = limits.entries;
const path = new Set<number>([root.reference]);
const walk = async (reference: number, depth: number): Promise<void> => {
const pad = " ".repeat(depth);
if (depth > limits.depth) {
lines.push(`${pad}…`);
return;
}
for (const child of await fetchChildren(reference)) {
if (budget-- <= 0) {
lines.push(`${pad}…`);
return;
}
lines.push(`${pad}${child.name}: ${child.value}`);
const ref = child.variablesReference;
if (ref > 0 && !path.has(ref)) {
path.add(ref);
await walk(ref, depth + 1);
path.delete(ref);
}
}
};
await walk(root.reference, 1);
return lines.join("\n");
}