diff --git a/assets/app.js b/assets/app.js
index 6e4e985..00a0214 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -8,6 +8,19 @@ let currentOffset = 0;
let isLoading = false;
let navHistory = [];
let isNavigatingBack = false;
+let currentDetailData = null;
+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-collapsed', JSON.stringify(treeCollapsed));
+}
+
+function treeStateKey(pathKey) {
+ return (currentTraceKey || '') + '|' + pathKey;
+}
const ITEM_HEIGHT = 36;
const BUFFER = 5;
@@ -67,6 +80,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 +263,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 +323,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 +346,72 @@ 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 >= TREE_DEFAULT_DEPTH;
}
-function renderTreeHtml(nodes, prefixes) {
+function toggleTreeNode(pathKey, depth) {
+ const k = treeStateKey(pathKey);
+ treeCollapsed[k] = !isTreeNodeCollapsed(pathKey, depth);
+ saveTreeState();
+ 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() {
+ collectExpandableKeys().forEach(k => { treeCollapsed[treeStateKey(k)] = false; });
+ saveTreeState();
+ renderDetail(true);
+}
+
+function collapseAllTree() {
+ collectExpandableKeys().forEach(k => { treeCollapsed[treeStateKey(k)] = true; });
+ saveTreeState();
+ renderDetail(true);
+}
+
+function renderTreeHtml(nodes, section, pathIdx, depth, prefixes) {
if (!nodes || nodes.length === 0) return '(none)
';
prefixes = prefixes || [];
let html = '';
@@ -346,18 +421,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 (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]));
+ }
+ const childlessFakeLeaf = n.has_more && (!n.children || n.children.length === 0);
+ if (n.has_more && (!isCollapsed || childlessFakeLeaf)) {
const childPrefix = isLast ? ' ' : '\u2502 ';
- html += renderTreeHtml(n.children, prefixes.concat([childPrefix]));
+ html += '' +
+ '' + esc(childPrefix + ' ') + '' +
+ '\u25be ' + n.more_count + ' more \u2014 click to open' +
+ '
';
}
}
return html;
@@ -367,6 +458,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..b5022dd 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;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}
+.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}
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),
})
})