@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 +1 -1
- package/src/cache.js +17 -4
- package/src/cli.js +221 -8
- package/src/index.js +102 -9
- package/src/server.js +9 -0
- package/src/ui.html +430 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mishasinitcyn/betterrank",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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 (
|
|
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
|
-
|
|
188
|
-
|
|
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 (
|
|
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]
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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))
|
|
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))
|
|
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
|
-
<!--
|
|
681
|
+
<!-- Indexed repos -->
|
|
601
682
|
<div class="recent-repos" id="recentRepos" style="display:none">
|
|
602
|
-
<div class="recent-label">
|
|
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">📂</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>⌘O</kbd> browse <kbd>⌘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>⌘O</kbd> browse <kbd>⌘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>⌘O</kbd> browse <kbd>⌘K</kbd> focus search`;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
updateEmptyState();
|
|
1447
1871
|
</script>
|
|
1448
1872
|
</body>
|
|
1449
1873
|
</html>
|