From 507a5ddc489e6a294465a5cf64157c6860673869 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 09:12:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(graph):=20=E6=A0=91=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E6=A0=87=E8=AE=B0=20has=5Fmore/more=5Fcount?= =?UTF-8?q?=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_tree_dfs 返回 (nodes, unexpanded) 闭环:预算耗尽、深度钳制、 循环 break 三种截断统一折算为未展开的有效邻居数,使假叶子与真叶子 在 JSON 中可区分(修复截断误导)。顶层 truncated/caller_count 语义 保持兼容;HTTP 与 MCP 的 trace JSON 同步输出新字段。 测试:新增 6 个截断行为测试(预算精确计数/兄弟截断/深度边界/ 真叶子不误报/祖先环防误报/builtin 过滤不计入)。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/graph/traverse.rs | 184 +++++++++++++++++++++++++++++++++++++---- src/mcp/tools.rs | 2 + src/server/handlers.rs | 2 + 3 files changed, 171 insertions(+), 17 deletions(-) diff --git a/src/graph/traverse.rs b/src/graph/traverse.rs index 828fd1c..5cfcf15 100644 --- a/src/graph/traverse.rs +++ b/src/graph/traverse.rs @@ -34,6 +34,14 @@ pub struct TreeNode { pub idx: NodeIndex, pub edge_label: Option, pub children: Vec, + /// True when direct children of this node could not all be added to the + /// tree (node budget / depth clamp hit). A node with `children: []` and + /// `has_more: false` is a genuine leaf; with `has_more: true` it is a + /// truncated "fake leaf". + pub has_more: bool, + /// Number of direct children that exist in the graph but are missing + /// from the tree (always 0 when `has_more` is false). + pub more_count: usize, } pub struct CallChain { @@ -110,9 +118,18 @@ fn build_tree_dfs( max_nodes: usize, visited: &mut usize, skip_builtins: bool, -) -> Vec { +) -> (Vec, usize) { + let neighbors: Vec = graph + .neighbors_directed(start, direction) + .filter(|n| !ancestors.contains(n)) + .filter(|n| { + !skip_builtins || !matches!(graph[*n], crate::graph::Node::BuiltinFunction { .. }) + }) + .collect(); + let effective = neighbors.len(); + if *visited >= max_nodes { - return Vec::new(); + return (Vec::new(), effective); } if depth > max_depth { if max_depth > 20 { @@ -123,18 +140,10 @@ fn build_tree_dfs( max_depth, key ); } - return Vec::new(); + return (Vec::new(), effective); } let mut roots = Vec::new(); - let neighbors: Vec = graph - .neighbors_directed(start, direction) - .filter(|n| !ancestors.contains(n)) - .filter(|n| { - !skip_builtins || !matches!(graph[*n], crate::graph::Node::BuiltinFunction { .. }) - }) - .collect(); - for neighbor in neighbors { if *visited >= max_nodes { break; @@ -146,7 +155,7 @@ fn build_tree_dfs( let edge_label = edge_label_for(graph, from, to); ancestors.insert(neighbor); *visited += 1; - let children = build_tree_dfs( + let (children, unexpanded) = build_tree_dfs( graph, neighbor, direction, @@ -162,9 +171,12 @@ fn build_tree_dfs( idx: neighbor, edge_label, children, + has_more: unexpanded > 0, + more_count: unexpanded, }); } - roots + let unexpanded_here = effective - roots.len(); + (roots, unexpanded_here) } pub fn trace_chain( @@ -181,7 +193,7 @@ pub fn trace_chain( } else { let mut caller_ancestors = HashSet::new(); caller_ancestors.insert(start); - build_tree_dfs( + let (nodes, _) = build_tree_dfs( graph, start, Direction::Incoming, @@ -191,7 +203,8 @@ pub fn trace_chain( max_nodes, &mut visited, skip_builtins, - ) + ); + nodes }; let callees = if max_depth == 0 { @@ -199,7 +212,7 @@ pub fn trace_chain( } else { let mut callee_ancestors = HashSet::new(); callee_ancestors.insert(start); - build_tree_dfs( + let (nodes, _) = build_tree_dfs( graph, start, Direction::Outgoing, @@ -209,7 +222,8 @@ pub fn trace_chain( max_nodes, &mut visited, skip_builtins, - ) + ); + nodes }; ( @@ -953,6 +967,142 @@ mod tests { ); } + // ── has_more / more_count truncation markers (Issue #152) ── + + /// Build a linear call chain n0 → n1 → … → n_{k-1} of procedure nodes. + fn make_chain(names: &[&str]) -> (crate::graph::CodeGraph, Vec) { + let mut graph = crate::graph::CodeGraph::new(); + let idxs: Vec<_> = names.iter().map(|n| add_proc_node(&mut graph, n)).collect(); + for w in idxs.windows(2) { + graph.add_edge( + w[0], + w[1], + crate::graph::Edge::DirectCall { + scope: crate::graph::CallScope::IntraPackage, + location: make_loc(), + }, + ); + } + (graph, idxs) + } + + fn add_call(graph: &mut crate::graph::CodeGraph, from: NodeIndex, to: NodeIndex) { + graph.add_edge( + from, + to, + crate::graph::Edge::DirectCall { + scope: crate::graph::CallScope::IntraPackage, + location: make_loc(), + }, + ); + } + + #[test] + fn truncated_by_budget_marks_has_more_with_exact_count() { + // Chain a→b→c→d with budget 2: b and c are visited; recursion into c + // hits the budget cap, so c is a "fake leaf" with one hidden child (d). + let (graph, idxs) = make_chain(&["a", "b", "c", "d"]); + let (chain, _) = trace_chain(&graph, idxs[0], 10, 2, false); + + let b = &chain.callees[0]; + assert!(!b.has_more, "b is fully expanded within budget"); + assert_eq!(b.more_count, 0); + + let c = &b.children[0]; + assert!(c.has_more, "budget-truncated node must report has_more"); + assert_eq!(c.more_count, 1, "d is the single unexpanded direct child"); + assert!( + c.children.is_empty(), + "truncated node must have no children" + ); + } + + #[test] + fn sibling_truncation_marks_parent_has_more() { + // a→b, b→{c,d} with budget 2: only one of c/d fits; the other is + // silently dropped by the loop break — b must report it. + let (mut graph, idxs) = make_chain(&["a", "b"]); + let c = add_proc_node(&mut graph, "c"); + let d = add_proc_node(&mut graph, "d"); + add_call(&mut graph, idxs[1], c); + add_call(&mut graph, idxs[1], d); + let (chain, _) = trace_chain(&graph, idxs[0], 10, 2, false); + + let b = &chain.callees[0]; + assert!( + b.has_more, + "loop-break truncation must mark parent has_more" + ); + assert_eq!(b.more_count, 1, "exactly one direct child was dropped"); + assert_eq!(b.children.len(), 1, "only one of c/d fits in budget"); + } + + #[test] + fn depth_boundary_marks_has_more() { + // a→b→c with max_depth=1: b is shown but its children are not explored. + let (graph, idxs) = make_chain(&["a", "b", "c"]); + let (chain, _) = trace_chain(&graph, idxs[0], 1, 100, false); + + let b = &chain.callees[0]; + assert!(b.has_more, "depth-clamped node must report has_more"); + assert_eq!(b.more_count, 1, "c is the single unexplored direct child"); + assert!(b.children.is_empty()); + } + + #[test] + fn true_leaf_has_no_more() { + let (graph, idxs) = make_chain(&["a", "b"]); + let (chain, _) = trace_chain(&graph, idxs[0], 10, 100, false); + + let b = &chain.callees[0]; + assert!(!b.has_more, "a real leaf must not be flagged as truncated"); + assert_eq!(b.more_count, 0); + } + + #[test] + fn cycle_neighbors_do_not_false_positive() { + // a↔b: b's only neighbor is its own ancestor and must be excluded, + // so b is a genuine leaf — has_more must stay false. + let (mut graph, idxs) = make_chain(&["a", "b"]); + add_call(&mut graph, idxs[1], idxs[0]); + let (chain, _) = trace_chain(&graph, idxs[0], 10, 100, false); + + let b = &chain.callees[0]; + assert!( + !b.has_more, + "ancestor-filtered neighbors must not count as more" + ); + assert_eq!(b.more_count, 0); + } + + #[test] + fn skip_builtins_respected_in_more_count() { + // b→{builtin, c} with skip_builtins=true: the builtin is filtered + // before counting, so b shows exactly one child and no truncation. + let (mut graph, idxs) = make_chain(&["a", "b"]); + let c = add_proc_node(&mut graph, "c"); + add_call(&mut graph, idxs[1], c); + let builtin = graph.add_node(crate::graph::Node::BuiltinFunction { + name: "count".into(), + category: "aggregate".into(), + domain: "sql".into(), + location: make_loc(), + }); + graph.add_edge( + idxs[1], + builtin, + crate::graph::Edge::UsesBuiltinFunction { + location: make_loc(), + }, + ); + let (chain, _) = trace_chain(&graph, idxs[0], 10, 100, true); + + let b = &chain.callees[0]; + assert!(!b.has_more, "filtered builtins must not inflate more_count"); + assert_eq!(b.more_count, 0); + assert_eq!(b.children.len(), 1, "only proc c remains after filtering"); + } + #[test] fn edge_label_none_for_unlabeled_edge_types() { let unlabeled_edges: Vec = vec![ diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 63d0bd6..cc04070 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -111,6 +111,8 @@ fn tree_nodes_to_json(nodes: &[traverse::TreeNode], graph: &CodeGraph) -> Vec Vec { "key": key.to_string(), "type": node_sub_type_tag(&graph[node.idx]), "edge_label": node.edge_label, + "has_more": node.has_more, + "more_count": node.more_count, "children": tree_nodes_to_json(&node.children, graph), }) }) From c3d7319e3755c4800b9f7225ef4ead065aacb5d1 Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 09:12:22 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(serve):=20=E8=B0=83=E7=94=A8=E9=93=BE?= =?UTF-8?q?=E6=A0=91=E6=8A=98=E5=8F=A0/=E5=B1=95=E5=BC=80=20+=20N-more=20?= =?UTF-8?q?=E5=BE=BD=E7=AB=A0=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 树节点 +/- 逐层折叠,默认展开 1 层(expand-depth 偏好持久化) - expand all / collapse all(清空显式状态 + 调整默认深度) - 折叠状态按目标节点作用域(currentTraceKey|pathKey),跨目标不泄漏 - has_more 节点渲染「▾ N more」注解徽章,点击复用 navigateTo 深入 - 折叠重渲染零 fetch(缓存 trace + 保留滚动位置),导航重置顶部 - localStorage JSON 读取加守卫,损坏数据不再阻断 UI 初始化 - trace 请求 depth 50→10,消除被服务端钳制的误导 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- assets/app.js | 99 +++++++++++++++++++++++++++++++++++++++++++----- assets/style.css | 9 +++++ 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/assets/app.js b/assets/app.js index 6e4e985..d1aa9c2 100644 --- a/assets/app.js +++ b/assets/app.js @@ -8,6 +8,21 @@ let currentOffset = 0; let isLoading = false; let navHistory = []; let isNavigatingBack = false; +let currentDetailData = null; +let treeExpandDepth = parseInt(localStorage.getItem('codeweb-tree-expand-depth') ?? '1', 10); +if (isNaN(treeExpandDepth)) treeExpandDepth = 1; +let treeCollapsed = {}; +try { treeCollapsed = JSON.parse(localStorage.getItem('codeweb-tree-collapsed') || '{}') || {}; } catch (_) {} +if (typeof treeCollapsed !== 'object') treeCollapsed = {}; + +function saveTreeState() { + localStorage.setItem('codeweb-tree-expand-depth', String(treeExpandDepth)); + localStorage.setItem('codeweb-tree-collapsed', JSON.stringify(treeCollapsed)); +} + +function treeStateKey(pathKey) { + return (currentTraceKey || '') + '|' + pathKey; +} const ITEM_HEIGHT = 36; const BUFFER = 5; @@ -67,6 +82,11 @@ async function init() { document.getElementById('detail-panel').addEventListener('click', function(e) { if (e.target.closest('.copy-btn')) return; + const toggle = e.target.closest('.tree-toggle'); + if (toggle && toggle.dataset.toggleKey) { + toggleTreeNode(toggle.dataset.toggleKey, parseInt(toggle.dataset.depth, 10)); + return; + } const treeNode = e.target.closest('.tree-node'); if (treeNode && treeNode.dataset.key) { navigateTo(treeNode.dataset.key); @@ -245,7 +265,7 @@ async function navigateTo(key) { currentTraceKey = key; const [trace, detail] = await Promise.all([ - api('/trace?from=' + encodeURIComponent(key) + '&depth=50&max_nodes=500'), + api('/trace?from=' + encodeURIComponent(key) + '&depth=10&max_nodes=500'), selectedNodeId !== null ? api('/nodes/' + selectedNodeId) : Promise.resolve(null), ]); if (currentTraceKey !== key) return; @@ -305,6 +325,21 @@ function renderPropertiesHtml(detail, showBodySql) { } function showDetail(trace, detail, mode) { + currentDetailData = { trace, detail, mode }; + renderDetail(false); + document.getElementById('detail-panel').classList.remove('hidden'); + updateBackButton(); +} + +function renderDetail(preserveScroll) { + const el = document.getElementById('detail-content'); + const scrollTop = preserveScroll ? el.scrollTop : 0; + el.innerHTML = buildDetailHtml(); + el.scrollTop = scrollTop; +} + +function buildDetailHtml() { + const { trace, detail, mode } = currentDetailData; const target = trace.target; const inDeg = trace.caller_count; const outDeg = trace.callee_count; @@ -313,30 +348,59 @@ function showDetail(trace, detail, mode) { showProperties = localStorage.getItem('codeweb-props-expanded') !== 'false'; document.getElementById('detail-title').textContent = target.type + ' ' + target.key; + const treeActions = ' ' + + 'expand all · ' + + 'collapse all'; + let h = '
Degree
'; h += '
in:' + inDeg + ' out:' + outDeg + ' total:' + (inDeg + outDeg) + '
'; h += renderPropertiesHtml(detail, showBodySql); - h += '
Callers (' + inDeg + ')
'; + h += '
Callers (' + inDeg + ')' + treeActions + '
'; h += '
'; - h += renderTreeHtml(trace.callers); + h += renderTreeHtml(trace.callers, 'callers', [], 0); h += '
'; - h += '
Callees (' + outDeg + ')
'; + h += '
Callees (' + outDeg + ')' + treeActions + '
'; h += '
'; - h += renderTreeHtml(trace.callees); + h += renderTreeHtml(trace.callees, 'callees', [], 0); h += '
'; if (trace.truncated) { h += '
Results truncated \u2014 too many nodes
'; } + return h; +} - document.getElementById('detail-content').innerHTML = h; - document.getElementById('detail-panel').classList.remove('hidden'); +function isTreeNodeCollapsed(pathKey, depth) { + const k = treeStateKey(pathKey); + if (treeCollapsed[k] !== undefined) return treeCollapsed[k]; + return depth >= treeExpandDepth; +} + +function toggleTreeNode(pathKey, depth) { + const k = treeStateKey(pathKey); + treeCollapsed[k] = !isTreeNodeCollapsed(pathKey, depth); + saveTreeState(); + renderDetail(true); +} + +function expandAllTree() { + treeCollapsed = {}; + treeExpandDepth = 99; + saveTreeState(); + renderDetail(true); } -function renderTreeHtml(nodes, prefixes) { +function collapseAllTree() { + treeCollapsed = {}; + treeExpandDepth = 0; + saveTreeState(); + renderDetail(true); +} + +function renderTreeHtml(nodes, section, pathIdx, depth, prefixes) { if (!nodes || nodes.length === 0) return '
(none)
'; prefixes = prefixes || []; let html = ''; @@ -346,18 +410,34 @@ function renderTreeHtml(nodes, prefixes) { const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; const prefix = prefixes.join(''); const label = n.edge_label ? ' ' + esc(n.edge_label) + '' : ''; + const pathKey = section + '.' + pathIdx.concat([i]).join('.'); + const expandable = (n.children && n.children.length > 0) || n.has_more; + const isCollapsed = expandable && isTreeNodeCollapsed(pathKey, depth); html += '
'; html += '' + esc(prefix + connector) + ''; + if (expandable) { + html += '' + (isCollapsed ? '[+]' : '[-]') + ' '; + } else { + html += '  '; + } html += '' + n.type + ''; html += '' + esc(n.key) + ''; html += '📋'; html += label; html += '
'; + if (isCollapsed) continue; if (n.children && n.children.length > 0) { const childPrefix = isLast ? ' ' : '\u2502 '; - html += renderTreeHtml(n.children, prefixes.concat([childPrefix])); + html += renderTreeHtml(n.children, section, pathIdx.concat([i]), depth + 1, prefixes.concat([childPrefix])); + } + if (n.has_more) { + const childPrefix = isLast ? ' ' : '\u2502 '; + html += '
' + + '' + esc(childPrefix + ' ') + '' + + '\u25be ' + n.more_count + ' more \u2014 click to open' + + '
'; } } return html; @@ -367,6 +447,7 @@ function hideDetail() { document.getElementById('detail-panel').classList.add('hidden'); selectedNodeId = null; currentTraceKey = null; + currentDetailData = null; navHistory = []; renderVirtualList(); } diff --git a/assets/style.css b/assets/style.css index 5795a5e..3989afa 100644 --- a/assets/style.css +++ b/assets/style.css @@ -45,6 +45,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou .tree-key{overflow:hidden;text-overflow:ellipsis} .edge-label{color:#666;font-size:13px} .tree-empty{padding:4px 0;color:#666;font-size:14px;font-style:italic} +.tree-toggle{font-family:'Cascadia Code',Consolas,monospace;color:#888;cursor:pointer;user-select:none;flex-shrink:0} +.tree-toggle:hover{color:#fff} +.tree-toggle-leaf{visibility:hidden} +.tree-actions{font-size:11px;font-weight:400;margin-left:8px;text-transform:none} +.tree-actions a{color:#6fb3e0;cursor:pointer;text-decoration:none} +.tree-actions a:hover{text-decoration:underline} +.tree-node.tree-more{cursor:pointer} +.tree-node.tree-more:hover{background:#2a2a4e} +.tree-more-badge{color:#e0a96f;font-size:12px;font-style:italic} .prop-list{padding:2px 0} .prop-entry{padding:2px 0;font-size:14px} .prop-label{color:#e94560;font-weight:700;margin-right:6px} From 92e40bdf335d13c3cdc4763df13c09a86e7fd23a Mon Sep 17 00:00:00 2001 From: Jianjun Chen Date: Mon, 7 Sep 2026 11:55:08 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(serve):=20expand/collapse-all=20?= =?UTF-8?q?=E6=8C=89=E7=9B=AE=E6=A0=87=E4=BD=9C=E7=94=A8=E5=9F=9F=20+=20?= =?UTF-8?q?=E6=A0=91=E5=88=97=E5=AF=B9=E9=BD=90=20+=20=E5=81=87=E5=8F=B6?= =?UTF-8?q?=E5=AD=90=E5=BE=BD=E7=AB=A0=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 修正(PR #155): - expand/collapse-all 不再修改全局默认深度(原实现写 localStorage 后跨目标泄漏、跨刷新持久,与按目标隔离目标矛盾),改为枚举当前 树的可展开节点写入作用域键(target|pathKey);默认深度回归常量 1 - .tree-toggle 固定 min-width:3ch + 居中,叶子占位与 [+]/[-] 列对齐 - 无 children 的截断假叶子折叠后仍渲染「N more」徽章,折叠态保留 深入查看入口;有 children 的节点折叠仍隐藏徽章(行为不变) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- assets/app.js | 33 ++++++++++++++++++++++----------- assets/style.css | 2 +- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/assets/app.js b/assets/app.js index d1aa9c2..00a0214 100644 --- a/assets/app.js +++ b/assets/app.js @@ -9,14 +9,12 @@ let isLoading = false; let navHistory = []; let isNavigatingBack = false; let currentDetailData = null; -let treeExpandDepth = parseInt(localStorage.getItem('codeweb-tree-expand-depth') ?? '1', 10); -if (isNaN(treeExpandDepth)) treeExpandDepth = 1; +const TREE_DEFAULT_DEPTH = 1; let treeCollapsed = {}; try { treeCollapsed = JSON.parse(localStorage.getItem('codeweb-tree-collapsed') || '{}') || {}; } catch (_) {} if (typeof treeCollapsed !== 'object') treeCollapsed = {}; function saveTreeState() { - localStorage.setItem('codeweb-tree-expand-depth', String(treeExpandDepth)); localStorage.setItem('codeweb-tree-collapsed', JSON.stringify(treeCollapsed)); } @@ -376,7 +374,7 @@ function buildDetailHtml() { function isTreeNodeCollapsed(pathKey, depth) { const k = treeStateKey(pathKey); if (treeCollapsed[k] !== undefined) return treeCollapsed[k]; - return depth >= treeExpandDepth; + return depth >= TREE_DEFAULT_DEPTH; } function toggleTreeNode(pathKey, depth) { @@ -386,16 +384,29 @@ function toggleTreeNode(pathKey, depth) { renderDetail(true); } +function collectExpandableKeys() { + const keys = []; + if (!currentDetailData) return keys; + const walk = (nodes, section, pathIdx) => { + (nodes || []).forEach((n, i) => { + const p = pathIdx.concat([i]); + if ((n.children && n.children.length > 0) || n.has_more) keys.push(section + '.' + p.join('.')); + walk(n.children, section, p); + }); + }; + walk(currentDetailData.trace.callers, 'callers', []); + walk(currentDetailData.trace.callees, 'callees', []); + return keys; +} + function expandAllTree() { - treeCollapsed = {}; - treeExpandDepth = 99; + collectExpandableKeys().forEach(k => { treeCollapsed[treeStateKey(k)] = false; }); saveTreeState(); renderDetail(true); } function collapseAllTree() { - treeCollapsed = {}; - treeExpandDepth = 0; + collectExpandableKeys().forEach(k => { treeCollapsed[treeStateKey(k)] = true; }); saveTreeState(); renderDetail(true); } @@ -427,12 +438,12 @@ function renderTreeHtml(nodes, section, pathIdx, depth, prefixes) { html += label; html += ''; - if (isCollapsed) continue; - if (n.children && n.children.length > 0) { + if (!isCollapsed && n.children && n.children.length > 0) { const childPrefix = isLast ? ' ' : '\u2502 '; html += renderTreeHtml(n.children, section, pathIdx.concat([i]), depth + 1, prefixes.concat([childPrefix])); } - if (n.has_more) { + const childlessFakeLeaf = n.has_more && (!n.children || n.children.length === 0); + if (n.has_more && (!isCollapsed || childlessFakeLeaf)) { const childPrefix = isLast ? ' ' : '\u2502 '; html += '
' + '' + esc(childPrefix + ' ') + '' + diff --git a/assets/style.css b/assets/style.css index 3989afa..b5022dd 100644 --- a/assets/style.css +++ b/assets/style.css @@ -45,7 +45,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou .tree-key{overflow:hidden;text-overflow:ellipsis} .edge-label{color:#666;font-size:13px} .tree-empty{padding:4px 0;color:#666;font-size:14px;font-style:italic} -.tree-toggle{font-family:'Cascadia Code',Consolas,monospace;color:#888;cursor:pointer;user-select:none;flex-shrink:0} +.tree-toggle{font-family:'Cascadia Code',Consolas,monospace;color:#888;cursor:pointer;user-select:none;flex-shrink:0;min-width:3ch;text-align:center} .tree-toggle:hover{color:#fff} .tree-toggle-leaf{visibility:hidden} .tree-actions{font-size:11px;font-weight:400;margin-left:8px;text-transform:none}