pi-supernova 0.0.11 → 0.1.0
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/CHANGELOG.md +74 -3
- package/README.md +72 -21
- package/bottleneck.js +98 -97
- package/catalog.js +12 -36
- package/check.js +166 -0
- package/config.default.json +1 -0
- package/config.js +1 -2
- package/decode.js +57 -6
- package/evidence.js +27 -35
- package/format.js +16 -17
- package/fuzzy.js +182 -0
- package/guest-worker.js +38 -157
- package/host-bridge.js +246 -124
- package/index.js +95 -123
- package/ledger.js +150 -0
- package/omp-frame.js +7 -23
- package/outline.js +80 -0
- package/package.json +11 -2
- package/parallel.js +42 -34
- package/patch.js +62 -72
- package/render-measure.js +46 -144
- package/render.js +43 -158
- package/repo-index.js +106 -11
- package/runtime.js +204 -227
- package/search.js +141 -0
- package/snap.js +1 -6
- package/surface.js +22 -13
- package/vfs.js +114 -75
- package/workspace.js +51 -33
package/render.js
CHANGED
|
@@ -12,124 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { stripVTControlCharacters } from "node:util";
|
|
14
14
|
import { isString, isObject, isFunction } from "./decode.js";
|
|
15
|
-
import {
|
|
16
|
-
measureWidth,
|
|
17
|
-
hardTruncate,
|
|
18
|
-
clampLine,
|
|
19
|
-
wrapPlainToWidth,
|
|
20
|
-
fitPath,
|
|
21
|
-
} from "./render-measure.js";
|
|
15
|
+
import { measureWidth, hardTruncate, clampLine, fitPath, wrapLine } from "./render-measure.js";
|
|
22
16
|
import { novaFramedBlock, novaStatusLine } from "./omp-frame.js";
|
|
23
17
|
import { formatValue } from "./format.js";
|
|
24
18
|
|
|
25
|
-
export { measureWidth, hardTruncate, clampLine
|
|
26
|
-
|
|
27
|
-
function fitOutputLines(text, width) {
|
|
28
|
-
const w = Math.max(1, width | 0);
|
|
29
|
-
const out = [];
|
|
30
|
-
for (const line of String(text ?? "")
|
|
31
|
-
.replace(/\t/g, " ")
|
|
32
|
-
.split("\n")) {
|
|
33
|
-
if (measureWidth(line) <= w) {
|
|
34
|
-
out.push(line);
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
// Wrap on plain text so long paths continue on the next line instead of
|
|
38
|
-
// dying as `packages/pi-supern…`. ANSI is dropped on wrap (crash-safety).
|
|
39
|
-
const plain = stripVTControlCharacters(line);
|
|
40
|
-
for (const chunk of wrapPlainToWidth(plain, w)) {
|
|
41
|
-
out.push(clampLine(chunk, w));
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return out.length > 0 ? out : [""];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Compact bounded text; the host supplies the card background and borders. */
|
|
48
|
-
export class SafeText {
|
|
49
|
-
constructor(text = "") {
|
|
50
|
-
this.text = text;
|
|
51
|
-
}
|
|
52
|
-
setText(text) {
|
|
53
|
-
this.text = text;
|
|
54
|
-
}
|
|
55
|
-
invalidate() {}
|
|
56
|
-
render(width = 80) {
|
|
57
|
-
const raw = String(this.text ?? "");
|
|
58
|
-
return raw.trim() ? fitOutputLines(raw, Math.max(1, width | 0)) : [];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Keep names some tests / older call sites may import.
|
|
63
|
-
export const visibleWidth = measureWidth;
|
|
64
|
-
export const truncateToWidth = hardTruncate;
|
|
65
|
-
|
|
66
|
-
export function extractOperationsFromCode(code) {
|
|
67
|
-
const trimmed = String(code || "").trim();
|
|
68
|
-
if (!trimmed) return [];
|
|
69
|
-
|
|
70
|
-
const ops = [];
|
|
71
|
-
const seen = new Set();
|
|
72
|
-
|
|
73
|
-
const addOp = (tool, target) => {
|
|
74
|
-
const key = `${tool}:${target}`;
|
|
75
|
-
if (!seen.has(key)) {
|
|
76
|
-
seen.add(key);
|
|
77
|
-
ops.push({ tool, target });
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const callRegex = /nova\.call\s*\(\s*["'`]([a-zA-Z0-9_-]+)["'`](?:\s*,\s*(\{[\s\S]*?\}))?/g;
|
|
82
|
-
let match;
|
|
83
|
-
while ((match = callRegex.exec(trimmed)) !== null) {
|
|
84
|
-
const tool = match[1];
|
|
85
|
-
let target = "";
|
|
86
|
-
if (match[2]) {
|
|
87
|
-
const pathMatch = /path\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
88
|
-
const cmdMatch = /command\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
89
|
-
const patMatch = /pattern\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2]);
|
|
90
|
-
if (pathMatch) target = pathMatch[1];
|
|
91
|
-
else if (cmdMatch) target = cmdMatch[1].length > 30 ? cmdMatch[1].slice(0, 27) + "…" : cmdMatch[1];
|
|
92
|
-
else if (patMatch) target = patMatch[1];
|
|
93
|
-
}
|
|
94
|
-
addOp(tool, target);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const callManyRegex = /nova\.callMany\s*\(\s*\[([\s\S]*?)\]\s*\)/g;
|
|
98
|
-
while ((match = callManyRegex.exec(trimmed)) !== null) {
|
|
99
|
-
const inner = match[1];
|
|
100
|
-
const subCalls = inner.matchAll(/name\s*:\s*["'`]([a-zA-Z0-9_-]+)["'`]/g);
|
|
101
|
-
for (const sub of subCalls) addOp(sub[1], "");
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const namedCalls = [
|
|
105
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?read\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "read", wrap: (p) => p },
|
|
106
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?write\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "write", wrap: (p) => p },
|
|
107
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?edit\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "edit", wrap: (p) => p },
|
|
108
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?patch\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "patch", wrap: (p) => p },
|
|
109
|
-
{
|
|
110
|
-
regex: /(?:^|[^\w$.])(?:nova\.)?bash\s*\(\s*["'`]([^"'`]+)["'`]/gm,
|
|
111
|
-
tool: "bash",
|
|
112
|
-
wrap: (c) => (c.length > 32 ? c.slice(0, 29) + "…" : c),
|
|
113
|
-
},
|
|
114
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?exec\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "exec", wrap: (c) => c },
|
|
115
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?search\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "search", wrap: (q) => `"${q}"` },
|
|
116
|
-
{ regex: /(?:^|[^\w$.])nova\.describe\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "describe", wrap: (name) => name },
|
|
117
|
-
{ regex: /(?:^|[^\w$.])nova\.has\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "has", wrap: (name) => name },
|
|
118
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?surface\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "surface", wrap: (p) => p },
|
|
119
|
-
{ regex: /(?:^|[^\w$.])(?:nova\.)?snap\s*\(\s*["'`]([^"'`]+)["'`]/gm, tool: "snap", wrap: (q) => `"${q}"` },
|
|
120
|
-
];
|
|
121
|
-
for (const item of namedCalls) {
|
|
122
|
-
while ((match = item.regex.exec(trimmed)) !== null) {
|
|
123
|
-
addOp(item.tool, item.wrap(match[1]));
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (/nova\.speculate\s*\(/.test(trimmed)) {
|
|
128
|
-
addOp("speculate", "(branch foam)");
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return ops;
|
|
132
|
-
}
|
|
19
|
+
export { measureWidth, hardTruncate, clampLine };
|
|
133
20
|
|
|
134
21
|
function formatDiffRows(diff, theme, maxShown = 6) {
|
|
135
22
|
if (!diff || !Array.isArray(diff.lines) || diff.lines.length === 0) return [];
|
|
@@ -217,7 +104,7 @@ export function normalizeCallRenderArgs(a, b, c) {
|
|
|
217
104
|
/**
|
|
218
105
|
* Dual-host renderResult args:
|
|
219
106
|
* Pi: (result, {expanded,isPartial}, theme, context)
|
|
220
|
-
* OMP: (result, {expanded,isPartial}, theme, args)
|
|
107
|
+
* OMP: (result, {expanded,isPartial}, theme, args) (4th is args, not context)
|
|
221
108
|
*
|
|
222
109
|
* Call shapes share the first three positions, so host is inferred from the
|
|
223
110
|
* fourth argument's context-versus-args shape.
|
|
@@ -237,7 +124,7 @@ function detectResultHost(options, ctxOrArgs) {
|
|
|
237
124
|
return "pi";
|
|
238
125
|
}
|
|
239
126
|
|
|
240
|
-
|
|
127
|
+
function normalizeResultRenderArgs(result, options, themeOrCtx, ctxOrArgs) {
|
|
241
128
|
if (isTheme(themeOrCtx)) {
|
|
242
129
|
const opts = isObject(options) ? options : {};
|
|
243
130
|
const context = contextFrom(opts, ctxOrArgs);
|
|
@@ -333,20 +220,20 @@ function operationsFromTrace(trace) {
|
|
|
333
220
|
.filter(Boolean);
|
|
334
221
|
}
|
|
335
222
|
|
|
223
|
+
// The call slot is always empty: the result card owns the whole lifecycle in both hosts.
|
|
224
|
+
const EMPTY_CALL = { render: () => [], invalidate() {} };
|
|
225
|
+
|
|
336
226
|
export function renderSupernovaCall(a, b, c) {
|
|
337
227
|
const { context, options } = normalizeCallRenderArgs(a, b, c);
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
comp.setText("");
|
|
342
|
-
return comp;
|
|
228
|
+
if (options) options.lastComponent = EMPTY_CALL;
|
|
229
|
+
else if (context) context.lastComponent = EMPTY_CALL;
|
|
230
|
+
return EMPTY_CALL;
|
|
343
231
|
}
|
|
344
232
|
|
|
345
233
|
const TOOL_COL = 7;
|
|
346
234
|
const DURATION_COL = 6;
|
|
347
|
-
const PREVIEW_LINES = 24;
|
|
348
235
|
|
|
349
|
-
|
|
236
|
+
function formatDuration(ms) {
|
|
350
237
|
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
351
238
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
352
239
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -418,19 +305,13 @@ function formatOpRow(theme, op, width, isPartial, isError) {
|
|
|
418
305
|
return prefix.trimEnd();
|
|
419
306
|
}
|
|
420
307
|
|
|
421
|
-
function operationsFor(payload, context
|
|
422
|
-
|
|
423
|
-
const traced = operationsFromTrace(trace);
|
|
424
|
-
if (traced.length > 0) return traced;
|
|
425
|
-
return extractOperationsFromCode(args?.code).map((op) => displayOperation(op.tool, op.target)).filter(Boolean);
|
|
308
|
+
function operationsFor(payload, context) {
|
|
309
|
+
return operationsFromTrace(payload?.trace || context?.state?.trace || []);
|
|
426
310
|
}
|
|
427
311
|
|
|
428
|
-
function resultLines(value,
|
|
312
|
+
function resultLines(value, width) {
|
|
429
313
|
const text = isString(value) ? value : formatValue(value);
|
|
430
|
-
|
|
431
|
-
const shown = lines.slice(0, maxLines);
|
|
432
|
-
if (lines.length > maxLines) shown.push(`… ${lines.length - maxLines} more lines`);
|
|
433
|
-
return shown;
|
|
314
|
+
return cleanBlockText(text).split("\n").flatMap(line => wrapLine(line, width));
|
|
434
315
|
}
|
|
435
316
|
|
|
436
317
|
function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError) {
|
|
@@ -442,15 +323,18 @@ function appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, is
|
|
|
442
323
|
if (ops.length > maxOps) lines.push(theme.fg("dim", ` … ${ops.length - maxOps} more calls`));
|
|
443
324
|
}
|
|
444
325
|
|
|
445
|
-
function appendTail(lines, theme, payload, expanded, isError) {
|
|
446
|
-
if (isError)
|
|
326
|
+
function appendTail(lines, theme, payload, expanded, isError, width) {
|
|
327
|
+
if (isError) {
|
|
328
|
+
const error = "✗ " + (payload?.error ? cleanBlockText(payload.error) : "error");
|
|
329
|
+
for (const line of expanded ? resultLines(error, width) : error.split("\n")) lines.push(theme.fg("error", line));
|
|
330
|
+
}
|
|
447
331
|
else if (expanded && payload?.result !== undefined) {
|
|
448
332
|
lines.push(theme.fg("dim", "── result ──"));
|
|
449
|
-
for (const line of resultLines(payload.result,
|
|
333
|
+
for (const line of resultLines(payload.result, width)) lines.push(theme.fg("toolOutput", line));
|
|
450
334
|
}
|
|
451
335
|
if (expanded && payload?.logs?.length) {
|
|
452
336
|
lines.push(theme.fg("dim", "── logs ──"));
|
|
453
|
-
for (const log of payload.logs
|
|
337
|
+
for (const log of payload.logs) for (const line of resultLines(log, width)) lines.push(theme.fg("dim", line));
|
|
454
338
|
}
|
|
455
339
|
}
|
|
456
340
|
|
|
@@ -460,7 +344,7 @@ function buildBodyLines(theme, width, { payload, context, args, expanded, isPart
|
|
|
460
344
|
const maxDiffLines = expanded ? 24 : 8;
|
|
461
345
|
const lines = [];
|
|
462
346
|
appendOps(lines, theme, ops, maxOps, maxDiffLines, width, isPartial, isError);
|
|
463
|
-
appendTail(lines, theme, payload, expanded, isError);
|
|
347
|
+
appendTail(lines, theme, payload, expanded, isError, width);
|
|
464
348
|
return { lines, opCount: ops.length };
|
|
465
349
|
}
|
|
466
350
|
|
|
@@ -475,33 +359,34 @@ class UnifiedResultCard {
|
|
|
475
359
|
set(theme, model) {
|
|
476
360
|
this.theme = theme;
|
|
477
361
|
this.model = model;
|
|
478
|
-
this.
|
|
362
|
+
this.cache = undefined;
|
|
479
363
|
}
|
|
480
364
|
invalidate() {
|
|
481
|
-
this.
|
|
365
|
+
this.cache = undefined;
|
|
482
366
|
}
|
|
483
367
|
render(width = 80) {
|
|
484
368
|
const { theme, model } = this;
|
|
485
|
-
if (!theme || !model) return [];
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
369
|
+
if (!theme || !model || width <= 0) return [];
|
|
370
|
+
if (this.cache?.width === width) return this.cache.lines;
|
|
371
|
+
const view = buildBodyLines(theme, Math.max(1, width - 4), model);
|
|
372
|
+
const header = novaStatusLine(theme, {
|
|
373
|
+
icon: model.isError ? "error" : model.isPartial ? "running" : undefined,
|
|
374
|
+
title: "nova",
|
|
375
|
+
description: describeCard(model, view.opCount),
|
|
376
|
+
});
|
|
377
|
+
// A program with no host calls has nothing to frame: one status line, no empty box.
|
|
378
|
+
const lines = view.lines.length === 0
|
|
379
|
+
? [clampLine(header, width)]
|
|
380
|
+
: novaFramedBlock(theme, () => ({
|
|
381
|
+
header,
|
|
382
|
+
sections: [{ lines: view.lines }],
|
|
497
383
|
state: model.isError ? "error" : model.isPartial ? "pending" : "success",
|
|
498
384
|
// borderMuted is invisible on OMP's card background; dim matches the duration column.
|
|
499
385
|
borderColor: model.isError ? "error" : "dim",
|
|
500
|
-
width
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return this.frame.render(width);
|
|
386
|
+
width,
|
|
387
|
+
})).render(width);
|
|
388
|
+
this.cache = { width, lines };
|
|
389
|
+
return lines;
|
|
505
390
|
}
|
|
506
391
|
}
|
|
507
392
|
|
package/repo-index.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { extractStructuralSurface } from "./surface.js";
|
|
4
|
+
import { isFunction } from "./decode.js";
|
|
5
|
+
import { Frecency } from "./fuzzy.js";
|
|
6
|
+
import { relativeSlash } from "./workspace.js";
|
|
4
7
|
|
|
5
8
|
// In-process workspace index: the gitignore-aware file list comes from one
|
|
6
9
|
// \`rg --files\` spawn and is then reused; file text, lowercase text, and the
|
|
7
10
|
// structural surface are cached per path and validated by mtime. snap/grep/glob
|
|
8
11
|
// read from here instead of spawning, so a warm call is sub-millisecond.
|
|
9
12
|
|
|
13
|
+
// With a working fs.watch the list only refreshes on change; the TTL is the fallback when watching fails.
|
|
10
14
|
const LIST_TTL_MS = 10_000;
|
|
15
|
+
const WATCHED_TTL_MS = 5 * 60_000;
|
|
16
|
+
const WATCH_DEBOUNCE_MS = 150;
|
|
11
17
|
const MAX_INDEXED_FILES = 4000;
|
|
12
18
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
13
19
|
const BINARY_EXT = new Set([
|
|
@@ -20,7 +26,12 @@ const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
|
|
|
20
26
|
const EMPTY = Object.freeze([]);
|
|
21
27
|
const DEF_PATTERN = /^(?:pub\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(function|class|def|fn|const|let|interface|type|struct|enum)\s+([a-zA-Z0-9_$]+)/;
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
/** Declared identifier on a line (function/class/const/…), or ""; the same rule snap and grep use. */
|
|
30
|
+
export function declaredName(line) {
|
|
31
|
+
return DEF_PATTERN.exec(String(line).trim())?.[2] ?? "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isTextCandidate(filePath) {
|
|
24
35
|
return !BINARY_EXT.has(path.extname(filePath).toLowerCase());
|
|
25
36
|
}
|
|
26
37
|
|
|
@@ -69,17 +80,81 @@ export class WorkspaceIndex {
|
|
|
69
80
|
this.runCommand = runCommand;
|
|
70
81
|
this.lists = new Map();
|
|
71
82
|
this.entries = new Map();
|
|
83
|
+
this.watchers = new Map();
|
|
84
|
+
this.frecency = new Frecency();
|
|
85
|
+
this.gitModified = new Map(); // root → Set(relative "/"-joined paths)
|
|
86
|
+
this.lastTouched = null;
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
invalidate() {
|
|
75
90
|
this.lists.clear();
|
|
76
91
|
}
|
|
77
92
|
|
|
93
|
+
/** fff frecency: every read/edit is an access; the newest one is the "current file" for distance penalties. */
|
|
94
|
+
touch(relPath) {
|
|
95
|
+
this.frecency.record(relPath);
|
|
96
|
+
this.lastTouched = relPath;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
watch(root) {
|
|
100
|
+
if (this.watchers.has(root)) return this.watchers.get(root);
|
|
101
|
+
let ok = false;
|
|
102
|
+
try {
|
|
103
|
+
let timer = null;
|
|
104
|
+
const watcher = fs.watch(root, { recursive: true }, () => {
|
|
105
|
+
if (timer) return;
|
|
106
|
+
timer = setTimeout(() => {
|
|
107
|
+
timer = null;
|
|
108
|
+
this.lists.clear();
|
|
109
|
+
this.gitModified.delete(root);
|
|
110
|
+
}, WATCH_DEBOUNCE_MS);
|
|
111
|
+
});
|
|
112
|
+
watcher.on("error", () => {
|
|
113
|
+
this.watchers.set(root, false);
|
|
114
|
+
this.lists.clear();
|
|
115
|
+
});
|
|
116
|
+
if (isFunction(watcher.unref)) watcher.unref();
|
|
117
|
+
ok = true;
|
|
118
|
+
} catch {
|
|
119
|
+
ok = false;
|
|
120
|
+
}
|
|
121
|
+
this.watchers.set(root, ok);
|
|
122
|
+
return ok;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Paths git reports as modified/added/untracked (fff's git-status boost); one spawn per list refresh. */
|
|
126
|
+
async modifiedFiles(root) {
|
|
127
|
+
const cached = this.gitModified.get(root);
|
|
128
|
+
if (cached) return cached;
|
|
129
|
+
const set = new Set();
|
|
130
|
+
try {
|
|
131
|
+
const res = await this.runCommand(["git", "status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeoutMs: 5_000 });
|
|
132
|
+
if (res.exitCode === 0) {
|
|
133
|
+
for (const row of res.stdout.split("\0")) {
|
|
134
|
+
if (row.length > 3) set.add(row.slice(3));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
this.gitModified.set(root, set);
|
|
139
|
+
return set;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
mtimeSeconds(filePath) {
|
|
143
|
+
const e = this.entries.get(filePath);
|
|
144
|
+
if (e) return e.mtimeMs / 1000;
|
|
145
|
+
try {
|
|
146
|
+
return fs.statSync(filePath).mtimeMs / 1000;
|
|
147
|
+
} catch {
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
78
152
|
/** Absolute, sorted file list for a root; gitignore-aware via rg; cached for LIST_TTL_MS. */
|
|
79
153
|
async files(root, includeHidden = false) {
|
|
80
154
|
const key = root + "\0" + (includeHidden ? "h" : "");
|
|
81
155
|
const cached = this.lists.get(key);
|
|
82
|
-
|
|
156
|
+
const ttl = this.watch(root) ? WATCHED_TTL_MS : LIST_TTL_MS;
|
|
157
|
+
if (cached && Date.now() - cached.at < ttl) return cached.files;
|
|
83
158
|
const args = ["rg", "--files"];
|
|
84
159
|
if (includeHidden) args.push("--hidden");
|
|
85
160
|
args.push("-g", "!.git/**", "-g", "!**/.git/**", root);
|
|
@@ -114,16 +189,16 @@ export class WorkspaceIndex {
|
|
|
114
189
|
return null;
|
|
115
190
|
}
|
|
116
191
|
if (text.includes("\0")) return null;
|
|
117
|
-
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined };
|
|
192
|
+
const created = { text, lower: text.toLowerCase(), mtimeMs: stat.mtimeMs, size: stat.size, ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
118
193
|
this.entries.set(filePath, created);
|
|
119
194
|
return created;
|
|
120
195
|
}
|
|
121
196
|
|
|
122
197
|
static fromText(filePath, text) {
|
|
123
|
-
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined };
|
|
198
|
+
return { text, lower: text.toLowerCase(), ext: path.extname(filePath), surface: undefined, lines: undefined, spans: undefined };
|
|
124
199
|
}
|
|
125
200
|
|
|
126
|
-
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens
|
|
201
|
+
/** Per-line raw text, lowercase text, declared identifier (or ""), and identifier tokens, computed once per entry. */
|
|
127
202
|
static linesOf(entry) {
|
|
128
203
|
if (entry.lines) return entry.lines;
|
|
129
204
|
const raw = entry.text.split("\n");
|
|
@@ -140,6 +215,25 @@ export class WorkspaceIndex {
|
|
|
140
215
|
return entry.lines;
|
|
141
216
|
}
|
|
142
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Declaration spans [start, end] (1-based, inclusive) in file order, trailing blank lines trimmed.
|
|
220
|
+
* A span runs to the line before the next declaration; the file's leading header is not a span.
|
|
221
|
+
*/
|
|
222
|
+
static spansOf(entry) {
|
|
223
|
+
if (entry.spans) return entry.spans;
|
|
224
|
+
const { items, lineCount } = WorkspaceIndex.surfaceOf(entry);
|
|
225
|
+
const { lower } = WorkspaceIndex.linesOf(entry);
|
|
226
|
+
const spans = [];
|
|
227
|
+
for (let i = 0; i < items.length; i++) {
|
|
228
|
+
const start = items[i].line;
|
|
229
|
+
let end = Math.min(i + 1 < items.length ? items[i + 1].line - 1 : lineCount, lineCount);
|
|
230
|
+
while (end > start && lower[end - 1] === "") end--;
|
|
231
|
+
spans.push({ start, end, name: items[i].name, kind: items[i].kind, isExport: items[i].isExport === true });
|
|
232
|
+
}
|
|
233
|
+
entry.spans = spans;
|
|
234
|
+
return spans;
|
|
235
|
+
}
|
|
236
|
+
|
|
143
237
|
static surfaceOf(entry) {
|
|
144
238
|
if (!entry.surface) entry.surface = extractStructuralSurface(entry.text, entry.ext);
|
|
145
239
|
return entry.surface;
|
|
@@ -162,16 +256,17 @@ export class WorkspaceIndex {
|
|
|
162
256
|
return hits;
|
|
163
257
|
}
|
|
164
258
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
259
|
+
/** Structured grep rows {rel, line, text, def}; def marks lines whose declared name itself matches. */
|
|
260
|
+
grepRows(files, regex, root) {
|
|
167
261
|
const out = [];
|
|
262
|
+
const nameRegex = new RegExp(regex.source, "i");
|
|
168
263
|
for (const filePath of files) {
|
|
169
264
|
const e = this.entry(filePath);
|
|
170
265
|
if (!e || !regex.test(e.text)) continue;
|
|
171
|
-
const
|
|
172
|
-
const rel =
|
|
173
|
-
for (let i = 0; i <
|
|
174
|
-
if (regex.test(
|
|
266
|
+
const { raw, defNames } = WorkspaceIndex.linesOf(e);
|
|
267
|
+
const rel = relativeSlash(root, filePath);
|
|
268
|
+
for (let i = 0; i < raw.length; i++) {
|
|
269
|
+
if (regex.test(raw[i])) out.push({ rel, line: i + 1, text: raw[i], def: defNames[i] !== "" && nameRegex.test(defNames[i]) });
|
|
175
270
|
}
|
|
176
271
|
}
|
|
177
272
|
return out;
|