diff --git a/src/features/debugger/__tests__/valueText.test.ts b/src/features/debugger/__tests__/valueText.test.ts new file mode 100644 index 000000000..e27584c42 --- /dev/null +++ b/src/features/debugger/__tests__/valueText.test.ts @@ -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) => 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 …"); + }); +}); diff --git a/src/features/debugger/ui/VariablesView.tsx b/src/features/debugger/ui/VariablesView.tsx index af82bf4e5..5aef503b1 100644 --- a/src/features/debugger/ui/VariablesView.tsx +++ b/src/features/debugger/ui/VariablesView.tsx @@ -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, 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!) }); diff --git a/src/features/debugger/valueText.ts b/src/features/debugger/valueText.ts new file mode 100644 index 000000000..43bfc9672 --- /dev/null +++ b/src/features/debugger/valueText.ts @@ -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, + limits: ValueTextLimits = VALUE_TEXT_LIMITS, +): Promise { + if (root.reference <= 0) return root.value; + const lines = [root.value]; + let budget = limits.entries; + const path = new Set([root.reference]); + + const walk = async (reference: number, depth: number): Promise => { + 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"); +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 4d3ee08db..8d1a6b985 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -65,5 +65,6 @@ "Branch Conversation from Here": "Branch Conversation from Here", "Couldn't regenerate the response": "Couldn't regenerate the response", "Reasoning": "Reasoning", - "Branch '{name}'": "Branch '{name}'" + "Branch '{name}'": "Branch '{name}'", + "Cannot copy the value": "Cannot copy the value" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 663bef61f..b1d785488 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6441,5 +6441,6 @@ "Regenerate Response": "重新生成回复", "Branch Conversation from Here": "从此处创建分支对话", "Couldn't regenerate the response": "无法重新生成回复", - "Reasoning": "思考过程" + "Reasoning": "思考过程", + "Cannot copy the value": "无法复制该值" }