feat(debug): enhance debug graph functionality with auto-discovery of local and global variables

This commit is contained in:
mynameisdeleted
2026-07-09 13:12:57 -04:00
parent 7630ab9050
commit 43e1eaf749
3 changed files with 522 additions and 58 deletions

View File

@@ -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<T>`'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()

View File

@@ -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<int>`). 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__":

View File

@@ -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({