diff --git a/.vscode/launch.json b/.vscode/launch.json index 1d169a2..9e8f00e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -53,7 +53,8 @@ "HOT_RELOAD": "", "USE_DEV_UI": "" }, - "outFiles": ["${workspaceFolder}/extension/dist/**/*.js"] + "outFiles": ["${workspaceFolder}/extension/dist/**/*.js"], + "preLaunchTask": "npm: build - extension" }, { "name": "Run Extension (Dev UI)", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index cc3b2e9..2b78058 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -15,6 +15,19 @@ "kind": "build", "isDefault": true } + }, + { + "type": "npm", + "label": "npm: build - extension", + // Deliberately the ROOT package.json's "build" script (data-extraction + // -> webview -> extension, in that order), not extension/'s own build + // script -- the extension's webpack just *copies* whatever is already + // sitting in webview/dist (via the node_modules/debug-visualizer-webview + // symlink) into its own bundle rather than rebuilding it. Scoping this + // task to extension/ alone silently ships a stale webview bundle. + "script": "build", + "problemMatcher": [], + "group": "build" } ] } diff --git a/demos/lists/cpp/main.cpp b/demos/lists/cpp/main.cpp new file mode 100644 index 0000000..0068bca --- /dev/null +++ b/demos/lists/cpp/main.cpp @@ -0,0 +1,80 @@ +#include +#include + +// A single node containing data and a raw pointer to the next node +template +struct Node { + T data; + Node* next; +}; + +// The main wrapper for the linked list tracking the head node +template +class LinkedList { +public: + LinkedList() : head_(nullptr) {} + + ~LinkedList() { + Node* current = head_; + while (current) { + Node* next = current->next; + delete current; + current = next; + } + } + + // Raw-pointer ownership can't be safely duplicated by a shallow copy + LinkedList(const LinkedList&) = delete; + LinkedList& operator=(const LinkedList&) = delete; + + // Add a new element to the front of the list + void push_front(T data) { + head_ = new Node{std::move(data), head_}; + } + + // Remove and return the front element of the list + std::optional pop_front() { + if (!head_) { + return std::nullopt; + } + Node* old_head = head_; + T data = std::move(old_head->data); + head_ = old_head->next; + delete old_head; + return data; + } + + // Read the front element without removing it + const T* peek_front() const { + return head_ ? &head_->data : nullptr; + } + +private: + Node* head_; +}; + +int main() { + LinkedList list; + int count=0; + char * message1 = "hello world"; + // Demonstrate pushing items + list.push_front(10); + count++; + list.push_front(20); + count++; + list.push_front(30); + count++; + + // Demonstrate peeking at the top item + if (const int* top = list.peek_front()) { + std::cout << "Top element: " << *top << "\n"<<"count: "< None: + self._head: Optional[Node[T]] = None + + # Add a new element to the front of the list + def push_front(self, data: T) -> None: + self._head = Node(data, self._head) + + # Remove and return the front element of the list + def pop_front(self) -> Optional[T]: + if self._head is None: + return None + data = self._head.data + self._head = self._head.next + return data + + # Read the front element without removing it + def peek_front(self) -> Optional[T]: + return self._head.data if self._head else None + + +def main() -> None: + lst: LinkedList[int] = LinkedList() + count = 0 + message1 = "hello world" + + # Demonstrate pushing items + lst.push_front(10) + count += 1 + lst.push_front(20) + count += 1 + lst.push_front(30) + count += 1 + + # Demonstrate peeking at the top item + top = lst.peek_front() + if top is not None: + print(f"Top element: {top}\ncount: {count}") # Output: 30 + print(message1) + + # Demonstrate popping items + while (value := lst.pop_front()) is not None: + print(f"Popped: {value}") + + +if __name__ == "__main__": + main() diff --git a/demos/lists/rust/Cargo.toml b/demos/lists/rust/Cargo.toml new file mode 100644 index 0000000..21e11bf --- /dev/null +++ b/demos/lists/rust/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "list_example" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/demos/lists/rust/src/main.rs b/demos/lists/rust/src/main.rs new file mode 100644 index 0000000..e7cefcc --- /dev/null +++ b/demos/lists/rust/src/main.rs @@ -0,0 +1,65 @@ +// A single node containing data and an optional pointer to the next node +struct Node { + data: T, + next: Option>>, +} + +// The main wrapper for the linked list tracking the head node +pub struct LinkedList { + head: Option>>, +} + +impl LinkedList { + // Create an empty linked list + pub fn new() -> Self { + LinkedList { head: None } + } + + // Add a new element to the front of the list + pub fn push_front(&mut self, data: T) { + let new_node = Box::new(Node { + data, + // .take() temporarily leaves None in self.head and extracts the value + next: self.head.take(), + }); + self.head = Some(new_node); + } + + // Remove and return the front element of the list + pub fn pop_front(&mut self) -> Option { + self.head.take().map(|node| { + self.head = node.next; + node.data + }) + } + + // Read the front element without removing it + pub fn peek_front(&self) -> Option<&T> { + self.head.as_ref().map(|node| &node.data) + } +} + +fn main() { + let mut list = LinkedList::new(); + let mut count = 0; + let message1 = "hello world"; + + // Demonstrate pushing items + list.push_front(10); + count += 1; + list.push_front(20); + count += 1; + list.push_front(30); + count += 1; + + // Demonstrate peeking at the top item + if let Some(top) = list.peek_front() { + println!("Top element: {}\ncount: {}", top, count); // Output: 30 + } + println!("{}", message1); + + // Demonstrate popping items + while let Some(value) = list.pop_front() { + println!("Popped: {}", value); + } +} \ No newline at end of file diff --git a/extension/package.json b/extension/package.json index 54d0950..16e4f82 100644 --- a/extension/package.json +++ b/extension/package.json @@ -9,7 +9,7 @@ "engines": { "vscode": "^1.84.0" }, - "publisher": "hediet", + "publisher": "ai-coder", "keywords": [ "debugger", "debugging", @@ -21,7 +21,7 @@ "visualization" ], "bugs": { - "url": "https://github.com/hediet/vscode-debug-visualizer/issues" + "url": "https://git.fairfaxmedia.net/ai_coder/vscode-debug-visualizer/issues" }, "author": { "email": "henning.dieterichs@live.de", @@ -30,7 +30,7 @@ "readme": "./README.md", "repository": { "type": "git", - "url": "https://github.com/hediet/vscode-debug-visualizer.git" + "url": "https://git.fairfaxmedia.net/ai_coder/vscode-debug-visualizer" }, "categories": [ "Other" @@ -103,6 +103,15 @@ "repl" ], "description": "The context to use for evaluating the expression." + }, + "nodeTableGraph": { + "type": "boolean", + "description": "Use the built-in node-table graph visualization for this debug adapter type, walking the expression via the standard evaluate/variables/scopes requests instead of expressionTemplate. Enabled by default for every adapter type except ones with a more specific built-in engine (node/chrome/pwa-* JS adapters, rdbg); set to false to fall back to expressionTemplate for a given adapter type, or true to opt a JS/Ruby adapter back in." + }, + "setupExpression": { + "type": "array", + "items": { "type": "string" }, + "description": "Commands run once per debug session (context \"repl\") before the node-table graph walk -- e.g. gdb's pretty-printing/print-elements/print-characters options." } } } @@ -112,7 +121,7 @@ }, "scripts": { "pub": "vsce publish --packagePath ./dist/extension.vsix", - "package": "vsce package --no-dependencies -o ./dist/extension.vsix --yarn --baseImagesUrl https://github.com/hediet/vscode-debug-visualizer/raw/master/extension --baseContentUrl https://github.com/hediet/vscode-debug-visualizer/raw/master/extension", + "package": "vsce package --no-dependencies -o ./dist/extension.vsix --yarn --baseImagesUrl https://git.fairfaxmedia.net/ai_coder/vscode-debug-visualizer/raw/master/extension --baseContentUrl https://git.fairfaxmedia.net/ai_coder/vscode-debug-visualizer/raw/master/extension", "vscode:prepublish": "", "build": "webpack --mode production", "dev": "shx rm -rf dist && tsc -watch -p ./" diff --git a/extension/src/Config.ts b/extension/src/Config.ts index 799e314..1d6c570 100644 --- a/extension/src/Config.ts +++ b/extension/src/Config.ts @@ -87,21 +87,65 @@ export class Config { ); } + // Adapter types that already have a dedicated, richer visualization + // engine (JsEvaluationEngine injects real typed data-extractors into the + // debuggee; RbEvaluationEngine similarly) -- the generic node-table graph + // defaults to *on* for every other adapter type (see getDebugAdapterConfig + // below) so it works out of the box across languages with no workspace + // config required, but shouldn't shadow those two's better-than-generic + // support unless a user explicitly opts back in via + // `debugVisualizer.debugAdapterConfigurations`. + private static readonly nodeTableGraphExcludedAdapters = new Set([ + "node", + "node2", + "extensionHost", + "chrome", + "pwa-chrome", + "pwa-node", + "pwa-extensionHost", + "node-terminal", + "pwa-msedge", + "rdbg", + ]); + + // Built-in per-adapter extras beyond the nodeTableGraph on/off default + // above -- currently just gdb's display-option setup commands, so the + // node-table graph's pretty-printed/full-length output works with no + // workspace config required. The user's own + // `debugVisualizer.debugAdapterConfigurations` setting still wins + // field-by-field (see getDebugAdapterConfig's merge below). + private static readonly builtInDebugAdapterConfigs: DebugAdapterConfigs = { + cppdbg: { + setupExpression: [ + "-enable-pretty-printing", + "-gdb-set print elements 0", + "-gdb-set print characters 0", + ], + }, + }; + public getDebugAdapterConfig( debugAdapterType: string ): DebugAdapterConfig | undefined { - const c = this._debugAdapterConfigs.get()[debugAdapterType]; - if (!c) { + const builtIn = Config.builtInDebugAdapterConfigs[debugAdapterType]; + const user = this._debugAdapterConfigs.get()[debugAdapterType]; + const defaultNodeTableGraph = !Config.nodeTableGraphExcludedAdapters.has( + debugAdapterType + ); + if (!builtIn && !user && !defaultNodeTableGraph) { return undefined; } + const c = { nodeTableGraph: defaultNodeTableGraph, ...builtIn, ...user }; return { context: c.context || "watch", + nodeTableGraph: c.nodeTableGraph, getFinalExpression: ({ expression, preferredExtractorId }) => evaluateTemplate(c.expressionTemplate || "${expr}", { expr: expression, preferredDataExtractorId: preferredExtractorId || "", }), + getSetupExpression: () => c.setupExpression, }; } } @@ -110,15 +154,19 @@ type DebugAdapterConfigs = { [debugAdapter: string]: { context?: "watch" | "repl"; expressionTemplate?: "string"; + nodeTableGraph?: boolean; + setupExpression?: string[]; }; }; export interface DebugAdapterConfig { context: "watch" | "repl"; + nodeTableGraph: boolean; getFinalExpression(vars: { expression: string; preferredExtractorId: DataExtractorId | undefined; }): string; + getSetupExpression(): string[] | undefined; } function evaluateTemplate( diff --git a/extension/src/VisualizationBackend/NodeTableGraphVisualizationSupport.ts b/extension/src/VisualizationBackend/NodeTableGraphVisualizationSupport.ts new file mode 100644 index 0000000..04f1e80 --- /dev/null +++ b/extension/src/VisualizationBackend/NodeTableGraphVisualizationSupport.ts @@ -0,0 +1,638 @@ +import { + DataExtractionResult, + VisualizationData, +} from "@hediet/debug-visualizer-data-extraction"; +import { hotClass, registerUpdateReconciler } from "@hediet/node-reload"; +import { Config, DebugAdapterConfig } from "../Config"; +import { DebugSessionProxy } from "../proxies/DebugSessionProxy"; +import { DebuggerViewProxy } from "../proxies/DebuggerViewProxy"; +import { FormattedMessage } from "../webviewContract"; +import { + DebugSessionVisualizationSupport, + GetVisualizationDataArgs, + VisualizationBackend, + VisualizationBackendBase, +} from "./VisualizationBackend"; + +registerUpdateReconciler(module); + +// A generic, DAP-only replacement for the old gdb_debug_graph.py/ +// py_debug_graph.py debuggee-side scripts: walks whatever the debug adapter +// already exposes via the standard "evaluate"/"variables"/"scopes" requests +// and produces the same { kind: { nodeTable: true }, nodes, edges, roots } +// shape the node-table-graph webview visualizer expects (see +// webview/src/visualizers/nodeTableGraphVisualizer.ts) -- no script needs to +// be sourced into gdb or imported into the debuggee's Python process. + +interface NodeField { + name: string; + value: string; + isPointer?: boolean; + typeHint?: string; +} + +interface ArrayTableColumn { + id: string; + // Aligned by index with the owning ArrayTable's memberNames -- a member + // this particular element doesn't have gets an empty-value placeholder + // rather than shifting the columns out of alignment. + values: NodeField[]; +} + +interface ArrayTable { + memberNames: string[]; + columns: ArrayTableColumn[]; + // The real element count and whether `columns` was capped at + // MAX_ARRAY_ELEMENTS -- lets the webview show "N of M" instead of + // silently hiding that the rest of a very large array wasn't fetched. + totalCount: number; + truncated: boolean; +} + +interface GraphNodeData { + id: string; + fields: NodeField[]; + type?: string; + // Present instead of a meaningful `fields` (left []) when this node is an + // array/slice/vector of struct-shaped elements -- rendered as a compact + // table (one row per member, one column per element) instead of a chain + // of per-element boxes. See looksLikeArrayOfStructs/buildArrayTableNode. + arrayTable?: ArrayTable; +} + +interface GraphEdge { + from: string; + to: string; + label?: string; +} + +interface GraphRoot { + name: string; + value: string; + kind?: "local" | "watched" | "global"; + type?: string; +} + +interface NodeTableVisualizationData extends VisualizationData { + kind: { nodeTable: true }; + lang: string; + nodes: GraphNodeData[]; + edges: GraphEdge[]; + roots: GraphRoot[]; +} + +interface DapVariable { + name: string; + value: string; + variablesReference: number; + type?: string; +} + +// A field only becomes its own graph node (and edge) when it looks like a +// genuine pointer with an independent address -- everything else (by-value +// nested structs, Option/std::optional wrappers, ...) is flattened inline as +// dotted sub-fields instead, up to this depth. Once `-enable-pretty-printing` +// is on (see Config.ts's built-in cppdbg setupExpression), a wrapper's DAP +// children already reflect gdb's own pretty-printer output, so this stays a +// shallow structural rule rather than needing to special-case any container +// type by name. +const INLINE_FLATTEN_MAX_DEPTH = 3; +const ADDRESS_RE = /0x[0-9a-f]+/i; + +// Fetching (and walking the members of) every element of a very large +// array/slice/vector is both slow (one DAP round trip per element) and +// produces an unusably wide table -- cap it and let the webview page +// through what's fetched instead of hanging or rendering thousands of +// columns at once. +const MAX_ARRAY_ELEMENTS = 500; + +// pydevd/debugpy (Python) injects these as synthetic pseudo-children into +// *every* object's variable listing, grouping dunder attributes and bound +// methods for the Variables view -- they aren't real fields. Left +// unfiltered, the inline-flatten path in addField() (which has no notion of +// "this isn't a real field, don't recurse") walks straight into them: each +// one is itself an object with its own nested "special variables", so it +// recurses into Python's dict/type/method-wrapper internals. Measured on a +// 3-node linked list: ~126 DAP round trips and 400+ flattened junk fields, +// several seconds, on every single refresh -- the "Debug Visualizer +// freezes" symptom reported against a Python (debugpy) session. gdb/delve +// have no equivalent synthetic grouping, so this is debugpy-specific. +const PSEUDO_SCOPE_NAMES = new Set([ + "special variables", + "function variables", + "class variables", +]); + +@hotClass(module) +export class NodeTableGraphEngine implements DebugSessionVisualizationSupport { + constructor( + private readonly debuggerView: DebuggerViewProxy, + private readonly config: Config + ) {} + + createBackend( + session: DebugSessionProxy + ): VisualizationBackend | undefined { + const config = this.config.getDebugAdapterConfig(session.session.type); + if (!config || !config.nodeTableGraph) { + return undefined; + } + return new NodeTableGraphVisualizationBackend( + session, + this.debuggerView, + config + ); + } +} + +export class NodeTableGraphVisualizationBackend extends VisualizationBackendBase { + public readonly expressionLanguageId = "text"; + + constructor( + debugSession: DebugSessionProxy, + debuggerView: DebuggerViewProxy, + private readonly config: DebugAdapterConfig + ) { + super(debugSession, debuggerView); + } + + private detectLang(): string { + const type = this.debugSession.session.type; + if (type === "debugpy" || type === "python") { + return "python"; + } + if (type === "go") { + return "go"; + } + const miDebuggerPath = String( + (this.debugSession.session.configuration as { miDebuggerPath?: string }) + .miDebuggerPath || "" + ); + if (miDebuggerPath.includes("rust-gdb")) { + return "rust"; + } + return "cpp"; + } + + public async getVisualizationData({ + expression, + sessionStore, + }: GetVisualizationDataArgs): Promise< + | { kind: "data"; result: DataExtractionResult } + | { kind: "error"; message: FormattedMessage } + > { + const frameId = this.debuggerView.getActiveStackFrameId( + this.debugSession + ); + + try { + await this.runSetupOnce(sessionStore, frameId); + + const nodes = new Map(); + const edges: GraphEdge[] = []; + const visited = new Set(); + const roots: GraphRoot[] = []; + + const primary = await this.debugSession.evaluate({ + expression, + frameId, + context: this.config.context, + }); + + let primaryId: string | undefined; + if (primary.variablesReference > 0) { + primaryId = await this.walkNodeOrArrayTable( + primary.variablesReference, + primary.result, + nodes, + edges, + visited + ); + } else { + nodes.set("root", { + id: "root", + fields: [{ name: "value", value: primary.result }], + }); + } + + await this.discoverLocals(frameId, primaryId, nodes, edges, visited, roots); + + const data: NodeTableVisualizationData = { + kind: { nodeTable: true }, + lang: this.detectLang(), + nodes: [...nodes.values()], + edges, + roots, + }; + + return { + kind: "data", + result: { + availableExtractors: [], + usedExtractor: { + id: "node-table-graph" as any, + name: "Node Table Graph", + priority: 2000, + }, + data, + }, + }; + } catch (error: any) { + return { + kind: "error", + message: { + kind: "list", + items: [ + "An error occurred while building the node table graph:", + error.message, + `Used debug adapter: ${this.debugSession.session.configuration.type}`, + { + kind: "inlineList", + items: [ + "Evaluated expression is", + { kind: "code", content: expression }, + ], + }, + ], + }, + }; + } + } + + // Runs the adapter's setup commands (e.g. gdb's pretty-printing/ + // print-elements/print-characters options) once per debug session + // instead of on every refresh -- sessionStore.data is a per-session slot + // VisualizationWatchModel already keeps around for exactly this purpose. + private async runSetupOnce( + sessionStore: { data: unknown }, + frameId: number | undefined + ) { + const store = sessionStore.data as + | { nodeTableGraphSetupDone?: boolean } + | undefined; + if (store && store.nodeTableGraphSetupDone) { + return; + } + const commands = this.config.getSetupExpression(); + if (commands) { + for (const cmd of commands) { + try { + await this.debugSession.evaluate({ + expression: cmd, + frameId, + context: "repl", + }); + } catch { + // best-effort -- fall through, the real walk below will + // still surface a clear error if something's actually wrong + } + } + } + sessionStore.data = { ...(store || {}), nodeTableGraphSetupDone: true }; + } + + private async discoverLocals( + frameId: number | undefined, + primaryId: string | undefined, + nodes: Map, + edges: GraphEdge[], + visited: Set, + roots: GraphRoot[] + ) { + if (frameId === undefined) { + return; + } + const scopes = await this.debugSession.getScopes({ frameId }); + const localsScope = scopes.find((s) => /local/i.test(s.name)); + if (!localsScope) { + return; + } + const localVars = await this.debugSession.getVariables({ + variablesReference: localsScope.variablesReference, + }); + for (const v of localVars as DapVariable[]) { + const addr = this.extractAddress(v.value); + if (primaryId !== undefined && addr === primaryId) { + continue; // already covered by the primary root + } + if (v.variablesReference > 0) { + const id = await this.walkNodeOrArrayTable( + v.variablesReference, + v.value, + nodes, + edges, + visited + ); + roots.push({ name: v.name, value: id, kind: "local", type: v.type }); + } else { + roots.push({ + name: v.name, + value: v.value, + kind: "local", + type: v.type, + }); + } + } + } + + // Finds an address that plausibly belongs to `value` *itself*, not to + // something nested inside its own summary text. Some adapters (delve, + // with dlvLoadConfig's followPointers on) inline a pointer's entire + // pointee summary -- including further nested pointers' addresses -- + // into the value string without ever printing the pointer's own + // address anywhere; naively taking the first "0x..." match there would + // silently misattribute some nested field's address as this value's own + // identity. Rejecting a match that only appears after the value's own + // summary has already opened a nested struct/array (a "{" or "[") + // filters those out, while still matching the common bare-address + // ("0x1234") and gdb-style ("0x1234 ") and Python-repr + // ("") shapes, none of which have that. + private extractAddress(value: string): string | undefined { + const m = ADDRESS_RE.exec(value); + if (!m) { + return undefined; + } + if (/[{[]/.test(value.slice(0, m.index))) { + return undefined; + } + return m[0].toLowerCase(); + } + + // Some debug adapters (delve/Go, at least) represent a pointer's + // dereference as a single transparent child with an empty name, rather + // than exposing the pointee's fields/elements directly -- unwrap through + // it (recursively, in case of multiple indirections, e.g. `**T`) so + // field names and array detection see the real members, not one + // anonymous wrapper hop away from them. + private async getResolvedChildren( + variablesReference: number + ): Promise { + let children = await this.fetchRealChildren(variablesReference); + while ( + children.length === 1 && + children[0].name === "" && + children[0].variablesReference > 0 + ) { + children = await this.fetchRealChildren(children[0].variablesReference); + } + return children; + } + + // Filters out pydevd/debugpy's synthetic "special variables"/"function + // variables" pseudo-children (see PSEUDO_SCOPE_NAMES) right at the + // source, so nothing downstream -- array-of-structs detection, the + // inline-flatten walk, array table columns -- ever has to know they + // exist. + private async fetchRealChildren( + variablesReference: number + ): Promise { + const children = (await this.debugSession.getVariables({ + variablesReference, + })) as DapVariable[]; + return children.filter((c) => !PSEUDO_SCOPE_NAMES.has(c.name)); + } + + private looksLikePointer(v: { type?: string; value: string }): boolean { + if (v.type) { + // C/C++ (rust-gdb prints Rust pointers/references the same way + // gdb's own C type-printer would): trailing sigil, e.g. "Node *". + if (/\*\s*$/.test(v.type)) { + return true; + } + // Go (delve) and Rust's own `*mut T`/`*const T`/`&T` syntax put + // the sigil in front instead, e.g. "*main.Node", "&Node". + if (/^\s*[*&]/.test(v.type)) { + return true; + } + } + if (/<.*object at 0x[0-9a-f]+>/i.test(v.value)) { + return true; + } + return false; + } + + // A value counts as "array of classes" when every child's name is a bare + // or bracketed index (0,1,2,... or [0],[1],[2],...) -- the DAP naming + // convention arrays/slices/vectors already use across gdb/MIEngine, + // debugpy, and delve -- and the first element is itself struct-shaped + // (has its own children), not a scalar leaf. Only the first element is + // checked, on the assumption the array is homogeneous (true for every + // language this targets); a scalar array (e.g. `int[5]`) intentionally + // falls through to the ordinary inline-flatten path instead. + private looksLikeArrayOfStructs(children: DapVariable[]): boolean { + if (children.length === 0) { + return false; + } + if (!children.every((c) => /^\[?\d+\]?$/.test(c.name))) { + return false; + } + return children[0].variablesReference > 0; + } + + // Recursively walks a DAP variablesReference into `nodes`/`edges`, + // returning the id (a real address when one is present in the variable's + // own printed value, else a synthesized id) of the node it produced -- + // mirrors the old scripts' hex(ptr_int)/id(obj)-derived node ids, giving + // the same cross-reference cycle dedup via `visited`. Routes to either a + // regular struct node or an array-table node depending on shape, so an + // array of structs gets the compact table rendering everywhere it + // appears -- as the primary root, a struct's own field, a local, or a + // pointer's target -- not just in one specific position. + private async walkNodeOrArrayTable( + variablesReference: number, + ownValue: string, + nodes: Map, + edges: GraphEdge[], + visited: Set, + prefetchedChildren?: DapVariable[] + ): Promise { + const id = this.extractAddress(ownValue) || `synthetic:${nodes.size}`; + if (visited.has(id)) { + return id; + } + visited.add(id); + + const children = + prefetchedChildren ?? (await this.getResolvedChildren(variablesReference)); + + if (this.looksLikeArrayOfStructs(children)) { + await this.buildArrayTableNode(id, children, nodes, edges, visited); + } else { + await this.buildStructFields(id, children, nodes, edges, visited); + } + return id; + } + + private async buildStructFields( + id: string, + children: DapVariable[], + nodes: Map, + edges: GraphEdge[], + visited: Set + ) { + const fields: NodeField[] = []; + for (const child of children) { + await this.addField(child, fields, edges, nodes, visited, id, 0); + } + nodes.set(id, { id, fields }); + } + + // Builds a single node holding every element's members as columns + // (memberNames is the union of member names across elements, in + // first-seen order, so a ragged/heterogeneous array still lines up + // sensibly). A pointer member is still a real edge out of this same + // node for each element that has one, labeled "[i].member" -- pointers + // stay navigable everywhere, they just fan out from one box instead of + // each element getting its own. + private async buildArrayTableNode( + id: string, + allElements: DapVariable[], + nodes: Map, + edges: GraphEdge[], + visited: Set + ) { + const totalCount = allElements.length; + const truncated = totalCount > MAX_ARRAY_ELEMENTS; + const elements = truncated + ? allElements.slice(0, MAX_ARRAY_ELEMENTS) + : allElements; + + const memberNames: string[] = []; + const seenMembers = new Set(); + const columnCells: Map[] = []; + + for (let i = 0; i < elements.length; i++) { + const cells = new Map(); + const el = elements[i]; + if (el.variablesReference > 0) { + const members = await this.getResolvedChildren(el.variablesReference); + for (const m of members) { + if (!seenMembers.has(m.name)) { + seenMembers.add(m.name); + memberNames.push(m.name); + } + const addr = this.extractAddress(m.value); + if (addr && this.looksLikePointer(m)) { + if (m.variablesReference > 0) { + const targetId = await this.walkNodeOrArrayTable( + m.variablesReference, + m.value, + nodes, + edges, + visited + ); + edges.push({ + from: id, + to: targetId, + label: `[${i}].${m.name}`, + }); + } + cells.set(m.name, { + name: m.name, + value: addr, + isPointer: true, + typeHint: m.type, + }); + } else { + cells.set(m.name, { + name: m.name, + value: m.value, + typeHint: m.type, + }); + } + } + } + columnCells.push(cells); + } + + const columns: ArrayTableColumn[] = elements.map((el, i) => ({ + id: /^\[\d+\]$/.test(el.name) ? el.name : `[${el.name}]`, + values: memberNames.map( + (m) => columnCells[i].get(m) || { name: m, value: "" } + ), + })); + + nodes.set(id, { + id, + fields: [], + arrayTable: { memberNames, columns, totalCount, truncated }, + }); + } + + private async addField( + child: DapVariable, + fields: NodeField[], + edges: GraphEdge[], + nodes: Map, + visited: Set, + ownerId: string, + depth: number + ) { + const addr = this.extractAddress(child.value); + if (addr && this.looksLikePointer(child)) { + fields.push({ + name: child.name, + value: addr, + isPointer: true, + typeHint: child.type, + }); + if (child.variablesReference > 0) { + const targetId = await this.walkNodeOrArrayTable( + child.variablesReference, + child.value, + nodes, + edges, + visited + ); + edges.push({ from: ownerId, to: targetId, label: child.name }); + } + return; + } + + if (child.variablesReference > 0) { + const grandchildren = await this.getResolvedChildren( + child.variablesReference + ); + + if (this.looksLikeArrayOfStructs(grandchildren)) { + const targetId = await this.walkNodeOrArrayTable( + child.variablesReference, + child.value, + nodes, + edges, + visited, + grandchildren + ); + edges.push({ from: ownerId, to: targetId, label: child.name }); + fields.push({ + name: child.name, + value: targetId, + isPointer: true, + typeHint: child.type, + }); + return; + } + + if (depth < INLINE_FLATTEN_MAX_DEPTH) { + if (grandchildren.length === 0) { + fields.push({ name: child.name, value: child.value, typeHint: child.type }); + return; + } + for (const gc of grandchildren) { + await this.addField( + { ...gc, name: `${child.name}.${gc.name}` }, + fields, + edges, + nodes, + visited, + ownerId, + depth + 1 + ); + } + return; + } + } + + fields.push({ name: child.name, value: child.value, typeHint: child.type }); + } +} diff --git a/extension/src/VisualizationBackend/index.ts b/extension/src/VisualizationBackend/index.ts index 6558860..517c8cd 100644 --- a/extension/src/VisualizationBackend/index.ts +++ b/extension/src/VisualizationBackend/index.ts @@ -6,3 +6,4 @@ export { PyEvaluationEngine } from "./PyVisualizationSupport"; export { RbEvaluationEngine } from './RbVisualizationSupport'; export { GenericVisualizationSupport } from "./GenericVisualizationSupport"; export { ConfigurableVisualizationSupport } from "./ConfigurableVisualizationSupport"; +export { NodeTableGraphEngine } from "./NodeTableGraphVisualizationSupport"; diff --git a/extension/src/extension.ts b/extension/src/extension.ts index fb6deb2..d5d17ba 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -25,6 +25,7 @@ import { RbEvaluationEngine, GenericVisualizationSupport, ConfigurableVisualizationSupport, + NodeTableGraphEngine, } from "./VisualizationBackend"; import { DispatchingVisualizationBackend } from "./VisualizationBackend/DispatchingVisualizationBackend"; @@ -48,6 +49,7 @@ export class Extension { public readonly dataSource = new VisualizationWatchModelImpl( new DispatchingVisualizationBackend( new ComposedVisualizationSupport([ + new NodeTableGraphEngine(this.debuggerView, this.config), new ConfigurableVisualizationSupport( this.config, this.debuggerView diff --git a/extension/src/proxies/DebugSessionProxy.ts b/extension/src/proxies/DebugSessionProxy.ts index 8a48a15..ce29972 100644 --- a/extension/src/proxies/DebugSessionProxy.ts +++ b/extension/src/proxies/DebugSessionProxy.ts @@ -113,6 +113,7 @@ interface Variable { name: string; value: string; variablesReference: number; + type?: string; } export interface StackFrame { diff --git a/webview/src/model/Model.ts b/webview/src/model/Model.ts index 427dc5c..3e29d60 100644 --- a/webview/src/model/Model.ts +++ b/webview/src/model/Model.ts @@ -4,6 +4,7 @@ import { EventTimer } from "@hediet/std/timer"; import { ConsoleRpcLogger } from "@hediet/typed-json-rpc"; import { WebSocketStream } from "@hediet/typed-json-rpc-websocket"; import "@hediet/visualization-bundle"; +import "../visualizers/nodeTableGraphVisualizer"; import { RegisterVisualizerFn, Visualization, diff --git a/webview/src/visualizers/nodeTableGraphVisualizer.ts b/webview/src/visualizers/nodeTableGraphVisualizer.ts new file mode 100644 index 0000000..145af35 --- /dev/null +++ b/webview/src/visualizers/nodeTableGraphVisualizer.ts @@ -0,0 +1,1764 @@ +// @ts-nocheck +// Built-in "Node Table Graph" visualization. +// Renders a { kind: { nodeTable: true }, nodes: [...], edges: [...] } payload +// as a row of boxes -- one per struct/node -- where each box is a table of +// that node's member variables (address in the header, fields below, +// pointer values in hex), connected by arrows for pointer fields. +// Nodes are draggable, the canvas is pannable/zoomable, and a toolbar lets +// you switch between a force-directed ("Auto", vis-network-style) layout +// and the original left-to-right layered chain layout, or fit everything +// back into view. +// +// Ported verbatim (mechanical changes only -- see git history) from a +// customVisualizerScriptPaths plugin into a statically-registered built-in; +// left untyped (`@ts-nocheck`) since it's a direct port, not new code worth +// retrofitting types onto. +// +// Data shape produced by NodeTableGraphVisualizationSupport.ts's DAP-based +// walker (extension host side): +// nodes: [{ id: "0x...", fields: [{ name, value, isPointer? }, ...] }] +// edges: [{ from: "0x...", to: "0x...", label? }] +// roots: [{ name: "top", value: "0x...", kind? }] -- external pointer +// variables (e.g. a local `top`/`end` in the caller's stack frame); +// drawn as a small pill that tracks above whichever node's id matches +// its value. kind is "local" | "watched" | "global" (missing/unknown +// treated as "watched"); the toolbar offers a show/hide toggle per +// kind that's actually present in the data. +// lang: "cpp" | "rust" | "python" -- which assignment-expression syntax +// editing a value needs (see buildAssignmentExpression below); missing +// for JSON produced by an older, unpatched debug-graph module. +// +// Editing: double-clicking a *non-pointer* field's value (or an unmatched +// scalar root's value row) turns it into a text input. Enter constructs a +// real write-expression for the active language and sends it through +// globalThis.debugVisualizerSetValue -- a bridge only present when both the +// Debug Visualizer extension and this plugin have been patched to support +// editing (see this repo's evaluateForEffect); on an unpatched install the +// double-click handler simply doesn't attach, so the visualizer degrades to +// its normal read-only behavior instead of failing. +// Pointer fields stay read-only in this version -- reassigning a pointer +// safely needs a way to pick *what* to point it at, not just a text box. + +import { createVisualizer, globalVisualizationFactory, libImplementation } from "@hediet/visualization-core"; + +// ---- editable-value expression construction (pure, unit-testable) -------- + +function quoteForLang(lang, text) { + if (lang === "python") { + return "'" + text.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; + } + // cpp/rust: double-quoted string literal escaping + return '"' + text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; +} + +// A light heuristic, not a real per-language literal grammar: numbers pass +// through bare, everything else is treated as (and quoted like) a string. +// Good enough for this demo's int/i32/str fields; a real type-aware editor +// would use the field's typeHint instead of guessing from the text. +function literalForLang(lang, rawText) { + if (/^-?\d+(\.\d+)?$/.test(rawText.trim())) { + return rawText.trim(); + } + return quoteForLang(lang, rawText); +} + +// Writes into a struct/object field living at `node.id`, given the node's +// reported `type` -- verified against real gdb/rust-gdb/debugpy sessions +// for this demo's Node/list_example::Node/Node shapes: +// cpp: ((Node*)0x...)->data = 99 +// rust: (*(0x... as *mut list_example::Node)).data = 99 +// python: setattr(__import__('ctypes').cast(0x..., __import__('ctypes').py_object).value, 'data', 99) +function buildFieldAssignmentExpression(lang, node, fieldName, rawValueText) { + var literal = literalForLang(lang, rawValueText); + if (lang === "rust") { + return "(*(" + node.id + " as *mut " + node.type + "))." + fieldName + " = " + literal; + } + if (lang === "python") { + return ( + "setattr(__import__('ctypes').cast(" + node.id + ", __import__('ctypes').py_object).value, " + + quoteForLang("python", fieldName) + ", " + literal + ")" + ); + } + // cpp (default) + return "((" + node.type + "*)" + node.id + ")->" + fieldName + " = " + literal; +} + +// Rebinds a plain local/global variable by name -- standard in gdb's and +// rust-gdb's expression parsers; relies on debugpy/pydevd's evaluate(repl) +// handler syncing the frame's fast-locals after the assignment exec's, +// the same mechanism that makes "x = 5" in VS Code's own Python Debug +// Console update the Variables view. +function buildVariableAssignmentExpression(lang, name, rawValueText) { + return name + " = " + literalForLang(lang, rawValueText); +} + +var sj = libImplementation.semanticJson; + +var sField = sj.sOpenObject({ + name: sj.sString(), + value: sj.sString(), + isPointer: sj.sOptionalProp(sj.sBoolean(), {}), + typeHint: sj.sOptionalProp(sj.sString(), {}), +}); + +var sArrayTableColumn = sj.sOpenObject({ + id: sj.sString(), + values: sj.sArrayOf(sField), +}); + +var sArrayTable = sj.sOpenObject({ + memberNames: sj.sArrayOf(sj.sString()), + columns: sj.sArrayOf(sArrayTableColumn), + totalCount: sj.sOptionalProp(sj.sNumber(), {}), + truncated: sj.sOptionalProp(sj.sBoolean(), {}), +}); + +var sNode = sj.sOpenObject({ + id: sj.sString(), + fields: sj.sOptionalProp(sj.sArrayOf(sField), {}), + type: sj.sOptionalProp(sj.sString(), {}), + // Present instead of meaningful `fields` when this node is an array of + // struct-shaped elements -- see NodeTableGraphVisualizationSupport.ts's + // buildArrayTableNode. Rendered as one row per member, one column per + // element, instead of the usual name/value field table. + arrayTable: sj.sOptionalProp(sArrayTable, {}), +}); + +var sEdge = sj.sOpenObject({ + from: sj.sString(), + to: sj.sString(), + label: sj.sOptionalProp(sj.sString(), {}), +}); + +var sRoot = sj.sOpenObject({ + name: sj.sString(), + value: sj.sString(), + kind: sj.sOptionalProp(sj.sString(), {}), + type: sj.sOptionalProp(sj.sString(), {}), +}); + +var sData = sj.sOpenObject({ + kind: sj.sOpenObject({ nodeTable: sj.sLiteral(true) }), + lang: sj.sOptionalProp(sj.sString(), {}), + nodes: sj.sArrayOf(sNode), + edges: sj.sArrayOf(sEdge), + roots: sj.sOptionalProp(sj.sArrayOf(sRoot), {}), +}); + +var HEADER_HEIGHT = 24; +var ROW_HEIGHT = 20; +var MAX_VISIBLE_ROWS = 10; +var NODE_WIDTH = 220; +var H_GAP = 70; +var V_GAP = 24; +var ROOT_BADGE_HEIGHT = 22; +var ROOT_GAP = 30; +var MIN_ZOOM = 0.15; +var MAX_ZOOM = 3; +var MIN_NODE_WIDTH = 100; +var MIN_NODE_HEIGHT = HEADER_HEIGHT + ROW_HEIGHT; +var RESIZE_EDGE_SIZE = 6; +var RESIZE_CORNER_SIZE = 10; +// A field row's name+typeHint column is otherwise unbounded (nowrap, no +// max-width), so a long field name or type could squeeze -- or in table +// auto-layout, actually push clean off the edge of -- the value column +// entirely. Capping the name column at "whatever's left after reserving +// this many characters for the value" keeps the value legible no matter +// how long the name/type text is. +var MIN_VALUE_CH = 15; +// An array-table node shows this many array-element columns at a time -- +// large arrays page through the rest instead of laying out hundreds of +// columns (and firing hundreds of per-element DAP round trips worth of DOM) +// at once. Still resizable/scrollable like any other node on top of that. +var ARRAY_COLUMNS_PER_PAGE = 15; +var ARRAY_COLUMN_WIDTH = 70; +var ARRAY_PAGE_BAR_HEIGHT = 18; + +function arrayPageBarHeight(n) { + return n.arrayTable && n.arrayTable.columns.length > ARRAY_COLUMNS_PER_PAGE ? ARRAY_PAGE_BAR_HEIGHT : 0; +} + +// ---- seed layout: left-to-right BFS levels, also used as "Layered" mode ---- +function layeredPositions(nodeIds, edges, boxHeights) { + var adjacency = {}; + var incoming = {}; + nodeIds.forEach(function (id) { + adjacency[id] = []; + incoming[id] = 0; + }); + edges.forEach(function (e) { + if (adjacency[e.from]) adjacency[e.from].push(e.to); + if (incoming[e.to] !== undefined) incoming[e.to]++; + }); + + var level = {}; + var queue = []; + nodeIds.forEach(function (id) { + if (incoming[id] === 0) { + level[id] = 0; + queue.push(id); + } + }); + if (queue.length === 0 && nodeIds.length > 0) { + level[nodeIds[0]] = 0; + queue.push(nodeIds[0]); + } + + var head = 0; + var maxLevel = 0; + while (head < queue.length) { + var cur = queue[head++]; + var curLevel = level[cur]; + maxLevel = Math.max(maxLevel, curLevel); + (adjacency[cur] || []).forEach(function (next) { + if (level[next] === undefined) { + level[next] = curLevel + 1; + queue.push(next); + } + }); + } + + var colCount = {}; + var positions = {}; + nodeIds.forEach(function (id) { + var lvl = level[id]; + if (lvl === undefined) { + maxLevel++; + lvl = maxLevel; + } + var col = colCount[lvl] || 0; + colCount[lvl] = col + 1; + var x = lvl * (NODE_WIDTH + H_GAP) + NODE_WIDTH / 2; + var y = col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP) + boxHeights[id] / 2; + positions[id] = { x: x, y: y }; + }); + return positions; +} + +// ---- simple O(n^2) force-directed layout, vis-network-style ---- +// isVisible(id) lets collapsed-subtree nodes sit out of the simulation +// entirely (frozen, not just hidden) so they don't invisibly shove +// visible nodes around, and reappear where they last were when expanded. +function createSimulation(nodeList, edges, isVisible) { + var REPULSION = 55000; + var SPRING_LENGTH = NODE_WIDTH + H_GAP; + var SPRING_STIFFNESS = 0.02; + // A pointer's target should sit near it whenever the rest of the + // graph allows -- but plain repulsion is global (every node pushes + // every other node), so in a busy graph the combined push from + // everything *else* can easily outweigh the one spring pulling a + // connected pair together, leaving "next" sitting far from the node + // it points at. Cutting repulsion between directly-linked pairs lets + // their spring actually win that tug-of-war, while collision + // avoidance below still keeps them from touching. + var LINKED_REPULSION_SCALE = 0.3; + // Boxes vary a lot in height (one row vs. MAX_VISIBLE_ROWS), so the + // plain 1/dist^2 repulsion above -- tuned for the common case -- + // doesn't reliably keep a short box and a tall box apart. This adds + // a second force, zero until two boxes' padded rects actually + // intersect and growing with how deep the overlap is, along + // whichever axis has the smaller overlap. Modeling it as a force + // (added into the same fx/fy accumulator as repulsion/spring, then + // run through the normal damped integrator below) instead of a + // direct position snap is what makes it converge instead of + // fighting the spring/gravity every frame: an earlier version that + // hard-set positions (and zeroed velocity) after integration could + // get stuck in a permanent tug-of-war with gravity pulling chain + // ends back in, never settling. COLLISION_PAD is deliberately much + // smaller than H_GAP/V_GAP -- it's a last-resort "don't visually + // touch" floor, not the comfortable resting distance (that's still + // SPRING_LENGTH); asking it for a full H_GAP-sized gap made it + // fight the spring hard enough to be the same instability. + var COLLISION_PAD = 16; + var COLLISION_STIFFNESS = 1; + var DAMPING = 0.82; + var GRAVITY = 0.01; + + var byId = {}; + nodeList.forEach(function (n) { + byId[n.id] = n; + }); + + var linked = {}; + edges.forEach(function (e) { + var key = e.from < e.to ? e.from + "|" + e.to : e.to + "|" + e.from; + linked[key] = true; + }); + + function step() { + var fx = {}; + var fy = {}; + nodeList.forEach(function (n) { + fx[n.id] = 0; + fy[n.id] = 0; + }); + + for (var i = 0; i < nodeList.length; i++) { + for (var j = i + 1; j < nodeList.length; j++) { + var a = nodeList[i]; + var b = nodeList[j]; + if (!isVisible(a.id) || !isVisible(b.id)) continue; + var dx = a.x - b.x; + var dy = a.y - b.y; + var distSq = dx * dx + dy * dy || 0.01; + var dist = Math.sqrt(distSq); + var key = a.id < b.id ? a.id + "|" + b.id : b.id + "|" + a.id; + var scale = linked[key] ? LINKED_REPULSION_SCALE : 1; + var force = (REPULSION * scale) / distSq; + var ux = dx / dist; + var uy = dy / dist; + fx[a.id] += ux * force; + fy[a.id] += uy * force; + fx[b.id] -= ux * force; + fy[b.id] -= uy * force; + + var minDX = (a.width + b.width) / 2 + COLLISION_PAD; + var minDY = (a.height + b.height) / 2 + COLLISION_PAD; + var overlapX = minDX - Math.abs(dx); + var overlapY = minDY - Math.abs(dy); + if (overlapX > 0 && overlapY > 0) { + if (overlapX < overlapY) { + var cf = overlapX * COLLISION_STIFFNESS; + var csign = dx < 0 ? -1 : 1; + fx[a.id] += csign * cf; + fx[b.id] -= csign * cf; + } else { + var cf2 = overlapY * COLLISION_STIFFNESS; + var csign2 = dy < 0 ? -1 : 1; + fy[a.id] += csign2 * cf2; + fy[b.id] -= csign2 * cf2; + } + } + } + } + + edges.forEach(function (e) { + if (!isVisible(e.from) || !isVisible(e.to)) return; + var a = byId[e.from]; + var b = byId[e.to]; + if (!a || !b) return; + var dx = b.x - a.x; + var dy = b.y - a.y; + var dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + var diff = dist - SPRING_LENGTH; + var force = diff * SPRING_STIFFNESS; + var ux = dx / dist; + var uy = dy / dist; + fx[a.id] += ux * force; + fy[a.id] += uy * force; + fx[b.id] -= ux * force; + fy[b.id] -= uy * force; + }); + + var kinetic = 0; + nodeList.forEach(function (n) { + if (n.fixed || !isVisible(n.id)) { + n.vx = 0; + n.vy = 0; + return; + } + fx[n.id] -= n.x * GRAVITY; + fy[n.id] -= n.y * GRAVITY; + n.vx = (n.vx + fx[n.id]) * DAMPING; + n.vy = (n.vy + fy[n.id]) * DAMPING; + n.x += n.vx; + n.y += n.vy; + kinetic += n.vx * n.vx + n.vy * n.vy; + }); + + return kinetic; + } + + return { nodes: nodeList, byId: byId, step: step }; +} + +// Where a line from (fromX,fromY) toward box-center (cx,cy) crosses that +// box's boundary -- used to end an edge at the target's edge instead of +// its center. Ending at the center buries the last stretch of the line +// (and its arrowhead marker) under the box's own opaque background, +// since boxes are appended to the DOM after the edge SVG and so paint +// over it wherever they'd overlap. +function boxEdgePoint(cx, cy, halfW, halfH, fromX, fromY) { + var dx = fromX - cx; + var dy = fromY - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; + var tx = dx !== 0 ? halfW / Math.abs(dx) : Infinity; + var ty = dy !== 0 ? halfH / Math.abs(dy) : Infinity; + var t = Math.min(tx, ty); + return { x: cx + dx * t, y: cy + dy * t }; +} + +// ---- collapse/expand: purely client-side, over the already-fetched graph ---- +function subtreeSize(startChildren, childrenOf) { + var seen = {}; + var stack = startChildren.slice(); + while (stack.length) { + var id = stack.pop(); + if (seen[id]) continue; + seen[id] = true; + (childrenOf[id] || []).forEach(function (c) { + if (!seen[c]) stack.push(c); + }); + } + return Object.keys(seen).length; +} + +function el(tag, style, attrs) { + var e = document.createElement(tag); + if (style) Object.assign(e.style, style); + if (attrs) Object.keys(attrs).forEach(function (k) { + e.setAttribute(k, attrs[k]); + }); + return e; +} + +function button(label, colors) { + var b = el( + "button", + { + font: "12px var(--vscode-editor-font-family, monospace)", + padding: "3px 8px", + borderRadius: "4px", + border: "1px solid " + colors.border, + background: colors.headerBg, + color: colors.headerFg, + cursor: "pointer", + }, + { type: "button" } + ); + b.textContent = label; + return b; +} + +var ROOT_KINDS = ["local", "watched", "global"]; +var ROOT_KIND_LABELS = { local: "Locals", watched: "Watched", global: "Globals" }; + +function rootKind(r) { + return ROOT_KINDS.indexOf(r.kind) !== -1 ? r.kind : "watched"; +} + +function kindToggle(labelText, colors) { + var wrap = el("label", { + display: "flex", + alignItems: "center", + gap: "4px", + font: "12px var(--vscode-editor-font-family, monospace)", + padding: "3px 8px", + borderRadius: "4px", + border: "1px solid " + colors.border, + background: colors.headerBg, + color: colors.headerFg, + cursor: "pointer", + userSelect: "none", + }); + var input = el("input", { cursor: "pointer" }, { type: "checkbox" }); + input.checked = true; + wrap.appendChild(input); + wrap.appendChild(document.createTextNode(labelText)); + return { wrap: wrap, input: input }; +} + +// Manually moved/resized nodes should survive a refresh (step, hit a +// breakpoint again, ...) instead of snapping back to the auto layout -- +// render() below is called fresh on every refresh (it does +// `target.innerHTML = ""` and rebuilds everything), so this has to live at +// module scope, outside render(), to actually survive across those calls. +// Keyed by node id -- a real pointer address for most nodes, so stable +// across steps as long as the underlying object doesn't move/get freed. +// Synthetic ids (used only for pointerless scalar roots) are a simple +// per-render counter, so they aren't guaranteed unique across two different +// simultaneously-open node-table-graph panels; this is scoped to the common +// case of one panel stepping through time, not airtight isolation between +// panels. +// +// Never pruned on a node's id being merely absent from one refresh's +// data.nodes -- e.g. watching a `current`-style cursor pointer means each +// step's graph only includes whatever's reachable from it right now, so an +// earlier node can drop out and back in as the cursor walks forward. +// Pruning on "not in this snapshot" was tried and reverted: it deleted a +// still-relevant node's saved layout the instant it was transiently out of +// view, which is indistinguishable here from the node actually having been +// freed. Letting this grow for the life of the panel is the safer failure +// mode. +var savedNodeLayouts = {}; + +function render(data, target, theme) { + target.innerHTML = ""; + Object.assign(target.style, { + position: "relative", + overflow: "hidden", + width: "100%", + height: "100%", + boxSizing: "border-box", + }); + + var dark = theme === "dark"; + var colors = dark + ? { + border: "#5a5a5a", + headerBg: "#333333", + headerFg: "#e8e8e8", + bodyBg: "#252526", + fieldName: "#9cdcfe", + fieldValue: "#d4d4d4", + fieldType: "#7a7a7a", + pointerValue: "#ce9178", + edge: "#8a8a8a", + viewportBg: "#1e1e1e", + } + : { + border: "#b8b8b8", + headerBg: "#eeeeee", + headerFg: "#1a1a1a", + bodyBg: "#ffffff", + fieldName: "#0451a5", + fieldValue: "#1a1a1a", + fieldType: "#8a8a8a", + pointerValue: "#a31515", + edge: "#6b6b6b", + viewportBg: "#fafafa", + }; + + var roots = data.roots || []; + + var boxHeights = {}; + data.nodes.forEach(function (n) { + // +1 row for the array table's own column-index header row -- it + // scrolls together with the member rows below it rather than being + // pinned, same simple single-scroll-region approach as a regular + // node's field table. + var rowCount = n.arrayTable ? n.arrayTable.memberNames.length + 1 : n.fields.length; + var visibleRows = Math.min(rowCount, MAX_VISIBLE_ROWS + (n.arrayTable ? 1 : 0)); + boxHeights[n.id] = HEADER_HEIGHT + arrayPageBarHeight(n) + Math.max(visibleRows, 1) * ROW_HEIGHT; + }); + + var nodeIds = data.nodes.map(function (n) { + return n.id; + }); + + var seed = layeredPositions(nodeIds, data.edges, boxHeights); + var nodeList = data.nodes.map(function (n) { + var saved = savedNodeLayouts[n.id]; + // headerExtra always comes from the *current* data, even for a node + // with a saved layout -- whether the array-pagination bar is present + // can change between refreshes (e.g. the array grew past the page + // threshold), independent of the size the user dragged the box to. + var headerExtra = arrayPageBarHeight(n); + if (saved) { + return { + id: n.id, + x: saved.x, + y: saved.y, + vx: 0, + vy: 0, + fixed: true, + userPinned: true, + height: saved.height, + width: saved.width, + headerExtra: headerExtra, + }; + } + var p = seed[n.id]; + // Wide enough for a full page of columns by default (capped, so a + // huge array doesn't produce a huge initial box) -- still resizable + // smaller/larger by hand, and scrollable within a page either way. + var width = NODE_WIDTH; + if (n.arrayTable) { + var visibleCols = Math.min(n.arrayTable.columns.length, ARRAY_COLUMNS_PER_PAGE); + width = Math.max(NODE_WIDTH, 90 + visibleCols * ARRAY_COLUMN_WIDTH); + } + return { + id: n.id, + x: p.x, + y: p.y, + vx: 0, + vy: 0, + fixed: false, + height: boxHeights[n.id], + width: width, + headerExtra: headerExtra, + }; + }); + var byId = {}; + nodeList.forEach(function (n) { + byId[n.id] = n; + }); + + var childrenOf = {}; + var parentsOf = {}; + data.edges.forEach(function (e) { + (childrenOf[e.from] = childrenOf[e.from] || []).push(e.to); + (parentsOf[e.to] = parentsOf[e.to] || []).push(e.from); + }); + + var collapsed = {}; // nodeId -> bool, user-toggled + var visible = {}; // nodeId -> bool, recomputed by computeVisible() + + function computeVisible() { + var newVisible = {}; + var hasIncoming = {}; + data.edges.forEach(function (e) { + hasIncoming[e.to] = true; + }); + var roots = nodeIds.filter(function (id) { + return !hasIncoming[id]; + }); + if (roots.length === 0 && nodeIds.length > 0) roots = [nodeIds[0]]; + + var queue = roots.slice(); + roots.forEach(function (id) { + newVisible[id] = true; + }); + while (queue.length) { + var id = queue.shift(); + if (collapsed[id]) continue; // don't expand past a collapsed node + (childrenOf[id] || []).forEach(function (childId) { + if (!newVisible[childId]) { + newVisible[childId] = true; + queue.push(childId); + } + }); + } + visible = newVisible; + } + computeVisible(); + + // A local/global/watched pointer's target can end up hidden inside a + // collapsed subtree; rather than just hiding its arrow, walk up the + // edge graph to whichever visible ancestor is currently standing in + // for that hidden subtree, and point the arrow there instead -- so + // expanding that ancestor is what makes the arrow "arrive" at its + // real target. + function nearestVisibleTarget(id) { + var seen = {}; + var queue = [id]; + while (queue.length) { + var cur = queue.shift(); + if (visible[cur]) return cur; + if (seen[cur]) continue; + seen[cur] = true; + (parentsOf[cur] || []).forEach(function (p) { + if (!seen[p]) queue.push(p); + }); + } + return null; + } + + var sim = createSimulation(nodeList, data.edges, function (id) { + return !!visible[id]; + }); + var layoutMode = "auto"; // "auto" (physics) | "layered" (static chain) + var running = true; + + // ---- DOM: viewport (pan/zoom window) + world (transformed content) ---- + var viewport = el("div", { + position: "absolute", + left: "0", + top: "0", + width: "100%", + height: "100%", + overflow: "hidden", + background: colors.viewportBg, + cursor: "grab", + }); + + var world = el("div", { + position: "absolute", + left: "0", + top: "0", + transformOrigin: "0 0", + }); + + var svgNs = "http://www.w3.org/2000/svg"; + var svg = document.createElementNS(svgNs, "svg"); + Object.assign(svg.style, { position: "absolute", left: "0", top: "0", overflow: "visible", pointerEvents: "none" }); + var defs = document.createElementNS(svgNs, "defs"); + + function addArrowMarker(id, fill) { + var marker = document.createElementNS(svgNs, "marker"); + marker.setAttribute("id", id); + marker.setAttribute("markerWidth", "8"); + marker.setAttribute("markerHeight", "8"); + marker.setAttribute("refX", "7"); + marker.setAttribute("refY", "4"); + marker.setAttribute("orient", "auto"); + var arrowPath = document.createElementNS(svgNs, "path"); + arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z"); + arrowPath.setAttribute("fill", fill); + marker.appendChild(arrowPath); + defs.appendChild(marker); + } + // Struct-to-struct pointer fields (e.g. `next`) use the neutral edge + // color; local/watched/global variable pointers use the same accent + // color as pointer values in the field tables, so a glance at an + // arrow's color says whether it's "inside the data structure" or + // "a variable pointing into it". + addArrowMarker("dv-arrow", colors.edge); + addArrowMarker("dv-arrow-root", colors.pointerValue); + svg.appendChild(defs); + world.appendChild(svg); + + var edgePaths = data.edges.map(function (e) { + var line = document.createElementNS(svgNs, "path"); + line.setAttribute("fill", "none"); + line.setAttribute("stroke", colors.edge); + line.setAttribute("stroke-width", "1.5"); + line.setAttribute("marker-end", "url(#dv-arrow)"); + svg.appendChild(line); + var text = null; + if (e.label) { + text = document.createElementNS(svgNs, "text"); + text.setAttribute("fill", colors.edge); + text.setAttribute("font-size", "11"); + text.setAttribute("font-family", "monospace"); + text.setAttribute("text-anchor", "middle"); + text.textContent = e.label; + svg.appendChild(text); + } + return { edge: e, line: line, text: text }; + }); + + // ---- editable-value cells (double-click a scalar to change it) ---- + // Only wired up when the extension host has been patched to expose + // the write-back bridge -- otherwise the cell just stays plain text, + // same as before this feature existed. + var canEdit = typeof globalThis.debugVisualizerSetValue === "function"; + + function makeValueEditable(valueCell, currentText, buildExpression) { + if (!canEdit) return; + valueCell.style.cursor = "text"; + valueCell.title = (valueCell.title ? valueCell.title + " -- " : "") + "double-click to edit"; + + valueCell.addEventListener("mousedown", function (e) { + e.stopPropagation(); // don't start a box-drag on either click of the double-click + }); + + valueCell.addEventListener("dblclick", function (e) { + e.stopPropagation(); // don't start a box-drag + + var input = el("input", { + width: "100%", + boxSizing: "border-box", + font: "inherit", + color: "inherit", + background: colors.bodyBg, + border: "1px solid " + colors.pointerValue, + }); + input.value = currentText; + valueCell.textContent = ""; + valueCell.appendChild(input); + input.focus(); + input.select(); + + function restore(text, errorMessage) { + valueCell.textContent = text; + valueCell.style.outline = errorMessage ? "1px solid " + colors.pointerValue : ""; + valueCell.title = errorMessage || "double-click to edit"; + } + + function commit() { + var expression = buildExpression(input.value); + globalThis.debugVisualizerSetValue(expression).then(function (reply) { + if (reply && reply.success) { + currentText = input.value; + restore(currentText, null); + if (typeof globalThis.debugVisualizerRefresh === "function") { + globalThis.debugVisualizerRefresh(); + } + } else { + restore(currentText, (reply && reply.message) || "Edit failed."); + } + }); + } + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + restore(currentText, null); + } + }); + input.addEventListener("blur", function () { + // only revert on blur -- Enter's own commit() already + // replaced the input, so there's nothing left to blur + if (input.isConnected) restore(currentText, null); + }); + input.addEventListener("mousedown", function (e) { + e.stopPropagation(); + }); + }); + } + + var boxes = {}; + var bodies = {}; + var toggles = {}; + + // ---- resize handles (edges + corners) ----------------------------- + // Dragging a handle changes that node's own width/height (stored on + // its nodeList entry, read by layout/collision/edge code below instead + // of the NODE_WIDTH constant) and, for a "w"/"n"-ish handle, shifts the + // node's center so the *opposite* edge stays put -- the same anchoring + // a native window resize gives you, rather than the box growing only + // to one side regardless of which handle you grabbed. + // + // sizeKind says which of width/height is this handle's "thickness" (as + // opposed to the dimension that just spans the box's edge) -- world is + // scaled by camera.zoom (see applyCamera/updateResizeHandleSizes + // below), so a handle's *world-space* thickness has to grow at low zoom + // and shrink at high zoom to keep a constant on-screen hit target. + // Measured directly: a busy 25-node graph fits at ~0.5x zoom, which + // alone shrinks a naively-fixed 6px handle to ~3 screen px -- already + // unreliable to click, and at MIN_ZOOM (0.15x) it's under 1px. This is + // almost certainly what "resize stopped working" reports were actually + // hitting once real graphs (not single-node test cases) forced any + // noticeable zoom-out. + var RESIZE_DIRS = [ + { key: "n", cursor: "ns-resize", sizeKind: "edgeH", style: { top: "0", left: "0", right: "0", height: RESIZE_EDGE_SIZE + "px" } }, + { key: "s", cursor: "ns-resize", sizeKind: "edgeH", style: { bottom: "0", left: "0", right: "0", height: RESIZE_EDGE_SIZE + "px" } }, + { key: "e", cursor: "ew-resize", sizeKind: "edgeV", style: { top: "0", right: "0", bottom: "0", width: RESIZE_EDGE_SIZE + "px" } }, + { key: "w", cursor: "ew-resize", sizeKind: "edgeV", style: { top: "0", left: "0", bottom: "0", width: RESIZE_EDGE_SIZE + "px" } }, + { key: "ne", cursor: "nesw-resize", sizeKind: "corner", style: { top: "0", right: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } }, + { key: "nw", cursor: "nwse-resize", sizeKind: "corner", style: { top: "0", left: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } }, + { key: "se", cursor: "nwse-resize", sizeKind: "corner", style: { bottom: "0", right: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } }, + { key: "sw", cursor: "nesw-resize", sizeKind: "corner", style: { bottom: "0", left: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } }, + ]; + + // Every handle ever created, across every node -- updateResizeHandleSizes() + // (called from applyCamera(), so it stays current through zoom/pan/fit) + // walks this flat list rather than needing a per-node lookup. + var resizeHandleEls = []; + + function updateResizeHandleSizes() { + var edgePx = RESIZE_EDGE_SIZE / camera.zoom; + var cornerPx = RESIZE_CORNER_SIZE / camera.zoom; + resizeHandleEls.forEach(function (h) { + if (h.sizeKind === "edgeH") { + h.el.style.height = edgePx + "px"; + } else if (h.sizeKind === "edgeV") { + h.el.style.width = edgePx + "px"; + } else { + h.el.style.width = cornerPx + "px"; + h.el.style.height = cornerPx + "px"; + } + }); + } + + function addResizeHandles(box, nodeId) { + RESIZE_DIRS.forEach(function (d) { + var handle = el( + "div", + Object.assign( + { position: "absolute", zIndex: "5", cursor: d.cursor, borderRadius: "2px" }, + d.style + ) + ); + // The handle itself has no permanent visual weight (a border or + // fill on every corner/edge of every box would be a lot of + // visual noise on a busy graph) -- it only reveals itself, via a + // solid accent fill, on hover/while actively being dragged. That + // was the actual "handles aren't showing" bug: there was never + // any visual indicator at all, only a cursor change over an + // easy-to-miss few-pixel-wide strip. + handle.addEventListener("mouseenter", function () { + handle.style.background = colors.pointerValue; + }); + handle.addEventListener("mouseleave", function () { + if (!resizeDrag || resizeDrag.node !== byId[nodeId] || resizeDrag.dir !== d.key) { + handle.style.background = ""; + } + }); + handle.addEventListener("mousedown", function (e) { + e.stopPropagation(); // don't start a box-drag or a pan + e.preventDefault(); + var node = byId[nodeId]; + node.fixed = true; + node.userPinned = true; + resizeDrag = { + node: node, + dir: d.key, + startX: e.clientX, + startY: e.clientY, + startWidth: node.width, + startHeight: node.height, + startNodeX: node.x, + startNodeY: node.y, + handleEl: handle, + }; + }); + // Appended after the box's own content, and corners after + // edges, so corner handles win the hit-test in the small + // squares where an edge strip and a corner square overlap. + box.appendChild(handle); + resizeHandleEls.push({ el: handle, sizeKind: d.sizeKind }); + }); + } + + data.nodes.forEach(function (n) { + var box = el("div", { + position: "absolute", + width: NODE_WIDTH + "px", + border: "1px solid " + colors.border, + borderRadius: "6px", + background: colors.bodyBg, + boxShadow: "0 1px 3px rgba(0,0,0,0.3)", + fontFamily: "var(--vscode-editor-font-family, monospace)", + fontSize: "12px", + overflow: "hidden", + cursor: "grab", + }); + + var header = el("div", { + padding: "4px 8px", + background: colors.headerBg, + color: colors.headerFg, + fontWeight: "bold", + borderBottom: "1px solid " + colors.border, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "6px", + }); + var headerLabel = el("span", { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + minWidth: "0", + }); + headerLabel.appendChild(document.createTextNode(n.id)); + if (n.type) { + var nodeTypeSpan = el("span", { + color: colors.fieldType, + fontSize: "10px", + fontWeight: "normal", + marginLeft: "6px", + }); + nodeTypeSpan.textContent = n.type; + headerLabel.appendChild(nodeTypeSpan); + header.title = n.id + " : " + n.type; + } + header.appendChild(headerLabel); + + var kids = childrenOf[n.id]; + if (kids && kids.length) { + var toggle = el("span", { + flexShrink: "0", + cursor: "pointer", + border: "1px solid " + colors.border, + borderRadius: "3px", + padding: "0 5px", + fontSize: "11px", + lineHeight: "14px", + userSelect: "none", + }); + toggle.addEventListener("mousedown", function (e) { + e.stopPropagation(); // don't start a box-drag + }); + toggle.addEventListener("click", function (e) { + e.stopPropagation(); + collapsed[n.id] = !collapsed[n.id]; + computeVisible(); + redraw(); + if (layoutMode === "auto") stabilize(200); + }); + header.appendChild(toggle); + toggles[n.id] = toggle; + } + box.appendChild(header); + + var body = el("div", { + // Height is set explicitly (from the node's own, possibly + // user-resized, height) by redraw() below -- not a fixed + // maxHeight -- so growing a box via its resize handles reveals + // more rows instead of just adding empty scrollable space. + overflowY: "auto", + overflowX: n.arrayTable ? "auto" : "hidden", + }); + var table = el("table", { borderCollapse: "collapse", width: n.arrayTable ? "auto" : "100%" }); + + if (n.arrayTable) { + // One row per member, one column per array element -- a compact + // alternative to a chain of near-identical per-element boxes. + // Member names are a plain first column (not a sticky header) + // same single-scroll-region simplicity as a regular node's table. + // Shown a page (ARRAY_COLUMNS_PER_PAGE columns) at a time -- + // rebuildArrayTableRows() replaces just these rows when the user + // pages, everything else about the box is untouched. + var page = 0; + function pageCount() { + return Math.max(1, Math.ceil(n.arrayTable.columns.length / ARRAY_COLUMNS_PER_PAGE)); + } + function rebuildArrayTableRows() { + table.innerHTML = ""; + var start = page * ARRAY_COLUMNS_PER_PAGE; + var pageColumns = n.arrayTable.columns.slice(start, start + ARRAY_COLUMNS_PER_PAGE); + + var headerRow = el("tr"); + headerRow.appendChild(el("td", { padding: "2px 8px", height: ROW_HEIGHT - 4 + "px" })); + pageColumns.forEach(function (col) { + var th = el("td", { + padding: "2px 8px", + color: colors.fieldType, + fontWeight: "bold", + whiteSpace: "nowrap", + textAlign: "center", + }); + th.textContent = col.id; + headerRow.appendChild(th); + }); + table.appendChild(headerRow); + + n.arrayTable.memberNames.forEach(function (memberName, rowIndex) { + var row = el("tr"); + var nameCell = el("td", { + padding: "2px 8px", + color: colors.fieldName, + whiteSpace: "nowrap", + height: ROW_HEIGHT - 4 + "px", + }); + nameCell.appendChild(document.createTextNode(memberName)); + row.appendChild(nameCell); + + pageColumns.forEach(function (col) { + var f = col.values[rowIndex]; + var valueCell = el("td", { + padding: "2px 8px", + color: f.isPointer ? colors.pointerValue : colors.fieldValue, + whiteSpace: "nowrap", + textAlign: "center", + }); + valueCell.textContent = f.value; + if (f.typeHint) valueCell.title = memberName + ": " + f.typeHint + " = " + f.value; + row.appendChild(valueCell); + }); + table.appendChild(row); + }); + } + rebuildArrayTableRows(); + + if (n.arrayTable.columns.length > ARRAY_COLUMNS_PER_PAGE || n.arrayTable.truncated) { + var pageBar = el("div", { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "4px", + padding: "0 6px", + height: ARRAY_PAGE_BAR_HEIGHT + "px", + borderBottom: "1px solid " + colors.border, + font: "10px var(--vscode-editor-font-family, monospace)", + color: colors.fieldType, + }); + var prevBtn = el("span", { cursor: "pointer", padding: "0 4px", userSelect: "none" }); + prevBtn.textContent = "‹"; + var nextBtn = el("span", { cursor: "pointer", padding: "0 4px", userSelect: "none" }); + nextBtn.textContent = "›"; + var pageLabel = el("span", { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }); + + function updatePageBar() { + var pc = pageCount(); + var label = "Page " + (page + 1) + "/" + pc; + if (n.arrayTable.truncated) { + label += " (" + n.arrayTable.columns.length + " of " + n.arrayTable.totalCount + " fetched)"; + } + pageLabel.textContent = label; + prevBtn.style.opacity = page <= 0 ? "0.35" : "1"; + nextBtn.style.opacity = page >= pc - 1 ? "0.35" : "1"; + } + [prevBtn, nextBtn].forEach(function (b) { + b.addEventListener("mousedown", function (e) { + e.stopPropagation(); // don't start a box-drag + }); + }); + prevBtn.addEventListener("click", function (e) { + e.stopPropagation(); + if (page <= 0) return; + page--; + rebuildArrayTableRows(); + updatePageBar(); + }); + nextBtn.addEventListener("click", function (e) { + e.stopPropagation(); + if (page >= pageCount() - 1) return; + page++; + rebuildArrayTableRows(); + updatePageBar(); + }); + updatePageBar(); + + pageBar.appendChild(prevBtn); + pageBar.appendChild(pageLabel); + pageBar.appendChild(nextBtn); + box.appendChild(pageBar); + } + } else { + // Plain values first, pointers below -- so the data you'd read at + // a glance isn't pushed below the fold by hex addresses, and so + // a box with many fields still shows its scalars without + // scrolling past pointers to reach them. Stable sort keeps each + // group in its original (declaration) order. + var orderedFields = n.fields.slice().sort(function (a, b) { + return (a.isPointer ? 1 : 0) - (b.isPointer ? 1 : 0); + }); + + orderedFields.forEach(function (f) { + var row = el("tr"); + // Always available on hover, not just when typeHint is set -- + // the name itself is now truncatable too (see nameCell below). + row.title = f.name + (f.typeHint ? ": " + f.typeHint : "") + " = " + f.value; + var nameCell = el("td", { + padding: "2px 8px", + color: colors.fieldName, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + // Leave at least MIN_VALUE_CH worth of the row for the + // value column -- see valueCell's matching minWidth below. + maxWidth: "calc(100% - " + MIN_VALUE_CH + "ch)", + height: ROW_HEIGHT - 4 + "px", + }); + nameCell.appendChild(document.createTextNode(f.name)); + if (f.typeHint) { + var typeSpan = el("span", { + color: colors.fieldType, + fontSize: "10px", + marginLeft: "5px", + }); + typeSpan.textContent = " " + f.typeHint; + nameCell.appendChild(typeSpan); + } + var valueCell = el("td", { + padding: "2px 8px", + color: f.isPointer ? colors.pointerValue : colors.fieldValue, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: "0", + minWidth: MIN_VALUE_CH + "ch", + width: "100%", + }); + valueCell.textContent = f.value; + if (!f.isPointer) { + makeValueEditable(valueCell, f.value, function (newText) { + return buildFieldAssignmentExpression(data.lang || "cpp", n, f.name, newText); + }); + } + row.appendChild(nameCell); + row.appendChild(valueCell); + table.appendChild(row); + }); + } + + body.appendChild(table); + box.appendChild(body); + addResizeHandles(box, n.id); + world.appendChild(box); + boxes[n.id] = box; + bodies[n.id] = body; + }); + + var rootBadges = roots.map(function (r) { + var matched = !!byId[r.value]; + + if (matched) { + var badge = el("div", { + position: "absolute", + width: NODE_WIDTH / 2 + "px", + height: ROOT_BADGE_HEIGHT + "px", + lineHeight: ROOT_BADGE_HEIGHT + "px", + textAlign: "center", + borderRadius: "11px", + border: "1px solid " + colors.border, + background: colors.headerBg, + color: colors.headerFg, + fontFamily: "var(--vscode-editor-font-family, monospace)", + fontSize: "12px", + fontWeight: "bold", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + badge.title = r.name + (r.type ? " : " + r.type : "") + " = " + r.value; + badge.textContent = r.name; + world.appendChild(badge); + var line = document.createElementNS(svgNs, "path"); + line.setAttribute("fill", "none"); + line.setAttribute("stroke", colors.pointerValue); + line.setAttribute("stroke-width", "1.5"); + line.setAttribute("marker-end", "url(#dv-arrow-root)"); + svg.appendChild(line); + return { root: r, kind: rootKind(r), badge: badge, line: line, matched: true, x: 0 }; + } + + // Not a pointer into the graph (a plain scalar local like `count`, + // or a null/unresolved pointer) -- show it as its own small card, + // name on top then type then value, the same shape as a graph + // node's field table, instead of collapsing it into one line of + // text. This is what makes non-pointer locals show up as boxes + // the way pointer locals already do via their target node. + var rows = []; + if (r.type) rows.push({ name: "type", value: r.type }); + rows.push({ name: "value", value: r.value }); + var height = HEADER_HEIGHT + rows.length * ROW_HEIGHT; + + var box = el("div", { + position: "absolute", + width: NODE_WIDTH + "px", + border: "1px solid " + colors.border, + borderRadius: "6px", + background: colors.bodyBg, + boxShadow: "0 1px 3px rgba(0,0,0,0.3)", + fontFamily: "var(--vscode-editor-font-family, monospace)", + fontSize: "12px", + overflow: "hidden", + cursor: "grab", + }); + var header = el("div", { + padding: "4px 8px", + background: colors.headerBg, + color: colors.headerFg, + fontWeight: "bold", + borderBottom: "1px solid " + colors.border, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }); + header.textContent = r.name; + box.appendChild(header); + var table = el("table", { borderCollapse: "collapse", width: "100%" }); + rows.forEach(function (row) { + var tr = el("tr"); + var nameCell = el("td", { + padding: "2px 8px", + color: colors.fieldName, + whiteSpace: "nowrap", + height: ROW_HEIGHT - 4 + "px", + }); + nameCell.textContent = row.name; + var valueCell = el("td", { + padding: "2px 8px", + color: colors.fieldValue, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + maxWidth: "0", + width: "100%", + }); + valueCell.textContent = row.value; + // Only the "value" row, and only when it doesn't look like a + // pointer (e.g. a null/unresolved root) -- reassigning a + // pointer-typed variable safely needs a cast on the new + // value too, which is out of scope for this version. + if (row.name === "value" && !/^0x[0-9a-f]+$/i.test(row.value)) { + makeValueEditable(valueCell, row.value, function (newText) { + return buildVariableAssignmentExpression(data.lang || "cpp", r.name, newText); + }); + } + tr.appendChild(nameCell); + tr.appendChild(valueCell); + table.appendChild(tr); + }); + box.appendChild(table); + box.title = r.name + (r.type ? " : " + r.type : "") + " = " + r.value; + world.appendChild(box); + + return { root: r, kind: rootKind(r), badge: box, line: null, matched: false, height: height, x: 0, y: 0, pinned: false }; + }); + var badgesPerTarget = {}; + + viewport.appendChild(world); + target.appendChild(viewport); + + // ---- toolbar ---- + var toolbar = el("div", { + position: "absolute", + right: "8px", + top: "8px", + display: "flex", + gap: "6px", + zIndex: "10", + }); + var autoBtn = button("Auto-arrange", colors); + var layeredBtn = button("Layered", colors); + var expandAllBtn = button("Expand all", colors); + var fitBtn = button("Fit", colors); + var zoomOutBtn = button("−", colors); + var zoomInBtn = button("+", colors); + [autoBtn, layeredBtn, expandAllBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) { + toolbar.appendChild(b); + }); + target.appendChild(toolbar); + + // ---- root-kind toggles (Locals / Watched / Globals) ---- + var rootKindEnabled = { local: true, watched: true, global: true }; + var kindsPresent = {}; + roots.forEach(function (r) { + kindsPresent[rootKind(r)] = true; + }); + var presentKinds = ROOT_KINDS.filter(function (k) { + return kindsPresent[k]; + }); + if (presentKinds.length > 0) { + var kindToolbar = el("div", { + position: "absolute", + left: "8px", + top: "8px", + display: "flex", + gap: "6px", + zIndex: "10", + }); + presentKinds.forEach(function (kind) { + var cb = kindToggle(ROOT_KIND_LABELS[kind], colors); + cb.input.addEventListener("change", function () { + rootKindEnabled[kind] = cb.input.checked; + redraw(); + }); + kindToolbar.appendChild(cb.wrap); + }); + target.appendChild(kindToolbar); + } + + expandAllBtn.addEventListener("click", function () { + collapsed = {}; + computeVisible(); + redraw(); + if (layoutMode === "auto") stabilize(200); + }); + + function highlightModeButtons() { + autoBtn.style.background = layoutMode === "auto" ? colors.pointerValue : colors.headerBg; + layeredBtn.style.background = layoutMode === "layered" ? colors.pointerValue : colors.headerBg; + } + highlightModeButtons(); + + // ---- camera (pan/zoom) ---- + var camera = { x: target.clientWidth / 2 - NODE_WIDTH / 2, y: 40, zoom: 1 }; + + function applyCamera() { + world.style.transform = "translate(" + camera.x + "px, " + camera.y + "px) scale(" + camera.zoom + ")"; + // Keeps every resize handle a constant on-screen size regardless of + // zoom -- called from here (rather than only once at setup) since + // zoomBy()/fitToView() are exactly the things that change camera.zoom. + updateResizeHandleSizes(); + } + + function zoomBy(factor, centerX, centerY) { + var newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor)); + camera.x = centerX - ((centerX - camera.x) * newZoom) / camera.zoom; + camera.y = centerY - ((centerY - camera.y) * newZoom) / camera.zoom; + camera.zoom = newZoom; + applyCamera(); + } + + function fitToView() { + var visibleNodes = nodeList.filter(function (n) { + return visible[n.id]; + }); + if (visibleNodes.length === 0) return; + var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + visibleNodes.forEach(function (n) { + minX = Math.min(minX, n.x - n.width / 2); + maxX = Math.max(maxX, n.x + n.width / 2); + minY = Math.min(minY, n.y - n.height / 2 - (roots.length ? ROOT_BADGE_HEIGHT + ROOT_GAP : 0)); + maxY = Math.max(maxY, n.y + n.height / 2); + }); + // Unmatched-root cards (scalar locals/globals/watches) sit in + // their own shelf above the node cloud, positioned by redraw() + // -- not part of nodeList, so fold their bounds in explicitly or + // "Fit" can crop them out of view entirely. + rootBadges.forEach(function (rb) { + if (rb.matched || !rootKindEnabled[rb.kind]) return; + minX = Math.min(minX, rb.x); + maxX = Math.max(maxX, rb.x + NODE_WIDTH); + minY = Math.min(minY, rb.y); + maxY = Math.max(maxY, rb.y + rb.height); + }); + var pad = 30; + var w = Math.max(1, maxX - minX + pad * 2); + var h = Math.max(1, maxY - minY + pad * 2); + var vw = target.clientWidth || 800; + var vh = target.clientHeight || 600; + var zoom = Math.min(MAX_ZOOM, Math.min(vw / w, vh / h)); + camera.zoom = zoom; + camera.x = vw / 2 - ((minX + maxX) / 2) * zoom; + camera.y = vh / 2 - ((minY + maxY) / 2) * zoom; + applyCamera(); + } + + viewport.addEventListener( + "wheel", + function (e) { + e.preventDefault(); + var rect = viewport.getBoundingClientRect(); + var factor = Math.pow(1.0015, -e.deltaY); + zoomBy(factor, e.clientX - rect.left, e.clientY - rect.top); + }, + { passive: false } + ); + + zoomInBtn.addEventListener("click", function () { + zoomBy(1.2, target.clientWidth / 2, target.clientHeight / 2); + }); + zoomOutBtn.addEventListener("click", function () { + zoomBy(1 / 1.2, target.clientWidth / 2, target.clientHeight / 2); + }); + fitBtn.addEventListener("click", fitToView); + autoBtn.addEventListener("click", function () { + layoutMode = "auto"; + highlightModeButtons(); + nodeList.forEach(function (n) { + if (!n.userPinned) { + n.fixed = false; + n.vx += (Math.random() - 0.5) * 2; + n.vy += (Math.random() - 0.5) * 2; + } + }); + stabilize(500); + redraw(); + fitToView(); + }); + layeredBtn.addEventListener("click", function () { + layoutMode = "layered"; + highlightModeButtons(); + var pos = layeredPositions(nodeIds, data.edges, boxHeights); + nodeList.forEach(function (n) { + n.x = pos[n.id].x; + n.y = pos[n.id].y; + n.vx = 0; + n.vy = 0; + n.fixed = true; + }); + redraw(); + fitToView(); + }); + + // ---- pan (drag on background) ---- + var panDrag = null; + viewport.addEventListener("mousedown", function (e) { + if (e.target !== viewport && e.target !== world && e.target !== svg) return; + panDrag = { startX: e.clientX, startY: e.clientY, camX: camera.x, camY: camera.y }; + viewport.style.cursor = "grabbing"; + e.preventDefault(); + }); + + // Records a manually moved/resized node's current x/y/width/height into + // the module-level savedNodeLayouts cache so the *next* render() call + // (the next debug-step refresh) picks it back up instead of resetting + // to the auto layout -- see savedNodeLayouts' own comment above. + function saveNodeLayout(node) { + savedNodeLayouts[node.id] = { + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }; + } + + // ---- node resize (via the edge/corner handles added above) ---- + var resizeDrag = null; + + // ---- node drag ---- + var nodeDrag = null; + data.nodes.forEach(function (n) { + var box = boxes[n.id]; + box.addEventListener("mousedown", function (e) { + e.stopPropagation(); + var node = byId[n.id]; + node.fixed = true; + node.userPinned = true; + nodeDrag = { node: node, startX: e.clientX, startY: e.clientY, nodeX: node.x, nodeY: node.y }; + box.style.cursor = "grabbing"; + e.preventDefault(); + }); + }); + + // ---- unmatched-root card drag (e.g. a plain scalar local like + // `count`) -- these aren't part of the physics simulation, they're + // auto-flowed into a shelf above the node cloud by redraw(); once + // dragged, `pinned` takes a card out of that auto-flow so it stays + // wherever the user put it instead of snapping back next frame. + var rootDrag = null; + rootBadges.forEach(function (rb) { + if (rb.matched) return; + rb.badge.addEventListener("mousedown", function (e) { + e.stopPropagation(); + rb.pinned = true; + rootDrag = { rb: rb, startX: e.clientX, startY: e.clientY, rbX: rb.x, rbY: rb.y }; + rb.badge.style.cursor = "grabbing"; + e.preventDefault(); + }); + }); + + window.addEventListener("mousemove", function (e) { + if (panDrag) { + camera.x = panDrag.camX + (e.clientX - panDrag.startX); + camera.y = panDrag.camY + (e.clientY - panDrag.startY); + applyCamera(); + } else if (resizeDrag) { + var dx = (e.clientX - resizeDrag.startX) / camera.zoom; + var dy = (e.clientY - resizeDrag.startY) / camera.zoom; + var dir = resizeDrag.dir; + var node = resizeDrag.node; + var newWidth = resizeDrag.startWidth; + var newHeight = resizeDrag.startHeight; + + // Each active axis recomputes both the new size *and* the + // node's center, so the edge/corner opposite the one being + // dragged stays anchored in place instead of the whole box + // sliding along with the cursor. + if (dir.indexOf("e") !== -1) { + newWidth = Math.max(MIN_NODE_WIDTH, resizeDrag.startWidth + dx); + node.x = resizeDrag.startNodeX + (newWidth - resizeDrag.startWidth) / 2; + } else if (dir.indexOf("w") !== -1) { + newWidth = Math.max(MIN_NODE_WIDTH, resizeDrag.startWidth - dx); + node.x = resizeDrag.startNodeX - (newWidth - resizeDrag.startWidth) / 2; + } + if (dir.indexOf("s") !== -1) { + newHeight = Math.max(MIN_NODE_HEIGHT, resizeDrag.startHeight + dy); + node.y = resizeDrag.startNodeY + (newHeight - resizeDrag.startHeight) / 2; + } else if (dir.indexOf("n") !== -1) { + newHeight = Math.max(MIN_NODE_HEIGHT, resizeDrag.startHeight - dy); + node.y = resizeDrag.startNodeY - (newHeight - resizeDrag.startHeight) / 2; + } + + node.width = newWidth; + node.height = newHeight; + saveNodeLayout(node); + redraw(); + } else if (nodeDrag) { + nodeDrag.node.x = nodeDrag.nodeX + (e.clientX - nodeDrag.startX) / camera.zoom; + nodeDrag.node.y = nodeDrag.nodeY + (e.clientY - nodeDrag.startY) / camera.zoom; + saveNodeLayout(nodeDrag.node); + redraw(); + } else if (rootDrag) { + rootDrag.rb.x = rootDrag.rbX + (e.clientX - rootDrag.startX) / camera.zoom; + rootDrag.rb.y = rootDrag.rbY + (e.clientY - rootDrag.startY) / camera.zoom; + redraw(); + } + }); + window.addEventListener("mouseup", function () { + if (panDrag) { + panDrag = null; + viewport.style.cursor = "grab"; + } + if (resizeDrag) { + // mouseleave already skipped clearing this handle's highlight + // while the drag was in progress (see addResizeHandles) -- the + // mouse is very likely no longer over it by the time you let go. + resizeDrag.handleEl.style.background = ""; + resizeDrag = null; + } + if (nodeDrag) { + var box = boxes[nodeDrag.node.id]; + if (box) box.style.cursor = "grab"; + nodeDrag = null; + } + if (rootDrag) { + rootDrag.rb.badge.style.cursor = "grab"; + rootDrag = null; + } + }); + + // ---- per-frame redraw ---- + function redraw() { + var visibleTopY = Infinity; + nodeList.forEach(function (n) { + var box = boxes[n.id]; + var isVis = !!visible[n.id]; + box.style.display = isVis ? "" : "none"; + if (isVis) { + box.style.width = n.width + "px"; + box.style.left = n.x - n.width / 2 + "px"; + box.style.top = n.y - n.height / 2 + "px"; + var body = bodies[n.id]; + // Body height = box height minus the (fixed-height) header + // (and, for a paged array table, its page-nav bar too); box + // itself is never given an explicit height, it just grows to + // fit header + body -- so this is the one place a resize + // actually changes what's rendered. + if (body) body.style.height = Math.max(ROW_HEIGHT, n.height - HEADER_HEIGHT - (n.headerExtra || 0)) + "px"; + visibleTopY = Math.min(visibleTopY, n.y - n.height / 2); + } + var toggle = toggles[n.id]; + if (toggle) { + if (collapsed[n.id]) { + toggle.textContent = "+" + subtreeSize(childrenOf[n.id] || [], childrenOf); + toggle.title = "Expand"; + } else { + toggle.textContent = "−"; + toggle.title = "Collapse"; + } + } + }); + + edgePaths.forEach(function (ep) { + var a = byId[ep.edge.from]; + var b = byId[ep.edge.to]; + var show = !!(a && b && visible[ep.edge.from] && visible[ep.edge.to]); + ep.line.style.display = show ? "" : "none"; + if (ep.text) ep.text.style.display = show ? "" : "none"; + if (!show) return; + var start = boxEdgePoint(a.x, a.y, a.width / 2, a.height / 2, b.x, b.y); + var end = boxEdgePoint(b.x, b.y, b.width / 2, b.height / 2, a.x, a.y); + var x1 = start.x, y1 = start.y, x2 = end.x, y2 = end.y; + var midX = (x1 + x2) / 2; + var midY = (y1 + y2) / 2; + ep.line.setAttribute( + "d", + "M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2 + ); + if (ep.text) { + ep.text.setAttribute("x", String(midX)); + ep.text.setAttribute("y", String(midY - 4)); + } + }); + + // Unmatched-root cards (plain scalar locals/globals/watches) that + // aren't pinned by a drag auto-flow into a compact roughly-square + // cluster -- sqrt(count) columns -- instead of one ever-widening + // row, so a handful of locals reads as a group near the top + // instead of a line stretching off to the side. The cluster's + // bottom row sits just above the node cloud (same non-overlap + // tracking as before); earlier rows stack upward from there. + var unmatchedFlow = rootBadges.filter(function (rb) { + return !rb.matched && !rb.pinned && rootKindEnabled[rb.kind]; + }); + var flowCols = Math.max(1, Math.ceil(Math.sqrt(unmatchedFlow.length))); + var flowRows = Math.ceil(unmatchedFlow.length / flowCols); + var flowRowHeight = 0; + unmatchedFlow.forEach(function (rb) { + flowRowHeight = Math.max(flowRowHeight, rb.height); + }); + var flowRowGap = 10; + var flowShelfBottom = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP; + var flowIndex = 0; + + badgesPerTarget = {}; + rootBadges.forEach(function (rb) { + if (!rootKindEnabled[rb.kind]) { + rb.badge.style.display = "none"; + if (rb.line) rb.line.style.display = "none"; + return; + } + if (rb.matched) { + var targetId = visible[rb.root.value] ? rb.root.value : nearestVisibleTarget(rb.root.value); + rb.badge.style.display = targetId ? "" : "none"; + if (rb.line) rb.line.style.display = targetId ? "" : "none"; + if (!targetId) return; + var redirected = targetId !== rb.root.value; + rb.badge.title = rb.root.name + (rb.root.type ? " : " + rb.root.type : "") + " = " + rb.root.value + + (redirected ? " (inside collapsed " + targetId + ")" : ""); + var target = byId[targetId]; + var stackIndex = badgesPerTarget[targetId] || 0; + badgesPerTarget[targetId] = stackIndex + 1; + rb.x = target.x + stackIndex * 90; + var by = target.y - target.height / 2 - ROOT_GAP; + rb.badge.style.left = rb.x - NODE_WIDTH / 4 + "px"; + rb.badge.style.top = by + "px"; + if (rb.line) { + rb.line.setAttribute( + "d", + "M" + rb.x + "," + (by + ROOT_BADGE_HEIGHT) + " L " + target.x + "," + (target.y - target.height / 2) + ); + } + } else { + rb.badge.style.display = ""; + if (!rb.pinned) { + var row = Math.floor(flowIndex / flowCols); // 0 = top row + var col = flowIndex % flowCols; + var rowFromBottom = flowRows - 1 - row; // 0 = row nearest the graph + var rowBottom = flowShelfBottom - rowFromBottom * (flowRowHeight + flowRowGap); + rb.x = col * (NODE_WIDTH + 20); + rb.y = rowBottom - rb.height; + flowIndex++; + } + // A pinned (user-dragged) card keeps whatever rb.x/rb.y + // the drag left it at, and drops out of the auto-flow + // cluster entirely -- the remaining unpinned cards + // re-flow to close the gap instead of leaving a hole. + rb.badge.style.left = rb.x + "px"; + rb.badge.style.top = rb.y + "px"; + } + }); + } + + // ---- animation loop ---- + var lastKinetic = Infinity; + + // Force-directed layouts need many iterations to go from the seed + // positions to something settled; fitting the camera to the seed + // shape (then letting physics keep running) means nodes drift well + // outside whatever we framed. So run the simulation synchronously + // up front -- like vis-network's stabilization pass -- and only + // fit/paint once it's essentially settled. + function stabilize(maxSteps) { + var k = Infinity; + for (var i = 0; i < maxSteps; i++) { + k = sim.step(); + if (k < 0.01) break; + } + lastKinetic = k; + running = k >= 0.01; + } + + function frame() { + if (!document.body.contains(target)) return; // this render() call was superseded + if (layoutMode === "auto" && running) { + var kinetic = sim.step(); + if (kinetic < 0.01 && lastKinetic < 0.01) { + running = false; + } + lastKinetic = kinetic; + } + redraw(); + requestAnimationFrame(frame); + } + + stabilize(500); + redraw(); + fitToView(); + requestAnimationFrame(frame); + + // The panel/webview may not have its final layout size yet at the + // instant render() runs (fresh panel, window just reloaded, tab just + // became visible) -- fitToView() above would then measure a stale or + // fallback 800x600 target size. Refit once more the first time the + // target's real size shows up differently, then stop watching so we + // don't fight the user's own manual pan/zoom on later resizes. + if (typeof ResizeObserver !== "undefined") { + var lastW = target.clientWidth; + var lastH = target.clientHeight; + var ro = new ResizeObserver(function () { + if (!document.body.contains(target)) { + ro.disconnect(); // this render() call was superseded + return; + } + if (target.clientWidth === lastW && target.clientHeight === lastH) return; + ro.disconnect(); + fitToView(); + }); + ro.observe(target); + } +} + +globalVisualizationFactory.addVisualizer( + createVisualizer({ + id: "node-table-graph", + name: "Node Table Graph", + serializer: sData, + getVisualization: function (data, self) { + return { + id: "node-table-graph-instance", + name: "Node Table Graph", + priority: 2000, + preload: function () { + return Promise.resolve(); + }, + render: function (target, options) { + render(data, target, options.theme.kind); + return { renderState: undefined, ready: Promise.resolve() }; + }, + }; + }, + }) +);