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), }) })