@mishasinitcyn/betterrank 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mishasinitcyn/betterrank",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Structural code index with PageRank-ranked repo maps, symbol search, call-graph queries, and dependency analysis. Built on tree-sitter and graphology.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cache.js CHANGED
@@ -150,7 +150,7 @@ class CodeIndexCache {
150
150
  this.initialized = true;
151
151
  }
152
152
 
153
- const { changed, deleted } = await this._getChangedFiles();
153
+ const { changed, deleted, totalScanned } = await this._getChangedFiles();
154
154
 
155
155
  if (changed.length === 0 && deleted.length === 0) {
156
156
  if (!this.graph) {
@@ -159,9 +159,15 @@ class CodeIndexCache {
159
159
  const MDG = graphology.default?.MultiDirectedGraph || graphology.MultiDirectedGraph;
160
160
  this.graph = new MDG({ allowSelfLoops: false });
161
161
  }
162
- return { changed: 0, deleted: 0 };
162
+ return { changed: 0, deleted: 0, totalScanned };
163
163
  }
164
164
 
165
+ const isColdStart = !this.graph;
166
+ if (isColdStart) {
167
+ process.stderr.write(`Indexing ${this.projectRoot}... ${changed.length} files found, parsing...\n`);
168
+ }
169
+
170
+ const t0 = Date.now();
165
171
  const newSymbols = await this._parseFiles(changed);
166
172
 
167
173
  if (!this.graph) {
@@ -175,7 +181,14 @@ class CodeIndexCache {
175
181
 
176
182
  await saveGraph(this.graph, this.mtimes, this.cachePath);
177
183
 
178
- return { changed: changed.length, deleted: deleted.length };
184
+ if (isColdStart) {
185
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
186
+ let symbols = 0;
187
+ this.graph.forEachNode((_n, attrs) => { if (attrs.type === 'symbol') symbols++; });
188
+ process.stderr.write(`Indexed ${changed.length} files (${symbols} symbols, ${this.graph.size} edges) in ${elapsed}s\n`);
189
+ }
190
+
191
+ return { changed: changed.length, deleted: deleted.length, totalScanned };
179
192
  }
180
193
 
181
194
  /**
@@ -256,7 +269,7 @@ class CodeIndexCache {
256
269
  }
257
270
  }
258
271
 
259
- return { changed, deleted };
272
+ return { changed, deleted, totalScanned: files.length };
260
273
  }
261
274
 
262
275
  /**
package/src/cli.js CHANGED
@@ -27,8 +27,152 @@ Global flags:
27
27
  --count Return counts only (no content)
28
28
  --offset N Skip first N results
29
29
  --limit N Max results to return (default: ${DEFAULT_LIMIT} for list commands)
30
+ --help Show help for a command (e.g. betterrank search --help)
30
31
  `.trim();
31
32
 
33
+ const COMMAND_HELP = {
34
+ map: `betterrank map [--focus file1,file2] [--root <path>]
35
+
36
+ Aider-style repo map: the most structurally important definitions ranked by PageRank.
37
+
38
+ Options:
39
+ --focus <files> Comma-separated files to bias ranking toward
40
+ --count Return total symbol count only
41
+ --offset N Skip first N symbols
42
+ --limit N Max symbols to return (default: ${DEFAULT_LIMIT})
43
+
44
+ Examples:
45
+ betterrank map --root ./backend
46
+ betterrank map --root ./frontend --limit 100
47
+ betterrank map --root ./backend --focus src/auth/handlers.ts,src/api/login.ts`,
48
+
49
+ search: `betterrank search <query> [--kind type] [--root <path>]
50
+
51
+ Substring search on symbol names + full signatures (param names, types, defaults).
52
+ Results ranked by PageRank (most structurally important first).
53
+
54
+ Options:
55
+ --kind <type> Filter: function, class, type, variable, namespace, import
56
+ --count Return match count only
57
+ --offset N Skip first N results
58
+ --limit N Max results (default: ${DEFAULT_LIMIT})
59
+
60
+ Tips:
61
+ Use short substrings (3-5 chars) — PageRank ranking handles noise.
62
+ "imp" finds encrypt_imp_payload, increment_impression, etc.
63
+ Searches match against both symbol names AND full signatures (param names, types).
64
+
65
+ Examples:
66
+ betterrank search resolve --root ./backend
67
+ betterrank search auth --kind function --limit 10
68
+ betterrank search max_age --root . --count`,
69
+
70
+ structure: `betterrank structure [--depth N] [--root <path>]
71
+
72
+ File tree with symbol counts per file.
73
+
74
+ Options:
75
+ --depth N Max directory depth (default: ${DEFAULT_DEPTH})
76
+
77
+ Examples:
78
+ betterrank structure --root ./backend --depth 2`,
79
+
80
+ symbols: `betterrank symbols [--file path] [--kind type] [--root <path>]
81
+
82
+ List symbol definitions, optionally filtered by file or kind.
83
+ Results ranked by PageRank (most structurally important first).
84
+
85
+ Options:
86
+ --file <path> Filter to a specific file (relative to --root)
87
+ --kind <type> Filter: function, class, type, variable, namespace, import
88
+ --count Return count only
89
+ --offset N Skip first N results
90
+ --limit N Max results (default: ${DEFAULT_LIMIT})
91
+
92
+ Examples:
93
+ betterrank symbols --file src/auth/handlers.ts --root ./backend
94
+ betterrank symbols --kind class --root . --limit 20`,
95
+
96
+ callers: `betterrank callers <symbol> [--file path] [--root <path>]
97
+
98
+ Find all files that reference a symbol. Ranked by file-level PageRank.
99
+
100
+ Options:
101
+ --file <path> Disambiguate when multiple symbols share a name
102
+ --count Return count only
103
+ --offset N Skip first N results
104
+ --limit N Max results (default: ${DEFAULT_LIMIT})
105
+
106
+ Examples:
107
+ betterrank callers authenticateUser --root ./backend
108
+ betterrank callers resolve --file src/utils.ts --root .`,
109
+
110
+ deps: `betterrank deps <file> [--root <path>]
111
+
112
+ What this file imports / depends on. Ranked by PageRank.
113
+
114
+ Options:
115
+ --count Return count only
116
+ --offset N Skip first N results
117
+ --limit N Max results (default: ${DEFAULT_LIMIT})
118
+
119
+ Examples:
120
+ betterrank deps src/auth/handlers.ts --root ./backend`,
121
+
122
+ dependents: `betterrank dependents <file> [--root <path>]
123
+
124
+ What files import this file. Ranked by PageRank.
125
+
126
+ Options:
127
+ --count Return count only
128
+ --offset N Skip first N results
129
+ --limit N Max results (default: ${DEFAULT_LIMIT})
130
+
131
+ Examples:
132
+ betterrank dependents src/auth/handlers.ts --root ./backend`,
133
+
134
+ neighborhood: `betterrank neighborhood <file> [--hops N] [--max-files N] [--root <path>]
135
+
136
+ Local subgraph around a file: its imports, importers, and their symbols.
137
+
138
+ Options:
139
+ --hops N BFS depth for outgoing imports (default: 2)
140
+ --max-files N Max files to include (default: 15, direct neighbors always included)
141
+ --count Return counts only
142
+ --offset N Skip first N files
143
+ --limit N Max files to return
144
+
145
+ Examples:
146
+ betterrank neighborhood src/auth/handlers.ts --root ./backend
147
+ betterrank neighborhood src/api/bid.js --hops 3 --max-files 20 --root .`,
148
+
149
+ reindex: `betterrank reindex [--root <path>]
150
+
151
+ Force a full rebuild of the index. Use after branch switches, large merges,
152
+ or if results seem stale.
153
+
154
+ Examples:
155
+ betterrank reindex --root ./backend`,
156
+
157
+ stats: `betterrank stats [--root <path>]
158
+
159
+ Show index statistics: file count, symbol count, edge count.
160
+
161
+ Examples:
162
+ betterrank stats --root .`,
163
+
164
+ ui: `betterrank ui [--port N]
165
+
166
+ Launch the interactive web UI for exploring the index.
167
+
168
+ Options:
169
+ --port N Port to listen on (default: 3333)
170
+
171
+ Examples:
172
+ betterrank ui
173
+ betterrank ui --port 8080`,
174
+ };
175
+
32
176
  async function main() {
33
177
  const args = process.argv.slice(2);
34
178
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
@@ -39,6 +183,16 @@ async function main() {
39
183
  const command = args[0];
40
184
  const flags = parseFlags(args.slice(1));
41
185
 
186
+ // Per-command --help
187
+ if (flags.help === true || flags.h === true) {
188
+ if (COMMAND_HELP[command]) {
189
+ console.log(COMMAND_HELP[command]);
190
+ } else {
191
+ console.log(USAGE);
192
+ }
193
+ process.exit(0);
194
+ }
195
+
42
196
  // UI command doesn't need --root or a CodeIndex instance
43
197
  if (command === 'ui') {
44
198
  const { startServer } = await import('./server.js');
@@ -73,6 +227,35 @@ async function main() {
73
227
  return rel;
74
228
  }
75
229
 
230
+ /** Print file-not-found diagnostics and exit. Returns true if handled. */
231
+ function handleFileNotFound(result, filePath) {
232
+ if (!result || !result.fileNotFound) return false;
233
+ console.error(`File "${filePath}" not found in index.`);
234
+ if (result.suggestions && result.suggestions.length > 0) {
235
+ console.error(`Did you mean:`);
236
+ for (const s of result.suggestions) console.error(` ${s}`);
237
+ }
238
+ console.error(`Tip: File paths are relative to --root. Use \`betterrank structure\` to see indexed files.`);
239
+ return true;
240
+ }
241
+
242
+ /** Suggest shorter query alternatives by splitting on _ and camelCase boundaries. */
243
+ function suggestShorterQueries(query) {
244
+ const parts = new Set();
245
+ // Split on underscores
246
+ for (const p of query.split('_')) {
247
+ if (p.length >= 3) parts.add(p.toLowerCase());
248
+ }
249
+ // Split on camelCase boundaries
250
+ const camelParts = query.replace(/([a-z])([A-Z])/g, '$1_$2').split('_');
251
+ for (const p of camelParts) {
252
+ if (p.length >= 3) parts.add(p.toLowerCase());
253
+ }
254
+ // Remove the original query itself
255
+ parts.delete(query.toLowerCase());
256
+ return [...parts].slice(0, 4);
257
+ }
258
+
76
259
  switch (command) {
77
260
  case 'map': {
78
261
  const focusFiles = flags.focus ? flags.focus.split(',') : [];
@@ -87,6 +270,17 @@ async function main() {
87
270
  console.log(`Use --limit N to show more, or --count for totals`);
88
271
  }
89
272
  }
273
+ // Diagnostics on empty index
274
+ if (result.diagnostics) {
275
+ const d = result.diagnostics;
276
+ console.log(`\nDiagnostics:`);
277
+ console.log(` Root: ${d.root}`);
278
+ console.log(` Files found: ${d.filesScanned}`);
279
+ console.log(` Extensions: ${d.extensions}`);
280
+ if (d.filesScanned === 0) {
281
+ console.log(` Tip: No supported files found. Try a broader --root.`);
282
+ }
283
+ }
90
284
  break;
91
285
  }
92
286
 
@@ -102,7 +296,17 @@ async function main() {
102
296
  console.log(`${s.file}:${s.lineStart} [${s.kind}] ${s.signature}`);
103
297
  }
104
298
  if (result.length === 0) {
105
- console.log('(no matches)');
299
+ const st = await idx.stats();
300
+ console.log(`(no matches for "${query}")`);
301
+ if (st.symbols > 0) {
302
+ console.log(`Index has ${st.symbols.toLocaleString()} symbols across ${st.files.toLocaleString()} files.`);
303
+ const suggestions = suggestShorterQueries(query);
304
+ if (suggestions.length > 0) {
305
+ console.log(`Tip: Use shorter substrings. Try: ${suggestions.map(s => `"${s}"`).join(', ')}`);
306
+ }
307
+ } else {
308
+ console.log(`Index is empty. Check --root or run: betterrank map --root <path>`);
309
+ }
106
310
  } else if (result.length === effectiveLimit && userLimit === undefined) {
107
311
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
108
312
  }
@@ -163,13 +367,15 @@ async function main() {
163
367
  if (!file) { console.error('Usage: betterrank deps <file>'); process.exit(1); }
164
368
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
165
369
  const result = await idx.dependencies({ file, count: countMode, offset, limit: effectiveLimit });
370
+ if (handleFileNotFound(result, file)) break;
166
371
  if (countMode) {
167
372
  console.log(`total: ${result.total}`);
168
373
  } else {
169
- for (const d of result) console.log(d);
170
- if (result.length === 0) {
374
+ const items = result.items || result;
375
+ for (const d of items) console.log(d);
376
+ if (items.length === 0) {
171
377
  console.log('(no dependencies)');
172
- } else if (result.length === effectiveLimit && userLimit === undefined) {
378
+ } else if (items.length === effectiveLimit && userLimit === undefined) {
173
379
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
174
380
  }
175
381
  }
@@ -181,13 +387,15 @@ async function main() {
181
387
  if (!file) { console.error('Usage: betterrank dependents <file>'); process.exit(1); }
182
388
  const effectiveLimit = countMode ? undefined : (userLimit !== undefined ? userLimit : DEFAULT_LIMIT);
183
389
  const result = await idx.dependents({ file, count: countMode, offset, limit: effectiveLimit });
390
+ if (handleFileNotFound(result, file)) break;
184
391
  if (countMode) {
185
392
  console.log(`total: ${result.total}`);
186
393
  } else {
187
- for (const d of result) console.log(d);
188
- if (result.length === 0) {
394
+ const items = result.items || result;
395
+ for (const d of items) console.log(d);
396
+ if (items.length === 0) {
189
397
  console.log('(no dependents)');
190
- } else if (result.length === effectiveLimit && userLimit === undefined) {
398
+ } else if (items.length === effectiveLimit && userLimit === undefined) {
191
399
  console.log(`\n(showing top ${effectiveLimit} by relevance — use --limit N or --count for total)`);
192
400
  }
193
401
  }
@@ -208,6 +416,7 @@ async function main() {
208
416
  const preview = await idx.neighborhood({
209
417
  file, hops, maxFiles: maxFilesFlag, count: true,
210
418
  });
419
+ if (handleFileNotFound(preview, file)) break;
211
420
  console.log(`files: ${preview.totalFiles} (${preview.totalVisited} visited, ${preview.totalFiles} after ranking)`);
212
421
  console.log(`symbols: ${preview.totalSymbols}`);
213
422
  console.log(`edges: ${preview.totalEdges}`);
@@ -219,6 +428,7 @@ async function main() {
219
428
  file, hops, maxFiles: maxFilesFlag,
220
429
  count: countMode, offset, limit: userLimit,
221
430
  });
431
+ if (handleFileNotFound(result, file)) break;
222
432
 
223
433
  if (countMode) {
224
434
  console.log(`files: ${result.totalFiles} (${result.totalVisited} visited, ${result.totalFiles} after ranking)`);
@@ -284,7 +494,10 @@ function parseFlags(args) {
284
494
  const flags = { _positional: [] };
285
495
  let i = 0;
286
496
  while (i < args.length) {
287
- if (args[i].startsWith('--')) {
497
+ if (args[i] === '-h') {
498
+ flags.help = true;
499
+ i++;
500
+ } else if (args[i].startsWith('--')) {
288
501
  const key = args[i].substring(2);
289
502
  if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
290
503
  flags[key] = args[i + 1];
package/src/index.js CHANGED
@@ -1,8 +1,32 @@
1
1
  import { readFile } from 'fs/promises';
2
- import { join, dirname, relative, sep } from 'path';
2
+ import { join, dirname, relative, sep, basename } from 'path';
3
3
  import { CodeIndexCache } from './cache.js';
4
4
  import { rankedSymbols } from './graph.js';
5
5
 
6
+ /**
7
+ * Find file nodes in the graph that look similar to the given path.
8
+ * Uses basename matching and substring matching on the full path.
9
+ */
10
+ function findSimilarFiles(graph, filePath, maxSuggestions = 5) {
11
+ if (!graph) return [];
12
+ const base = basename(filePath);
13
+ const baseLower = base.toLowerCase();
14
+ const pathLower = filePath.toLowerCase();
15
+ const suggestions = [];
16
+
17
+ graph.forEachNode((node, attrs) => {
18
+ if (attrs.type !== 'file') return;
19
+ const nodeLower = node.toLowerCase();
20
+ const nodeBase = basename(node).toLowerCase();
21
+ // Exact basename match or basename contains query
22
+ if (nodeBase === baseLower || nodeBase.includes(baseLower) || nodeLower.includes(pathLower)) {
23
+ suggestions.push(node);
24
+ }
25
+ });
26
+
27
+ return suggestions.slice(0, maxSuggestions);
28
+ }
29
+
6
30
  /**
7
31
  * Apply offset/limit pagination to an array.
8
32
  * Returns { items, total } where total is the unpaginated count.
@@ -79,12 +103,17 @@ class CodeIndex {
79
103
  * @returns {{content, shownFiles, shownSymbols, totalFiles, totalSymbols}|{total: number}}
80
104
  */
81
105
  async map({ focusFiles = [], offset, limit, count = false, structured = false } = {}) {
82
- await this._ensureReady();
106
+ const ensureResult = await this._ensureReady();
83
107
  const graph = this.cache.getGraph();
84
108
  if (!graph || graph.order === 0) {
85
- if (count) return { total: 0 };
86
- if (structured) return { files: [], shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0 };
87
- return { content: '(empty index)', shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0 };
109
+ const diagnostics = {
110
+ root: this.projectRoot,
111
+ filesScanned: ensureResult.totalScanned || 0,
112
+ extensions: this.cache.extensions.join(', '),
113
+ };
114
+ if (count) return { total: 0, diagnostics };
115
+ if (structured) return { files: [], shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0, diagnostics };
116
+ return { content: '(empty index)', shownFiles: 0, shownSymbols: 0, totalFiles: 0, totalSymbols: 0, diagnostics };
88
117
  }
89
118
 
90
119
  // Count totals from the graph
@@ -362,7 +391,11 @@ class CodeIndex {
362
391
  async dependencies({ file, offset, limit, count = false }) {
363
392
  await this._ensureReady();
364
393
  const graph = this.cache.getGraph();
365
- if (!graph || !graph.hasNode(file)) return count ? { total: 0 } : [];
394
+ if (!graph || !graph.hasNode(file)) {
395
+ const suggestions = findSimilarFiles(graph, file);
396
+ if (count) return { total: 0, fileNotFound: true, suggestions };
397
+ return { items: [], fileNotFound: true, suggestions };
398
+ }
366
399
 
367
400
  const fileScores = this._getFileScores();
368
401
 
@@ -396,7 +429,11 @@ class CodeIndex {
396
429
  async dependents({ file, offset, limit, count = false }) {
397
430
  await this._ensureReady();
398
431
  const graph = this.cache.getGraph();
399
- if (!graph || !graph.hasNode(file)) return count ? { total: 0 } : [];
432
+ if (!graph || !graph.hasNode(file)) {
433
+ const suggestions = findSimilarFiles(graph, file);
434
+ if (count) return { total: 0, fileNotFound: true, suggestions };
435
+ return { items: [], fileNotFound: true, suggestions };
436
+ }
400
437
 
401
438
  const fileScores = this._getFileScores();
402
439
 
@@ -450,9 +487,10 @@ class CodeIndex {
450
487
  await this._ensureReady();
451
488
  const graph = this.cache.getGraph();
452
489
  if (!graph || !graph.hasNode(file)) {
490
+ const suggestions = findSimilarFiles(graph, file);
453
491
  return count
454
- ? { totalFiles: 0, totalSymbols: 0, totalEdges: 0 }
455
- : { files: [], symbols: [], edges: [] };
492
+ ? { totalFiles: 0, totalSymbols: 0, totalEdges: 0, fileNotFound: true, suggestions }
493
+ : { files: [], symbols: [], edges: [], fileNotFound: true, suggestions };
456
494
  }
457
495
 
458
496
  // BFS over file nodes, following outgoing IMPORTS edges (dependencies)
@@ -597,6 +635,61 @@ class CodeIndex {
597
635
  };
598
636
  }
599
637
 
638
+ /**
639
+ * File-level dependency graph for visualization.
640
+ * Returns nodes (files) ranked by PageRank and IMPORTS edges between them.
641
+ *
642
+ * @param {object} opts
643
+ * @param {number} [opts.limit] - Max nodes to return (default: 500)
644
+ * @returns {{ nodes: Array<{id, label, category, score}>, edges: Array<{source, target}> }}
645
+ */
646
+ async graph({ limit = 500 } = {}) {
647
+ await this._ensureReady();
648
+ const graph = this.cache.getGraph();
649
+ if (!graph || graph.order === 0) {
650
+ return { nodes: [], edges: [] };
651
+ }
652
+
653
+ const fileScores = this._getFileScores();
654
+
655
+ // Collect file nodes with scores, sorted by PageRank
656
+ const fileEntries = [];
657
+ graph.forEachNode((node, attrs) => {
658
+ if (attrs.type !== 'file') return;
659
+ fileEntries.push({ id: node, score: fileScores.get(node) || 0 });
660
+ });
661
+ fileEntries.sort((a, b) => b.score - a.score);
662
+
663
+ // Cap to limit
664
+ const capped = fileEntries.slice(0, limit);
665
+ const cappedSet = new Set(capped.map(f => f.id));
666
+
667
+ // Build nodes with category (first path segment) and label (filename)
668
+ const nodes = capped.map(f => {
669
+ const parts = f.id.split('/');
670
+ const category = parts.length > 1 ? parts[0] : 'root';
671
+ const label = parts[parts.length - 1].replace(/\.[^.]+$/, '');
672
+ return { id: f.id, label, category, score: f.score };
673
+ });
674
+
675
+ // Collect IMPORTS edges between capped files
676
+ const edges = [];
677
+ const edgeSet = new Set();
678
+ for (const f of capped) {
679
+ graph.forEachOutEdge(f.id, (_edge, attrs, source, target) => {
680
+ if (attrs.type !== 'IMPORTS') return;
681
+ if (!cappedSet.has(target)) return;
682
+ const key = `${source}->${target}`;
683
+ if (!edgeSet.has(key)) {
684
+ edgeSet.add(key);
685
+ edges.push({ source, target });
686
+ }
687
+ });
688
+ }
689
+
690
+ return { nodes, edges };
691
+ }
692
+
600
693
  /**
601
694
  * Force a full rebuild.
602
695
  */
package/src/server.js CHANGED
@@ -248,6 +248,15 @@ const routes = {
248
248
  json(res, result);
249
249
  },
250
250
 
251
+ 'GET /api/graph': async (req, res) => {
252
+ if (!requireIndex(res)) return;
253
+ const p = params(req.url);
254
+ const result = await currentIndex.graph({
255
+ limit: p.getInt('limit', 500),
256
+ });
257
+ json(res, result);
258
+ },
259
+
251
260
  'GET /api/structure': async (req, res) => {
252
261
  if (!requireIndex(res)) return;
253
262
  const p = params(req.url);
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>