diff --git a/gdb/debug_graph.py b/gdb/debug_graph.py index 03c36a6..18e6401 100644 --- a/gdb/debug_graph.py +++ b/gdb/debug_graph.py @@ -17,6 +17,21 @@ box: $debug_graph(list) $debug_graph(list, "top", top) # extra name/pointer marker pairs + +Besides EXPR and any explicit name/pointer pairs, every call also walks and +labels, as additional roots: + - every other local variable (and argument) visible at the current PC, + from the innermost lexical block out to function scope -- kind "local" + - every file-scope global/static visible from the current source file -- + kind "global" +the explicit name/pointer pairs are labeled kind "watched". This is what +lets e.g. $debug_graph(top) -- which VS Code's expressionTemplate produces +when you just type "top" in the Watch box -- still show the list: `top` is +a bare `int*` with no struct to recurse into on its own, but `list` (a +sibling local at that point) gets auto-walked too, so `top`'s address lands +on a node already present in the combined graph. Each root's "kind" rides +along in the output JSON so the visualizer can offer show/hide toggles per +category (see vis-plugins/node-table-visualizer.js). """ import gdb @@ -135,41 +150,178 @@ def _describe_struct(val, node_id, nodes, edges, visited): def _visit_pointer(ptr_val, ptr_int, nodes, edges, visited): + """Cycle-guards only on the struct/union branch (via `visited`), and + only fills in a scalar leaf if nothing has described this address yet + (via `nodes`). With auto-discovered roots (see build_graph_json), the + same address can be reached both as a struct-typed pointer (e.g. a + linked-list node) and, separately, as some other variable's plain + scalar pointer into one of that struct's fields (e.g. a `T*` pointing + at a `Node`'s first member, which shares its address). Whichever + order those walks happen in, the richer struct description must win -- + a scalar walk must never downgrade/overwrite an already-described + struct node, and a struct walk must always be able to upgrade a + previously-recorded scalar leaf at the same address.""" node_id = _addr_str(ptr_int) - if node_id in visited: - return - visited.add(node_id) - pointee_type = ptr_val.type.strip_typedefs().target().strip_typedefs() - pointee = ptr_val.dereference() if pointee_type.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION): + if node_id in visited: + return + visited.add(node_id) + pointee = ptr_val.dereference() _describe_struct(pointee, node_id, nodes, edges, visited) else: - # Pointer to a scalar (e.g. int*): still worth a leaf node. + if node_id in nodes: + return # already described (richly or otherwise) via another path + pointee = ptr_val.dereference() nodes[node_id] = [ {"name": "value", "value": _format_scalar(pointee), "isPointer": False} ] -def build_graph_json(root_val, roots): - nodes = {} - edges = [] - visited = set() - +def _walk_primary_root(root_val, nodes, edges, visited): + """Walks EXPR itself (the first argument to $debug_graph). Returns the + address string of EXPR's own node, if any, so callers can skip re-adding + it as a redundant self-pointing root when auto-discovery finds the same + variable again as a local.""" t = root_val.type.strip_typedefs() if t.code == gdb.TYPE_CODE_PTR: ptr_int = int(root_val) if ptr_int: _visit_pointer(root_val, ptr_int, nodes, edges, visited) + return _addr_str(ptr_int) + return None elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION): node_id = _addr_str(int(root_val.address)) visited.add(node_id) _describe_struct(root_val, node_id, nodes, edges, visited) + return node_id else: nodes["root"] = [ {"name": "value", "value": _format_scalar(root_val), "isPointer": False} ] + return None + + +def _add_named_root(name, val, kind, nodes, edges, visited, roots_out, skip_addrs): + """Walks `val` (a local/global/watched variable) same as a primary root, + then records it as a (name, value, kind) root entry -- unless its + address is in skip_addrs (already covered by the primary root).""" + t = val.type.strip_typedefs() + if t.code == gdb.TYPE_CODE_PTR: + try: + ptr_int = int(val) + except gdb.error: + return + if not ptr_int: + return + addr = _addr_str(ptr_int) + if addr in skip_addrs: + return + _visit_pointer(val, ptr_int, nodes, edges, visited) + roots_out.append((name, addr, kind)) + elif t.code in (gdb.TYPE_CODE_STRUCT, gdb.TYPE_CODE_UNION): + try: + addr = _addr_str(int(val.address)) + except gdb.error: + return + if addr in skip_addrs: + return + if addr not in visited: + visited.add(addr) + _describe_struct(val, addr, nodes, edges, visited) + roots_out.append((name, addr, kind)) + else: + roots_out.append((name, _format_scalar(val), kind)) + + +def _frame_local_symbols(frame): + """Yields every local variable/argument gdb.Symbol visible at the + current PC, walking from the innermost lexical block out to (but not + including) the enclosing file/global scope.""" + seen = set() + block = frame.block() + while block is not None and not block.is_global and not block.is_static: + for sym in block: + if not (sym.is_variable or sym.is_argument): + continue + if sym.name is None or sym.name in seen: + continue + seen.add(sym.name) + yield sym + block = block.superblock + + +def _file_global_symbols(frame): + """Yields every file-scope global/static gdb.Symbol visible from the + current frame's source file.""" + seen = set() + try: + symtab = frame.find_sal().symtab + except gdb.error: + return + if symtab is None: + return + for block in (symtab.global_block(), symtab.static_block()): + if block is None: + continue + for sym in block: + if not sym.is_variable: + continue + if sym.name is None or sym.name in seen: + continue + seen.add(sym.name) + yield sym + + +def build_graph_json(root_val, watched_roots): + nodes = {} + edges = [] + visited = set() + roots = [] + skip_addrs = set() + used_names = set() + + primary_addr = _walk_primary_root(root_val, nodes, edges, visited) + if primary_addr is not None: + skip_addrs.add(primary_addr) + + for name, ptr_val in watched_roots: + used_names.add(name) + try: + ptr_int = int(ptr_val) + except gdb.error: + ptr_int = 0 + addr = _addr_str(ptr_int) + roots.append((name, addr, "watched")) + if ptr_int and addr not in skip_addrs: + _visit_pointer(ptr_val, ptr_int, nodes, edges, visited) + + try: + frame = gdb.selected_frame() + except gdb.error: + frame = None + + if frame is not None: + for sym in _frame_local_symbols(frame): + if sym.name in used_names: + continue + used_names.add(sym.name) + try: + val = sym.value(frame) + except gdb.error: + continue + _add_named_root(sym.name, val, "local", nodes, edges, visited, roots, skip_addrs) + + for sym in _file_global_symbols(frame): + if sym.name in used_names: + continue + used_names.add(sym.name) + try: + val = sym.value(frame) + except gdb.error: + continue + _add_named_root(sym.name, val, "global", nodes, edges, visited, roots, skip_addrs) nodes_json = [{"id": nid, "fields": fields} for nid, fields in nodes.items()] edges_json = [ @@ -177,7 +329,7 @@ def build_graph_json(root_val, roots): for (src, dst, label) in edges if dst in nodes ] - roots_json = [{"name": name, "value": value} for (name, value) in roots] + roots_json = [{"name": name, "value": value, "kind": kind} for (name, value, kind) in roots] return json.dumps( { @@ -260,7 +412,10 @@ gdb.pretty_printers.append(_pretty_print_lookup) class DebugGraphFunction(gdb.Function): """$debug_graph(EXPR[, name, ptr]...) -- render EXPR's pointer-linked structure as Debug Visualizer JSON. Extra name/pointer argument pairs - are drawn as external marker pointers into the graph.""" + are drawn as external "watched" marker pointers into the graph; every + other local (and file-scope global) visible at the current PC is also + auto-walked and added as a "local"/"global" root -- see module + docstring for why.""" def __init__(self): super(DebugGraphFunction, self).__init__("debug_graph") @@ -271,7 +426,7 @@ class DebugGraphFunction(gdb.Function): root = args[0] extra = args[1:] - roots = [] + watched_roots = [] i = 0 while i + 1 < len(extra): name_val, ptr_val = extra[i], extra[i + 1] @@ -279,14 +434,10 @@ class DebugGraphFunction(gdb.Function): name = name_val.string() except (gdb.error, TypeError): name = str(name_val) - try: - ptr_int = int(ptr_val) - except gdb.error: - ptr_int = 0 - roots.append((name, _addr_str(ptr_int))) + watched_roots.append((name, ptr_val)) i += 2 - return _as_debugger_string(build_graph_json(root, roots)) + return _as_debugger_string(build_graph_json(root, watched_roots)) DebugGraphFunction() diff --git a/scripts/dump_debug_graph.py b/scripts/dump_debug_graph.py index 505e71f..9c05309 100755 --- a/scripts/dump_debug_graph.py +++ b/scripts/dump_debug_graph.py @@ -17,6 +17,20 @@ Writes debug_cpp.json / debug_rust.json into the output directory (default ~/lg, matching the style of the extension's own captured dumps): either the resolved data object on success, or a {"PARSE_ERROR": ..., "raw": ...} diagnostic on failure. + +For the C++ build specifically, the breakpoint sits inside +`if (const int* top = list.peek_front())`, so `top` is a live local pointing +at the head node's `data` field (the node's first member, so its address +equals the node's own address, but its *static type* is just `const int*` -- +gdb has no way to know it's secretly interior to a `Node`). VS Code's +"debugVisualizer.debugAdapterConfigurations" expressionTemplate wraps +whatever you type in the Watch box as $debug_graph(${expr}), so typing +plain `top` evaluates $debug_graph(top) -- not $debug_graph(list, ...): we +replay that exact call and verify it still delves into the full list. This +relies on gdb/debug_graph.py's auto-discovery of sibling locals (here, +`list`, a struct type it *can* walk) merging into the same node graph as +`top`'s address, rather than requiring `top` to be passed as an explicit +watched root. """ import json import re @@ -27,14 +41,14 @@ from pathlib import Path REPO = Path(__file__).resolve().parent.parent -def run_gdb(gdb_bin, binary, break_file, break_line): +def run_gdb(gdb_bin, binary, break_file, break_line, expr="$debug_graph(list)"): commands = [ "-gdb-set print elements 0", "-gdb-set print characters 0", "source %s" % (REPO / "gdb" / "debug_graph.py"), "-break-insert %s:%d" % (break_file, break_line), "-exec-run", - '-data-evaluate-expression "$debug_graph(list)"', + '-data-evaluate-expression "%s"' % expr.replace('"', '\\"'), ] proc = subprocess.run( [gdb_bin, "--interpreter=mi", binary], @@ -80,15 +94,55 @@ def parse_like_extension(mi_escaped_value): return {"ok": False, "error": str(e), "raw": result_text} -def dump(label, gdb_bin, binary, break_file, break_line, out_dir): +def verify_top_delves_into_list(data): + """Checks that evaluating bare `top` -- i.e. $debug_graph(top), exactly + what VS Code's expressionTemplate produces for a Watch box containing + just "top" -- still shows the whole 30/20/10 list rather than stopping + at a lone scalar leaf. Returns True/False and prints why.""" + nodes = data.get("nodes", []) + edges = data.get("edges", []) + roots_by_name = {r.get("name"): r for r in data.get("roots", [])} + + bare_leaves = [ + n for n in nodes + if [f.get("name") for f in n.get("fields", [])] == ["value"] + ] + if bare_leaves: + print(" VERIFY FAIL: $debug_graph(top) produced bare scalar leaf node(s) %s -- " + "top's address was never upgraded to the full Node it coincides with" + % [n.get("id") for n in bare_leaves]) + return False + + next_edges = [e for e in edges if e.get("label") == "next"] + if len(next_edges) < 2: + print(" VERIFY FAIL: expected 2 'next' edges chaining the 30/20/10 nodes, found %d " + "(edges=%r)" % (len(next_edges), edges)) + return False + + if "list" not in roots_by_name: + print(" VERIFY FAIL: 'list' was not auto-discovered as a sibling local alongside " + "top (roots=%r)" % data.get("roots")) + return False + + print(" VERIFY OK: $debug_graph(top) delves into the full list -- %d nodes, %d 'next' " + "edges, 'list' auto-discovered as a %r root" + % (len(nodes), len(next_edges), roots_by_name["list"].get("kind"))) + return True + + +def dump(label, gdb_bin, binary, break_file, break_line, out_dir, expr="$debug_graph(list)", verify=None): + ok = True try: - raw_value = run_gdb(gdb_bin, binary, break_file, break_line) + raw_value = run_gdb(gdb_bin, binary, break_file, break_line, expr) except Exception as e: result = {"PARSE_ERROR": "gdb invocation failed", "detail": str(e)} + ok = False else: parsed = parse_like_extension(raw_value) if parsed["ok"]: result = parsed["data"] + if verify is not None: + ok = verify(result) else: result = { "PARSE_ERROR": parsed["error"], @@ -96,18 +150,28 @@ def dump(label, gdb_bin, binary, break_file, break_line, out_dir): "note": "This is what the Debug Visualizer extension would fail to parse, " "triggering its generic byte-walk fallback (constructGraphFromVariablesReference).", } + ok = False out_path = out_dir / ("debug_%s.json" % label) out_path.write_text(json.dumps(result, indent=4)) print("Wrote %s (%s)" % (out_path, "OK" if "PARSE_ERROR" not in result else "PARSE_ERROR")) + return ok def main(): out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / "lg" out_dir.mkdir(parents=True, exist_ok=True) - dump("cpp", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir) - dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 51, out_dir) + ok_cpp = dump("cpp", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir) + ok_cpp_top = dump( + "cpp_top", "gdb", str(REPO / "cpp" / "list_example_cpp"), "cpp/main.cpp", 66, out_dir, + expr="$debug_graph(top)", + verify=verify_top_delves_into_list, + ) + ok_rust = dump("rust", "rust-gdb", str(REPO / "target" / "debug" / "list_example"), "src/main.rs", 51, out_dir) + + if not (ok_cpp and ok_cpp_top and ok_rust): + sys.exit(1) if __name__ == "__main__": diff --git a/vis-plugins/node-table-visualizer.js b/vis-plugins/node-table-visualizer.js index b4afc7f..3b058d8 100644 --- a/vis-plugins/node-table-visualizer.js +++ b/vis-plugins/node-table-visualizer.js @@ -14,9 +14,12 @@ // Data shape produced by gdb/debug_graph.py's $debug_graph(...): // nodes: [{ id: "0x...", fields: [{ name, value, isPointer? }, ...] }] // edges: [{ from: "0x...", to: "0x...", label? }] -// roots: [{ name: "top", value: "0x..." }] -- 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. +// 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. module.exports = function (register, lib) { var sj = lib.semanticJson; @@ -41,6 +44,7 @@ module.exports = function (register, lib) { var sRoot = sj.sOpenObject({ name: sj.sString(), value: sj.sString(), + kind: sj.sOptionalProp(sj.sString(), {}), }); var sData = sj.sOpenObject({ @@ -119,7 +123,10 @@ module.exports = function (register, lib) { } // ---- simple O(n^2) force-directed layout, vis-network-style ---- - function createSimulation(nodeList, edges) { + // 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; @@ -143,6 +150,7 @@ module.exports = function (register, lib) { 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; @@ -158,6 +166,7 @@ module.exports = function (register, lib) { } 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; @@ -176,7 +185,7 @@ module.exports = function (register, lib) { var kinetic = 0; nodeList.forEach(function (n) { - if (n.fixed) { + if (n.fixed || !isVisible(n.id)) { n.vx = 0; n.vy = 0; return; @@ -195,6 +204,21 @@ module.exports = function (register, lib) { return { nodes: nodeList, byId: byId, step: step }; } + // ---- 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); @@ -222,6 +246,34 @@ module.exports = function (register, lib) { 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 }; + } + function render(data, target, theme) { target.innerHTML = ""; Object.assign(target.style, { @@ -278,7 +330,69 @@ module.exports = function (register, lib) { byId[n.id] = n; }); - var sim = createSimulation(nodeList, data.edges); + 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; @@ -305,18 +419,28 @@ module.exports = function (register, lib) { 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"); - var marker = document.createElementNS(svgNs, "marker"); - marker.setAttribute("id", "dv-arrow"); - 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", colors.edge); - marker.appendChild(arrowPath); - defs.appendChild(marker); + + 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); @@ -341,6 +465,7 @@ module.exports = function (register, lib) { }); var boxes = {}; + var toggles = {}; data.nodes.forEach(function (n) { var box = el("div", { position: "absolute", @@ -361,11 +486,45 @@ module.exports = function (register, lib) { 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", }); - header.textContent = n.id; + headerLabel.textContent = n.id; + 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", { maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px", overflowY: "auto" }); @@ -427,13 +586,12 @@ module.exports = function (register, lib) { if (matched) { line = document.createElementNS(svgNs, "path"); line.setAttribute("fill", "none"); - line.setAttribute("stroke", colors.edge); + line.setAttribute("stroke", colors.pointerValue); line.setAttribute("stroke-width", "1.5"); - line.setAttribute("stroke-dasharray", "3,3"); - line.setAttribute("marker-end", "url(#dv-arrow)"); + line.setAttribute("marker-end", "url(#dv-arrow-root)"); svg.appendChild(line); } - return { root: r, badge: badge, line: line, matched: matched, x: 0 }; + return { root: r, kind: rootKind(r), badge: badge, line: line, matched: matched, x: 0 }; }); var nextUnmatchedX = 0; var badgesPerTarget = {}; @@ -452,14 +610,51 @@ module.exports = function (register, lib) { }); 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, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) { + [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; @@ -482,9 +677,12 @@ module.exports = function (register, lib) { } function fitToView() { - if (nodeList.length === 0) return; + var visibleNodes = nodeList.filter(function (n) { + return visible[n.id]; + }); + if (visibleNodes.length === 0) return; var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - nodeList.forEach(function (n) { + visibleNodes.forEach(function (n) { minX = Math.min(minX, n.x - NODE_WIDTH / 2); maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); minY = Math.min(minY, n.y - n.height / 2 - (roots.length ? ROOT_BADGE_HEIGHT + ROOT_GAP : 0)); @@ -600,14 +798,31 @@ module.exports = function (register, lib) { function redraw() { nodeList.forEach(function (n) { var box = boxes[n.id]; - box.style.left = n.x - NODE_WIDTH / 2 + "px"; - box.style.top = n.y - n.height / 2 + "px"; + var isVis = !!visible[n.id]; + box.style.display = isVis ? "" : "none"; + if (isVis) { + box.style.left = n.x - NODE_WIDTH / 2 + "px"; + box.style.top = n.y - n.height / 2 + "px"; + } + 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]; - if (!a || !b) return; + 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 x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y; var midX = (x1 + x2) / 2; var midY = (y1 + y2) / 2; @@ -624,10 +839,22 @@ module.exports = function (register, lib) { nextUnmatchedX = 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 target = byId[rb.root.value]; - var stackIndex = badgesPerTarget[rb.root.value] || 0; - badgesPerTarget[rb.root.value] = stackIndex + 1; + 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.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"; @@ -639,6 +866,7 @@ module.exports = function (register, lib) { ); } } else { + rb.badge.style.display = ""; rb.x = nextUnmatchedX; nextUnmatchedX += NODE_WIDTH / 2 + 20; rb.badge.style.left = rb.x + "px"; @@ -683,6 +911,27 @@ module.exports = function (register, lib) { 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); + } } register({