@mishasinitcyn/betterrank 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.html CHANGED
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>betterrank</title>
7
+ <script src="https://d3js.org/d3.v7.min.js"></script>
7
8
  <style>
8
9
  :root {
9
10
  --bg: #0c0c0c;
@@ -569,6 +570,86 @@
569
570
  text-align: right;
570
571
  flex-shrink: 0;
571
572
  }
573
+
574
+ /* Graph view — inline within results area */
575
+ .graph-wrap {
576
+ position: relative;
577
+ height: calc(100vh - 280px);
578
+ min-height: 400px;
579
+ background: var(--bg);
580
+ border: 1px solid var(--border);
581
+ border-radius: var(--radius);
582
+ overflow: hidden;
583
+ }
584
+ .graph-wrap svg { width: 100%; height: 100%; display: block; }
585
+ .graph-filters {
586
+ position: absolute; top: 12px; right: 12px; z-index: 10;
587
+ background: rgba(22,22,22,0.9); backdrop-filter: blur(8px);
588
+ border: 1px solid var(--border); border-radius: 10px;
589
+ padding: 10px 12px; max-width: 240px;
590
+ }
591
+ .graph-filters-title { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
592
+ .graph-filter-badges { display: flex; flex-wrap: wrap; gap: 5px; }
593
+ .graph-badge {
594
+ display: inline-flex; align-items: center; gap: 5px;
595
+ padding: 3px 10px; border-radius: 999px;
596
+ font-size: 11px; font-weight: 500; cursor: pointer;
597
+ border: 1px solid var(--border); background: transparent;
598
+ color: var(--text-dim); transition: all 0.15s; user-select: none;
599
+ }
600
+ .graph-badge.active { background: var(--bg-raised); color: var(--text); }
601
+ .graph-badge .dot { width: 7px; height: 7px; border-radius: 50%; }
602
+ .graph-badge:hover { border-color: #3f3f46; }
603
+ .graph-detail {
604
+ position: absolute; bottom: 12px; left: 12px; z-index: 10;
605
+ background: rgba(22,22,22,0.95); backdrop-filter: blur(8px);
606
+ border: 1px solid var(--border); border-radius: 10px;
607
+ padding: 14px 18px; min-width: 260px; max-width: 340px;
608
+ display: none;
609
+ }
610
+ .graph-detail.visible { display: block; }
611
+ .graph-detail-cat { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
612
+ .graph-detail-name { font-size: 15px; font-weight: 600; margin-bottom: 2px; color: var(--text); }
613
+ .graph-detail-path { font-size: 11px; color: var(--text-faint); font-family: var(--mono); margin-bottom: 10px; cursor: pointer; }
614
+ .graph-detail-path:hover { color: var(--accent); text-decoration: underline; }
615
+ .graph-detail-section { margin-top: 8px; }
616
+ .graph-detail-section-title { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
617
+ .graph-detail-item { font-size: 11px; color: var(--text-dim); padding: 2px 0; display: flex; align-items: center; gap: 6px; }
618
+ .graph-detail-item .dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
619
+ .graph-tooltip {
620
+ position: fixed; z-index: 70; pointer-events: none;
621
+ background: var(--bg-raised); border: 1px solid var(--border); border-radius: 6px;
622
+ padding: 6px 10px; font-size: 11px; font-family: var(--mono); display: none;
623
+ color: var(--text); box-shadow: 0 4px 12px rgba(0,0,0,0.4);
624
+ }
625
+ .graph-search {
626
+ position: absolute; bottom: 12px; right: 12px; z-index: 10;
627
+ background: rgba(22,22,22,0.95); backdrop-filter: blur(8px);
628
+ border: 1px solid var(--border); border-radius: 10px;
629
+ padding: 10px 12px; width: 280px;
630
+ }
631
+ .graph-search input {
632
+ width: 100%; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 6px;
633
+ padding: 7px 10px; font-size: 13px; color: var(--text); outline: none;
634
+ font-family: var(--mono);
635
+ }
636
+ .graph-search input::placeholder { color: var(--text-faint); }
637
+ .graph-search input:focus { border-color: var(--accent-dim); }
638
+ .graph-search-results { max-height: 200px; overflow-y: auto; margin-top: 8px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
639
+ .graph-search-results:empty { display: none; }
640
+ .graph-search-result {
641
+ padding: 6px 8px; border-radius: 6px; cursor: pointer;
642
+ transition: background 0.1s; font-size: 12px; font-family: var(--mono);
643
+ color: var(--text-dim);
644
+ }
645
+ .graph-search-result:hover { background: var(--bg-hover); color: var(--text); }
646
+ .graph-link { stroke-opacity: 0.15; }
647
+ .graph-link.highlighted { stroke-opacity: 0.7; stroke-width: 2px !important; }
648
+ .graph-node circle { stroke-width: 1.5; cursor: pointer; transition: r 0.15s; }
649
+ .graph-node text { fill: var(--text-dim); font-size: 9px; pointer-events: none; font-family: var(--mono); }
650
+ .graph-node.dimmed circle { opacity: 0.15; }
651
+ .graph-node.dimmed text { opacity: 0.1; }
652
+ .graph-node.highlighted circle { stroke-width: 3; }
572
653
  </style>
573
654
  </head>
574
655
  <body>
@@ -597,9 +678,9 @@
597
678
  </div>
598
679
  </div>
599
680
 
600
- <!-- Recent repos -->
681
+ <!-- Indexed repos -->
601
682
  <div class="recent-repos" id="recentRepos" style="display:none">
602
- <div class="recent-label">Recent</div>
683
+ <div class="recent-label">Indexed</div>
603
684
  <div class="recent-list" id="recentList"></div>
604
685
  </div>
605
686
 
@@ -625,6 +706,7 @@
625
706
  <button class="tab" data-tool="dependents">Dependents</button>
626
707
  <button class="tab" data-tool="neighborhood">Neighborhood</button>
627
708
  <button class="tab" data-tool="structure">Structure</button>
709
+ <button class="tab" data-tool="graph">Graph</button>
628
710
  </div>
629
711
 
630
712
  <!-- Search area -->
@@ -653,9 +735,9 @@
653
735
 
654
736
  <!-- Results -->
655
737
  <div class="results" id="results">
656
- <div class="empty-state">
738
+ <div class="empty-state" id="emptyState">
657
739
  <div class="icon">&#x1F4C2;</div>
658
- <p>Drop a folder here, click <strong>Browse</strong>, or type a path above.<br>
740
+ <p id="emptyMsg">Drop a folder here, click <strong>Browse</strong>, or type a path above.<br>
659
741
  Then search symbols, explore the map, or trace callers.<br><br>
660
742
  <kbd>&#8984;O</kbd> browse &nbsp; <kbd>&#8984;K</kbd> focus search</p>
661
743
  </div>
@@ -670,6 +752,9 @@
670
752
 
671
753
  </div>
672
754
 
755
+ <!-- Graph tooltip (needs to be outside .app for fixed positioning) -->
756
+ <div class="graph-tooltip" id="graphTooltip"></div>
757
+
673
758
  <script>
674
759
  const $ = s => document.querySelector(s);
675
760
  const $$ = s => document.querySelectorAll(s);
@@ -722,11 +807,13 @@ function removeRecentRepo(path) {
722
807
  const recent = getRecentRepos().filter(r => r !== path);
723
808
  localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
724
809
  renderRecentRepos();
810
+ updateEmptyState();
725
811
  }
726
812
 
727
813
  function clearRecentRepos() {
728
814
  localStorage.removeItem(RECENT_KEY);
729
815
  renderRecentRepos();
816
+ updateEmptyState();
730
817
  }
731
818
 
732
819
  function renderRecentRepos() {
@@ -1013,9 +1100,15 @@ $$('.tab').forEach(tab => {
1013
1100
  tab.classList.add('active');
1014
1101
  currentTool = tab.dataset.tool;
1015
1102
  currentPage = 0;
1103
+
1104
+ // Stop any running graph simulation when leaving graph tab
1105
+ if (currentTool !== 'graph' && graphSimulation) {
1106
+ graphSimulation.stop();
1107
+ }
1108
+
1016
1109
  updateSearchUI();
1017
- // Auto-run: map/structure run server query, others show pickers or run
1018
- if (['map', 'structure', 'symbols'].includes(currentTool)) {
1110
+ // Auto-run: map/structure/graph run server query, others show pickers or run
1111
+ if (['map', 'structure', 'symbols', 'graph'].includes(currentTool)) {
1019
1112
  runQuery();
1020
1113
  } else {
1021
1114
  queryInput.value = '';
@@ -1030,6 +1123,7 @@ function updateSearchUI() {
1030
1123
  const needsKind = ['search', 'symbols'].includes(currentTool);
1031
1124
 
1032
1125
  $('#searchArea').style.display = needsQuery ? 'block' : 'none';
1126
+ pagination.style.display = currentTool === 'graph' ? 'none' : pagination.style.display;
1033
1127
  kindFilter.style.display = needsKind ? 'inline-block' : 'none';
1034
1128
 
1035
1129
  const placeholders = {
@@ -1118,6 +1212,9 @@ async function runQuery() {
1118
1212
  case 'structure':
1119
1213
  url = `${API}/api/structure?depth=4`;
1120
1214
  break;
1215
+ case 'graph':
1216
+ url = `${API}/api/graph?limit=500`;
1217
+ break;
1121
1218
  }
1122
1219
 
1123
1220
  const res = await fetch(url);
@@ -1144,6 +1241,7 @@ function render(data) {
1144
1241
  case 'dependents': return renderFileList(data.results, data.total);
1145
1242
  case 'neighborhood': return renderNeighborhood(data);
1146
1243
  case 'structure': return renderPre(data.content);
1244
+ case 'graph': return renderGraph(data);
1147
1245
  }
1148
1246
  }
1149
1247
 
@@ -1444,6 +1542,332 @@ document.addEventListener('keydown', (e) => {
1444
1542
  repoInput.onkeydown = (e) => {
1445
1543
  if (e.key === 'Enter') btnIndex.click();
1446
1544
  };
1545
+
1546
+ // --- Graph visualization (inline) ---
1547
+ const graphTooltip = $('#graphTooltip');
1548
+
1549
+ const GRAPH_PALETTE = [
1550
+ '#3b82f6', '#a855f7', '#06b6d4', '#22c55e', '#f59e0b',
1551
+ '#14b8a6', '#ec4899', '#eab308', '#f97316', '#f43f5e',
1552
+ '#6366f1', '#84cc16', '#0ea5e9', '#d946ef', '#fb7185',
1553
+ ];
1554
+ let graphCategoryColors = {};
1555
+ let graphData = null;
1556
+ let graphSimulation = null;
1557
+ let graphActiveCategories = new Set();
1558
+ let graphG = null;
1559
+ let graphZoom = null;
1560
+ let graphLinkGroup = null;
1561
+ let graphNodeGroup = null;
1562
+
1563
+ function getCategoryColor(cat) {
1564
+ if (!graphCategoryColors[cat]) {
1565
+ const idx = Object.keys(graphCategoryColors).length % GRAPH_PALETTE.length;
1566
+ graphCategoryColors[cat] = GRAPH_PALETTE[idx];
1567
+ }
1568
+ return graphCategoryColors[cat];
1569
+ }
1570
+
1571
+ function renderGraph(data) {
1572
+ totalResults = 0;
1573
+ pagination.style.display = 'none';
1574
+ graphData = data;
1575
+
1576
+ if (!data.nodes || data.nodes.length === 0) {
1577
+ resultsEl.innerHTML = '<div class="empty-state"><p>No graph data. Index a repository first.</p></div>';
1578
+ resultCount.textContent = '';
1579
+ return;
1580
+ }
1581
+
1582
+ resultCount.textContent = `${data.nodes.length} modules, ${data.edges.length} edges`;
1583
+
1584
+ // Assign category colors
1585
+ graphCategoryColors = {};
1586
+ const categories = [...new Set(data.nodes.map(n => n.category))].sort();
1587
+ categories.forEach(c => getCategoryColor(c));
1588
+ graphActiveCategories = new Set(categories);
1589
+
1590
+ // Build the inline graph HTML
1591
+ resultsEl.innerHTML = `
1592
+ <div class="graph-wrap" id="graphWrap">
1593
+ <div class="graph-filters">
1594
+ <div class="graph-filters-title">Categories</div>
1595
+ <div class="graph-filter-badges" id="graphFilterBadges"></div>
1596
+ </div>
1597
+ <div class="graph-detail" id="graphDetail">
1598
+ <div class="graph-detail-cat" id="graphDetailCat"></div>
1599
+ <div class="graph-detail-name" id="graphDetailName"></div>
1600
+ <div class="graph-detail-path" id="graphDetailPath"></div>
1601
+ <div class="graph-detail-section">
1602
+ <div class="graph-detail-section-title">Imports (<span id="graphDetailImportsCount">0</span>)</div>
1603
+ <div id="graphDetailImports"></div>
1604
+ </div>
1605
+ <div class="graph-detail-section">
1606
+ <div class="graph-detail-section-title">Imported By (<span id="graphDetailImportedByCount">0</span>)</div>
1607
+ <div id="graphDetailImportedBy"></div>
1608
+ </div>
1609
+ </div>
1610
+ <div class="graph-search">
1611
+ <input type="text" id="graphSearchInput" placeholder="Search files..." autocomplete="off" spellcheck="false" />
1612
+ <div class="graph-search-results" id="graphSearchResults"></div>
1613
+ </div>
1614
+ <svg id="graphSvg"></svg>
1615
+ </div>
1616
+ `;
1617
+
1618
+ // Build filter badges
1619
+ const badgeContainer = $('#graphFilterBadges');
1620
+ categories.forEach(cat => {
1621
+ const badge = document.createElement('div');
1622
+ badge.className = 'graph-badge active';
1623
+ badge.dataset.cat = cat;
1624
+ badge.innerHTML = `<span class="dot" style="background:${getCategoryColor(cat)}"></span>${esc(cat)}`;
1625
+ badge.addEventListener('click', () => {
1626
+ if (graphActiveCategories.has(cat)) {
1627
+ graphActiveCategories.delete(cat);
1628
+ badge.classList.remove('active');
1629
+ } else {
1630
+ graphActiveCategories.add(cat);
1631
+ badge.classList.add('active');
1632
+ }
1633
+ updateGraphViz();
1634
+ });
1635
+ badgeContainer.appendChild(badge);
1636
+ });
1637
+
1638
+ // SVG setup
1639
+ const wrap = $('#graphWrap');
1640
+ const width = wrap.clientWidth;
1641
+ const height = wrap.clientHeight;
1642
+ const svg = d3.select('#graphSvg').attr('width', width).attr('height', height);
1643
+
1644
+ graphG = svg.append('g');
1645
+ graphZoom = d3.zoom()
1646
+ .scaleExtent([0.05, 5])
1647
+ .on('zoom', (e) => graphG.attr('transform', e.transform));
1648
+ svg.call(graphZoom);
1649
+
1650
+ svg.append('defs').append('marker')
1651
+ .attr('id', 'graph-arrow')
1652
+ .attr('viewBox', '0 -3 6 6')
1653
+ .attr('refX', 16).attr('refY', 0)
1654
+ .attr('markerWidth', 5).attr('markerHeight', 5)
1655
+ .attr('orient', 'auto')
1656
+ .append('path')
1657
+ .attr('d', 'M0,-3L6,0L0,3')
1658
+ .attr('fill', '#555');
1659
+
1660
+ graphLinkGroup = graphG.append('g');
1661
+ graphNodeGroup = graphG.append('g');
1662
+
1663
+ // Click empty space to dismiss detail
1664
+ svg.on('click', () => {
1665
+ const detail = $('#graphDetail');
1666
+ if (detail) detail.classList.remove('visible');
1667
+ });
1668
+
1669
+ updateGraphViz();
1670
+
1671
+ // Fit to view after simulation settles
1672
+ setTimeout(() => {
1673
+ if (!graphG || !graphG.node()) return;
1674
+ const bounds = graphG.node().getBBox();
1675
+ if (bounds.width === 0) return;
1676
+ const dx = bounds.width, dy = bounds.height, x = bounds.x, y = bounds.y;
1677
+ const scale = 0.85 / Math.max(dx / width, dy / height);
1678
+ const translate = [width / 2 - scale * (x + dx / 2), height / 2 - scale * (y + dy / 2)];
1679
+ svg.transition().duration(750).call(
1680
+ graphZoom.transform,
1681
+ d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
1682
+ );
1683
+ }, 3000);
1684
+
1685
+ // Wire up inline search
1686
+ const searchInput = $('#graphSearchInput');
1687
+ const searchResultsEl = $('#graphSearchResults');
1688
+ if (searchInput) {
1689
+ searchInput.oninput = () => {
1690
+ const q = searchInput.value.trim().toLowerCase();
1691
+ if (!q || !graphData) {
1692
+ searchResultsEl.innerHTML = '';
1693
+ graphNodeGroup.selectAll('g.graph-node').classed('dimmed', false);
1694
+ return;
1695
+ }
1696
+ const matches = graphData.nodes.filter(n => n.id.toLowerCase().includes(q)).slice(0, 20);
1697
+ const matchIds = new Set(matches.map(m => m.id));
1698
+ graphNodeGroup.selectAll('g.graph-node').classed('dimmed', n => !matchIds.has(n.id));
1699
+ searchResultsEl.innerHTML = matches.map(m =>
1700
+ `<div class="graph-search-result" data-id="${esc(m.id)}">${esc(m.id)}</div>`
1701
+ ).join('');
1702
+ searchResultsEl.querySelectorAll('.graph-search-result').forEach(el => {
1703
+ el.onclick = () => {
1704
+ const node = graphData.nodes.find(n => n.id === el.dataset.id);
1705
+ if (!node || node.x == null) return;
1706
+ const s = 1.5;
1707
+ const t = [width / 2 - s * node.x, height / 2 - s * node.y];
1708
+ svg.transition().duration(500).call(
1709
+ graphZoom.transform, d3.zoomIdentity.translate(t[0], t[1]).scale(s)
1710
+ );
1711
+ onGraphNodeClick({ stopPropagation: () => {} }, node);
1712
+ };
1713
+ });
1714
+ };
1715
+ }
1716
+ }
1717
+
1718
+ function updateGraphViz() {
1719
+ if (!graphData) return;
1720
+ const wrap = $('#graphWrap');
1721
+ if (!wrap) return;
1722
+ const width = wrap.clientWidth;
1723
+ const height = wrap.clientHeight;
1724
+
1725
+ const nodes = graphData.nodes.filter(n => graphActiveCategories.has(n.category));
1726
+ const nodeIds = new Set(nodes.map(n => n.id));
1727
+ const edges = graphData.edges.filter(e =>
1728
+ nodeIds.has(e.source?.id || e.source) && nodeIds.has(e.target?.id || e.target)
1729
+ );
1730
+
1731
+ resultCount.textContent = `${nodes.length} modules, ${edges.length} edges`;
1732
+
1733
+ // In-degree for sizing
1734
+ const inDegree = {};
1735
+ edges.forEach(e => {
1736
+ const tid = e.target?.id || e.target;
1737
+ inDegree[tid] = (inDegree[tid] || 0) + 1;
1738
+ });
1739
+
1740
+ // Links
1741
+ const links = graphLinkGroup.selectAll('line').data(edges, d => `${d.source?.id||d.source}-${d.target?.id||d.target}`);
1742
+ links.exit().remove();
1743
+ links.enter().append('line')
1744
+ .attr('class', 'graph-link')
1745
+ .attr('stroke', '#555')
1746
+ .attr('stroke-width', 1)
1747
+ .attr('marker-end', 'url(#graph-arrow)');
1748
+
1749
+ // Nodes
1750
+ const nodeData = graphNodeGroup.selectAll('g.graph-node').data(nodes, d => d.id);
1751
+ nodeData.exit().remove();
1752
+ const nodeEnter = nodeData.enter().append('g').attr('class', 'graph-node');
1753
+
1754
+ nodeEnter.append('circle')
1755
+ .attr('r', d => Math.max(5, Math.min(14, 4 + (inDegree[d.id] || 0) * 1.5)))
1756
+ .attr('fill', d => getCategoryColor(d.category))
1757
+ .attr('stroke', d => d3.color(getCategoryColor(d.category)).brighter(0.5))
1758
+ .on('mouseover', onGraphNodeHover)
1759
+ .on('mouseout', onGraphNodeOut)
1760
+ .on('click', onGraphNodeClick);
1761
+
1762
+ nodeEnter.append('text')
1763
+ .attr('dx', d => Math.max(5, Math.min(14, 4 + (inDegree[d.id] || 0) * 1.5)) + 4)
1764
+ .attr('dy', 3)
1765
+ .text(d => d.label);
1766
+
1767
+ nodeData.select('circle')
1768
+ .attr('r', d => Math.max(5, Math.min(14, 4 + (inDegree[d.id] || 0) * 1.5)))
1769
+ .attr('fill', d => getCategoryColor(d.category));
1770
+
1771
+ nodeEnter.call(d3.drag()
1772
+ .on('start', (e, d) => { if (!e.active) graphSimulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
1773
+ .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
1774
+ .on('end', (e, d) => { if (!e.active) graphSimulation.alphaTarget(0); d.fx = null; d.fy = null; })
1775
+ );
1776
+
1777
+ const allLinks = graphLinkGroup.selectAll('line');
1778
+ const allNodes = graphNodeGroup.selectAll('g.graph-node');
1779
+
1780
+ if (graphSimulation) graphSimulation.stop();
1781
+ graphSimulation = d3.forceSimulation(nodes)
1782
+ .force('link', d3.forceLink(edges).id(d => d.id).distance(80))
1783
+ .force('charge', d3.forceManyBody().strength(-200))
1784
+ .force('center', d3.forceCenter(width / 2, height / 2))
1785
+ .force('collision', d3.forceCollide(25))
1786
+ .on('tick', () => {
1787
+ allLinks
1788
+ .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
1789
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
1790
+ allNodes.attr('transform', d => `translate(${d.x},${d.y})`);
1791
+ });
1792
+ }
1793
+
1794
+ function onGraphNodeHover(event, d) {
1795
+ graphTooltip.style.display = 'block';
1796
+ graphTooltip.style.left = (event.clientX + 12) + 'px';
1797
+ graphTooltip.style.top = (event.clientY - 8) + 'px';
1798
+ graphTooltip.textContent = d.id;
1799
+
1800
+ const connected = new Set([d.id]);
1801
+ graphData.edges.forEach(e => {
1802
+ const sid = e.source?.id || e.source;
1803
+ const tid = e.target?.id || e.target;
1804
+ if (sid === d.id) connected.add(tid);
1805
+ if (tid === d.id) connected.add(sid);
1806
+ });
1807
+
1808
+ graphNodeGroup.selectAll('g.graph-node')
1809
+ .classed('dimmed', n => !connected.has(n.id))
1810
+ .classed('highlighted', n => n.id === d.id);
1811
+ graphLinkGroup.selectAll('line').classed('highlighted', e => {
1812
+ const sid = e.source?.id || e.source;
1813
+ const tid = e.target?.id || e.target;
1814
+ return sid === d.id || tid === d.id;
1815
+ });
1816
+ }
1817
+
1818
+ function onGraphNodeOut() {
1819
+ graphTooltip.style.display = 'none';
1820
+ graphNodeGroup.selectAll('g.graph-node').classed('dimmed', false).classed('highlighted', false);
1821
+ graphLinkGroup.selectAll('line').classed('highlighted', false);
1822
+ }
1823
+
1824
+ function onGraphNodeClick(event, d) {
1825
+ event.stopPropagation();
1826
+ const detail = $('#graphDetail');
1827
+ if (!detail) return;
1828
+ detail.classList.add('visible');
1829
+ $('#graphDetailCat').textContent = d.category;
1830
+ $('#graphDetailCat').style.color = getCategoryColor(d.category);
1831
+ $('#graphDetailName').textContent = d.label;
1832
+ const pathEl = $('#graphDetailPath');
1833
+ pathEl.textContent = d.id;
1834
+ pathEl.onclick = () => openFile(d.id);
1835
+
1836
+ const imports = graphData.edges
1837
+ .filter(e => (e.source?.id || e.source) === d.id)
1838
+ .map(e => graphData.nodes.find(n => n.id === (e.target?.id || e.target)))
1839
+ .filter(Boolean);
1840
+ const importedBy = graphData.edges
1841
+ .filter(e => (e.target?.id || e.target) === d.id)
1842
+ .map(e => graphData.nodes.find(n => n.id === (e.source?.id || e.source)))
1843
+ .filter(Boolean);
1844
+
1845
+ $('#graphDetailImportsCount').textContent = imports.length;
1846
+ $('#graphDetailImports').innerHTML = imports.map(n =>
1847
+ `<div class="graph-detail-item"><span class="dot" style="background:${getCategoryColor(n.category)}"></span>${esc(n.label)}</div>`
1848
+ ).join('');
1849
+ $('#graphDetailImportedByCount').textContent = importedBy.length;
1850
+ $('#graphDetailImportedBy').innerHTML = importedBy.map(n =>
1851
+ `<div class="graph-detail-item"><span class="dot" style="background:${getCategoryColor(n.category)}"></span>${esc(n.label)}</div>`
1852
+ ).join('');
1853
+ }
1854
+
1855
+ // --- Context-aware empty state ---
1856
+ function updateEmptyState() {
1857
+ const emptyMsg = $('#emptyMsg');
1858
+ if (!emptyMsg) return;
1859
+ const hasRecent = getRecentRepos().length > 0;
1860
+ if (hasRecent) {
1861
+ emptyMsg.innerHTML = `Select an indexed repository above, or add a new one.<br>
1862
+ Drop a folder, click <strong>Browse</strong>, or type a path.<br><br>
1863
+ <kbd>&#8984;O</kbd> browse &nbsp; <kbd>&#8984;K</kbd> focus search`;
1864
+ } else {
1865
+ emptyMsg.innerHTML = `Drop a folder here, click <strong>Browse</strong>, or type a path above.<br>
1866
+ Then search symbols, explore the map, or trace callers.<br><br>
1867
+ <kbd>&#8984;O</kbd> browse &nbsp; <kbd>&#8984;K</kbd> focus search`;
1868
+ }
1869
+ }
1870
+ updateEmptyState();
1447
1871
  </script>
1448
1872
  </body>
1449
1873
  </html>