@evandu/super-pi 0.1.105 → 0.1.107

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.
Files changed (36) hide show
  1. package/agents/super-pi-task/index.ts +21 -7
  2. package/dist/.super-pi-check-inputs.json +2 -2
  3. package/dist/client/.super-pi-client-sources.json +8 -7
  4. package/dist/client/assets/index-Aa8KN3IB.css +1 -0
  5. package/dist/client/assets/index-B1K1OA_6.js +79 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/application/provider-service.d.ts +14 -0
  8. package/dist/server/application/provider-service.js +29 -0
  9. package/dist/server/application/run-service.d.ts +29 -0
  10. package/dist/server/application/run-service.js +109 -0
  11. package/dist/server/command-handler.js +10 -0
  12. package/dist/server/content-search.d.ts +32 -0
  13. package/dist/server/content-search.js +155 -0
  14. package/dist/server/file-changes.d.ts +119 -0
  15. package/dist/server/file-changes.js +278 -0
  16. package/dist/server/file-diff.d.ts +38 -0
  17. package/dist/server/file-diff.js +97 -0
  18. package/dist/server/file-index.d.ts +56 -0
  19. package/dist/server/file-index.js +150 -0
  20. package/dist/server/files.d.ts +8 -0
  21. package/dist/server/files.js +3 -1
  22. package/dist/server/git.d.ts +17 -0
  23. package/dist/server/git.js +39 -0
  24. package/dist/server/http-server.js +121 -1
  25. package/dist/server/index.js +17 -0
  26. package/dist/server/pi-session-events.d.ts +9 -0
  27. package/dist/server/pi-session-events.js +12 -0
  28. package/dist/server/runtime.d.ts +22 -0
  29. package/dist/server/runtime.js +16 -1
  30. package/dist/server/server-context.d.ts +12 -0
  31. package/dist/server/store.d.ts +1 -0
  32. package/dist/server/store.js +43 -1
  33. package/dist/server/types.d.ts +82 -0
  34. package/package.json +13 -1
  35. package/dist/client/assets/index-UiqTcZca.css +0 -1
  36. package/dist/client/assets/index-tBfBUY_8.js +0 -74
@@ -0,0 +1,278 @@
1
+ /**
2
+ * WHAT THE AGENT CHANGED, AND WHAT IT LOOKED LIKE BEFORE.
3
+ *
4
+ * WHY THIS FILE EXISTS. The Files panel could show a file, but it could not show a CHANGE - and a panel
5
+ * that cannot show a change is a file browser, not a workbench. The user asked for the opposite of a
6
+ * browser: "看得到文件才能更好地支持 ai 的改动" - being able to SEE the files is what makes the agent's
7
+ * edits reviewable. Reviewing an edit means seeing two things: WHICH files were touched, and WHAT
8
+ * changed in them. Neither existed.
9
+ *
10
+ * THE SERVICE ALREADY KNEW THE FIRST HALF AND THREW IT AWAY. `tool-calls.ts` reads a tool's target path
11
+ * out of its arguments to draw one line on a card ("edit src/server/files.ts"). That line was the ONLY
12
+ * place the information existed, and it was never sent to the Files panel - the WS protocol had no file
13
+ * event at all, so a file the agent rewrote looked exactly like a file it had never touched.
14
+ *
15
+ * WHY THE PATHS ARE READ FROM THE ARGUMENTS RATHER THAN GUESSED FROM A WATCHER. A watcher sees bytes
16
+ * changing; it does not see WHO changed them, and it cannot distinguish the agent's edit from an
17
+ * unrelated build step. The tool call is the agent saying "I am about to change this path" in its own
18
+ * words - the most reliable source available, and one that costs nothing to read.
19
+ *
20
+ * WHY THE "BEFORE" CONTENT IS CAPTURED HERE RATHER THAN RECOVERED LATER. Once the agent has written the
21
+ * file, the old bytes are gone from the disk. A diff needs a baseline, and the baseline has to be taken
22
+ * BEFORE the write. So this file keeps a copy of the content as it was, taken when the agent's write
23
+ * tool was announced - the last moment the old content is still on disk.
24
+ *
25
+ * WHAT THIS FILE DOES NOT DO. It does not watch the disk. An external editor saving a file is noticed by
26
+ * the CLIENT's existing poll (`?meta=1` on open files), not here - and that asymmetry is deliberate: this
27
+ * file is about the agent's changes, which is the half only the server can see. It does not keep a full
28
+ * copy of every file: only the files the agent actually wrote, and only until the diff is read.
29
+ */
30
+ import { readFileSync, statSync } from "node:fs";
31
+ import { createHash } from "node:crypto";
32
+ import { isAbsolute, relative, resolve, sep } from "node:path";
33
+ /**
34
+ * The tools whose arguments name a file the agent is about to WRITE.
35
+ *
36
+ * `edit` and `write` are Pi's two file-writing tools (`node_modules/@earendil-works/pi-coding-agent/
37
+ * dist/core/tools/`), and `notebookedit` is the third in the family that edits a notebook. The list is
38
+ * deliberately narrow: a `read` names a file too, and a `read` changes nothing, so treating it as a
39
+ * change would mark a file the agent merely LOOKED at as one it edited - and a marker that lies is worse
40
+ * than no marker, because the user's trust in every other marker goes with it.
41
+ *
42
+ * The names are matched case-insensitively and after trimming, because the name comes off a wire and a
43
+ * tool named "Edit" is the same tool.
44
+ */
45
+ export const FILE_WRITING_TOOLS = new Set(["edit", "write", "notebookedit", "multiedit"]);
46
+ /**
47
+ * The argument fields that name the file, in the order the tools tend to spell them.
48
+ *
49
+ * The same list `tool-calls.ts` uses to draw its one-line summary, and deliberately the same ORDER: two
50
+ * tables that disagree about which field wins would make the card say "src/a.ts" and the change tracker
51
+ * say "src/b.ts" for the same call, and the user would have no way to tell which one is right.
52
+ *
53
+ * `notebook_path` is here because the notebook tools spell it that way, and it is checked before the
54
+ * generic `path` only because the notebook tools have no plain `path` field to collide with.
55
+ */
56
+ const PATH_KEYS = ["path", "file_path", "filePath", "target_file", "filename", "file", "notebook_path"];
57
+ /**
58
+ * The path an argument names, or `undefined` when the arguments name none.
59
+ *
60
+ * NOT a display string. `describeToolCall` collapses whitespace and caps the length, which is right for
61
+ * a card and wrong for a path that is about to be read off the disk - a path with a run of spaces in it
62
+ * would be reported as a path with one space, and the file would not be found. This returns the value
63
+ * as the tool spelled it, so the caller can resolve it properly.
64
+ *
65
+ * The value is NOT validated here. The caller resolves it against the workspace root and refuses
66
+ * anything that leaves it - the containment rule lives in `files.ts` and has exactly one owner, and this
67
+ * module does not get a second, weaker copy of it.
68
+ */
69
+ export function toolTargetPath(name, args) {
70
+ if (typeof name !== "string")
71
+ return undefined;
72
+ if (!FILE_WRITING_TOOLS.has(name.trim().toLowerCase()))
73
+ return undefined;
74
+ if (args === null || typeof args !== "object" || Array.isArray(args))
75
+ return undefined;
76
+ const record = args;
77
+ for (const key of PATH_KEYS) {
78
+ const value = record[key];
79
+ if (typeof value !== "string")
80
+ continue;
81
+ const trimmed = value.trim();
82
+ if (trimmed)
83
+ return trimmed;
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * The changes recorded for one workspace, and the old contents they need.
89
+ *
90
+ * BOUNDED ON PURPOSE, in two directions. The number of entries is capped because a long session can
91
+ * touch a lot of files, and the copy of every old version is the memory cost of this feature. The size
92
+ * of ONE old version is capped because a single 5MB file would otherwise be the whole budget.
93
+ *
94
+ * When a cap is reached, the OLDEST entry is dropped, and `dropped` counts how many were dropped. That
95
+ * count is reported rather than hidden: a diff view that says "this file changed" and then cannot show
96
+ * the change because the baseline was evicted is a worse experience than one that says "the baseline for
97
+ * this file is no longer available", and the second is what the count makes possible.
98
+ */
99
+ const MAX_TRACKED_CHANGES = 200;
100
+ const MAX_BASELINE_BYTES = 1024 * 1024;
101
+ /**
102
+ * The identity of a file's current bytes - the same formula `files.ts` uses, so the two agree.
103
+ *
104
+ * Size, nanosecond modification time and inode: the two fields together change on any edit a human
105
+ * would call an edit, and `bigint` is what makes the modification time exact - the millisecond float
106
+ * `stat` reports by default rounds two edits inside the same millisecond into one value, and a check
107
+ * that misses a change is worse than no check.
108
+ */
109
+ function etagOf(size, mtimeNs, ino) {
110
+ return createHash("sha1").update(`${size}:${mtimeNs}:${ino}`).digest("hex").slice(0, 20);
111
+ }
112
+ export class FileChangeTracker {
113
+ /** Workspace id -> path -> the change recorded for it. A path is recorded once: the LAST change to a
114
+ * file wins, because a file edited three times in a turn is one file with one final state, and the
115
+ * reader wants the diff from before the FIRST change to after the LAST one. */
116
+ #byWorkspace = new Map();
117
+ /** How many changes were dropped because the cap was reached, per workspace. */
118
+ #dropped = new Map();
119
+ /**
120
+ * Records that the agent is about to write `path`, capturing the current content as the baseline.
121
+ *
122
+ * Called when the tool call is ANNOUNCED, not when it ends. The announcement is the last moment the
123
+ * old bytes are still on the disk: by the time the tool has finished, the file already holds the new
124
+ * content and the baseline is gone forever. This is the whole reason the tracker reads the disk at
125
+ * all.
126
+ *
127
+ * A file that is already tracked is NOT re-baselined. Re-baselining would set the "before" to the
128
+ * agent's own intermediate state, and the diff would then show only the LAST edit rather than
129
+ * everything the agent did to the file in this turn - which is the thing a reviewer needs to see.
130
+ *
131
+ * THE READ IS SYNCHRONOUS, and that is the whole reason this feature works.
132
+ *
133
+ * Pi announces a tool call and then RUNS it, and it does not wait for this server to acknowledge the
134
+ * announcement. So the tool may well have written the file by the time this server gets around to
135
+ * reading it - and an ASYNCHRONOUS read loses that race almost every time, because the read is queued
136
+ * behind the very write it is trying to get ahead of. The first version of this was asynchronous, and
137
+ * the symptom was a diff that said "+0 / -0": the baseline it captured was the file's NEW content,
138
+ * because by the time the read happened the agent had already written.
139
+ *
140
+ * A synchronous read cannot lose that race in the same way - it happens in the same tick the
141
+ * announcement was parsed, before anything else can run - and the cost is bounded by the size cap
142
+ * below. A blocked event loop for the length of one small file read, once per agent write, is a far
143
+ * better trade than a diff that shows nothing.
144
+ */
145
+ record(workspaceId, path, options) {
146
+ const relativePath = this.#relative(options.root, path);
147
+ if (!relativePath)
148
+ return;
149
+ const changes = this.#byWorkspace.get(workspaceId) ?? new Map();
150
+ this.#byWorkspace.set(workspaceId, changes);
151
+ if (changes.has(relativePath))
152
+ return;
153
+ let before = null;
154
+ let beforeUnknown = false;
155
+ let etag;
156
+ try {
157
+ const absolute = resolve(options.root, path);
158
+ // The size is checked BEFORE the read, so an oversized file costs one stat rather than a buffer.
159
+ const info = statSync(absolute, { bigint: true });
160
+ if (info.size > MAX_BASELINE_BYTES) {
161
+ // A baseline larger than the cap is not a baseline - it is a memory cost with no diff to show
162
+ // for it. The change is still recorded (the file WAS changed, and that fact is worth knowing);
163
+ // what is dropped is the ability to show what it was, which `beforeUnknown` says out loud.
164
+ beforeUnknown = true;
165
+ }
166
+ else {
167
+ before = readFileSync(absolute, "utf8");
168
+ etag = etagOf(info.size, info.mtimeNs, info.ino);
169
+ }
170
+ }
171
+ catch (error) {
172
+ const code = error?.code;
173
+ // ENOENT is the ordinary case of a NEW file, and it is a fact rather than a failure: the file did
174
+ // not exist before the agent created it, so there is no "before" to show. Anything else (a
175
+ // permission problem, a directory) means we simply do not know, and the two are told apart by
176
+ // `beforeUnknown`.
177
+ beforeUnknown = code !== "ENOENT" && code !== "ENOTDIR";
178
+ }
179
+ changes.set(relativePath, {
180
+ path: relativePath,
181
+ before,
182
+ beforeUnknown,
183
+ ...(etag ? { etag } : {}),
184
+ at: Date.now(),
185
+ ...(options.conversationId ? { conversationId: options.conversationId } : {}),
186
+ });
187
+ if (changes.size > MAX_TRACKED_CHANGES) {
188
+ // The OLDEST goes, because a reviewer is looking at the most recent work. `Map` preserves
189
+ // insertion order, so the first key is the oldest.
190
+ const oldest = changes.keys().next();
191
+ if (!oldest.done) {
192
+ changes.delete(oldest.value);
193
+ this.#dropped.set(workspaceId, (this.#dropped.get(workspaceId) ?? 0) + 1);
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * CONFIRMS A BASELINE, once the agent's write has finished.
199
+ *
200
+ * The synchronous read makes the baseline correct in practice, but "in practice" is not a guarantee:
201
+ * if the tool somehow ran before the announcement was processed, the baseline would be the file's NEW
202
+ * content and the diff would show nothing at all - a diff that says "no change" about a file the
203
+ * agent just rewrote, which is the worst possible lie this feature could tell.
204
+ *
205
+ * So the baseline is CHECKED rather than trusted. The file's etag after the write must DIFFER from
206
+ * the etag the baseline was taken at - and if it does not, the baseline was taken after the write and
207
+ * is worthless. The change is then marked as having no usable "before", and the diff view says so
208
+ * instead of showing an empty diff.
209
+ *
210
+ * The one case this cannot tell apart is a write that changed nothing (the agent wrote identical
211
+ * bytes). There the baseline and the file agree, and the diff is empty - which is CORRECT, because
212
+ * nothing changed. The reader is told the before-version is unconfirmed, which is true in both cases.
213
+ */
214
+ confirm(workspaceId, path, root) {
215
+ const changes = this.#byWorkspace.get(workspaceId);
216
+ if (!changes)
217
+ return;
218
+ // The path is spelled the way the tracker spells it - workspace-relative, `/`-separated - and the
219
+ // caller may hold the tool's own spelling, so both are tried.
220
+ const relativePath = this.#relative(root, path);
221
+ const change = (relativePath ? changes.get(relativePath) : undefined) ?? changes.get(path);
222
+ if (!change || !change.etag)
223
+ return;
224
+ try {
225
+ const info = statSync(resolve(root, change.path), { bigint: true });
226
+ const current = etagOf(info.size, info.mtimeNs, info.ino);
227
+ if (current === change.etag) {
228
+ // The file is byte-for-byte what the baseline says it was, so the baseline was taken AFTER the
229
+ // write - or nothing changed at all. Either way it cannot be shown as "before".
230
+ change.before = null;
231
+ change.beforeUnknown = true;
232
+ }
233
+ }
234
+ catch {
235
+ // The file is gone: the agent DELETED it, which is a change of its own. The baseline stands.
236
+ }
237
+ }
238
+ /**
239
+ * The changes recorded for a workspace, newest first.
240
+ *
241
+ * `dropped` says how many baselines were evicted, so the panel can say so rather than quietly showing
242
+ * a shorter list than the agent actually produced.
243
+ */
244
+ list(workspaceId) {
245
+ const changes = [...(this.#byWorkspace.get(workspaceId)?.values() ?? [])];
246
+ changes.reverse();
247
+ return { changes, dropped: this.#dropped.get(workspaceId) ?? 0 };
248
+ }
249
+ /** The recorded change for one path, or `undefined` when there is none. */
250
+ get(workspaceId, path) {
251
+ return this.#byWorkspace.get(workspaceId)?.get(path);
252
+ }
253
+ /** Forgets a workspace's changes. Used when the workspace is deleted. */
254
+ forget(workspaceId) {
255
+ this.#byWorkspace.delete(workspaceId);
256
+ this.#dropped.delete(workspaceId);
257
+ }
258
+ /**
259
+ * The path as the rest of the app spells it: workspace-relative, `/`-separated, no leading slash.
260
+ *
261
+ * REFUSES ANYTHING OUTSIDE THE ROOT, rather than clamping it. A tool that names `../outside.txt` is
262
+ * either broken or hostile, and a "relative path" that silently becomes `../outside.txt` would be a
263
+ * path the panel would then try to read. The containment decision itself belongs to `files.ts`; this
264
+ * is only about the SPELLING, and it declines to spell a path it cannot vouch for.
265
+ */
266
+ #relative(root, path) {
267
+ const base = resolve(root);
268
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(base, path);
269
+ const rel = relative(base, absolute);
270
+ if (rel === "")
271
+ return null;
272
+ if (isAbsolute(rel))
273
+ return null;
274
+ if (rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith("../"))
275
+ return null;
276
+ return rel.split(sep).join("/");
277
+ }
278
+ }
@@ -0,0 +1,38 @@
1
+ import type { FileChangeTracker } from "./file-changes.js";
2
+ /**
3
+ * WHERE THE "BEFORE" CAME FROM. The client renders a different label for each, because they are
4
+ * different claims: "this is what the agent changed" and "this is what the repository had committed"
5
+ * are not the same sentence, and a diff shown without knowing which one it is against is a diff the
6
+ * reader has to guess about.
7
+ */
8
+ export type DiffBaselineKind = "agent" | "git" | "none";
9
+ export type FileDiff = {
10
+ /** The file, workspace-relative and `/`-separated. */
11
+ path: string;
12
+ /** Which baseline was used. */
13
+ baseline: DiffBaselineKind;
14
+ /** The content before, or `null` when there was none - which is how a NEW file is told from an
15
+ * EDITED one. */
16
+ before: string | null;
17
+ /** True when the "before" could not be READ, as opposed to not existing. A diff against an empty
18
+ * "before" for a file that existed would be a lie, and this is what keeps it from being one. */
19
+ beforeUnknown?: boolean;
20
+ /** The content now, or `null` when the file is gone. */
21
+ after: string | null;
22
+ /** A sentence naming the baseline, for the view to show. */
23
+ label: string;
24
+ };
25
+ /**
26
+ * The diff of one file, with the best baseline available.
27
+ *
28
+ * `preferred` says which baseline the caller wants. `auto` means "pick the best one", which is what
29
+ * the review view asks for; naming a specific baseline means the user has been told which comparison
30
+ * they are looking at and it must not silently become a different one.
31
+ */
32
+ export declare function computeFileDiff(options: {
33
+ root: string;
34
+ path: string;
35
+ preferred?: "auto" | DiffBaselineKind;
36
+ tracker?: FileChangeTracker;
37
+ workspaceId?: string;
38
+ }): Promise<FileDiff>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * THE DIFF BETWEEN WHAT A FILE WAS AND WHAT IT IS NOW.
3
+ *
4
+ * WHY THE SERVER SENDS TWO TEXTS RATHER THAN A DIFF. The difference between two texts is computed by
5
+ * an algorithm, and there are good ones (jsdiff, on the client). Sending a diff instead would mean the
6
+ * server owns a second implementation of that algorithm, and the two would disagree at the edges - a
7
+ * hunk boundary, an empty file, a file with no trailing newline. Sending the two TEXTS means there is
8
+ * exactly ONE implementation of "what is the difference between these two strings" in the whole
9
+ * product, and the server's job is reduced to the one thing only it can do: produce the "before".
10
+ *
11
+ * WHERE THE "BEFORE" COMES FROM, in the order the user cares about.
12
+ *
13
+ * 1. THE AGENT'S OWN BASELINE. The server captured the file's content at the moment the agent's write
14
+ * tool was announced (`file-changes.ts`), so this is the exact content the agent started from. It is
15
+ * the right answer for "what did the agent just do", and it works in a directory that is not a git
16
+ * repository - which is most of them, for a user who has not initialised a repo.
17
+ * 2. GIT'S COMMITTED VERSION. For a file in a git repository, `HEAD` is a baseline that exists even
18
+ * when the server was not running when the change was made, and it is the baseline a reviewer
19
+ * already has in mind.
20
+ * 3. NOTHING. When neither exists - a brand new file in a directory that is not a repository, with no
21
+ * agent change to reconstruct - the answer is that there is no baseline, and the view says so
22
+ * rather than showing a diff against nothing.
23
+ *
24
+ * The BUFFER is the fourth baseline and it does not come from the server: the "before" of a buffer is
25
+ * the file on disk and the "after" is what the user has typed, and the client already holds both. So
26
+ * the client computes that one itself and never asks.
27
+ */
28
+ import { readFile } from "node:fs/promises";
29
+ import { gitHeadContent, isGitRepository } from "./git.js";
30
+ import { FilesError, resolveInsideRoot } from "./files.js";
31
+ /**
32
+ * The diff of one file, with the best baseline available.
33
+ *
34
+ * `preferred` says which baseline the caller wants. `auto` means "pick the best one", which is what
35
+ * the review view asks for; naming a specific baseline means the user has been told which comparison
36
+ * they are looking at and it must not silently become a different one.
37
+ */
38
+ export async function computeFileDiff(options) {
39
+ const { root, path, preferred = "auto", tracker, workspaceId } = options;
40
+ const after = await readAfter(root, path);
41
+ // The agent's baseline. It is the RIGHT one whenever it exists, because it is the content the agent
42
+ // actually started from - which is not necessarily HEAD, and not necessarily what the file looked
43
+ // like when the server started.
44
+ const agentChange = tracker && workspaceId ? tracker.get(workspaceId, path) : undefined;
45
+ if ((preferred === "auto" || preferred === "agent") && agentChange) {
46
+ return {
47
+ path,
48
+ baseline: "agent",
49
+ before: agentChange.before,
50
+ ...(agentChange.beforeUnknown ? { beforeUnknown: true } : {}),
51
+ after,
52
+ label: agentChange.before === null && !agentChange.beforeUnknown
53
+ ? "这个文件是 agent 新建的"
54
+ : "agent 改动前",
55
+ };
56
+ }
57
+ if (preferred === "auto" || preferred === "git") {
58
+ if (await isGitRepository(root)) {
59
+ const head = await gitHeadContent(root, path);
60
+ if (head !== null) {
61
+ return { path, baseline: "git", before: head, after, label: "Git 已提交的版本" };
62
+ }
63
+ }
64
+ }
65
+ if (preferred === "agent" && !agentChange) {
66
+ // The caller ASKED for the agent's baseline and there is none. Saying so is the honest answer; a
67
+ // silent fall-back to git would show the user a comparison they did not ask for.
68
+ return { path, baseline: "none", before: null, after, label: "没有 agent 改动前的版本可比较" };
69
+ }
70
+ return { path, baseline: "none", before: null, after, label: "没有可比较的基线" };
71
+ }
72
+ /**
73
+ * The file's content now, or `null` when it is gone.
74
+ *
75
+ * A MISSING FILE IS NOT AN ERROR. The agent deleting a file is a change the user needs to see, and a
76
+ * diff view that refused to open because the file no longer exists would refuse to show exactly the
77
+ * change that matters most.
78
+ *
79
+ * THE PATH GOES THROUGH `resolveInsideRoot`, like every other path this server reads. The path arrives
80
+ * from a browser, so it is untrusted, and the question "does this resolve to somewhere inside the
81
+ * workspace" has exactly one owner in this codebase (`files.ts`). A second, weaker copy of the check
82
+ * here would be a check that is correct once.
83
+ */
84
+ async function readAfter(root, path) {
85
+ try {
86
+ const target = await resolveInsideRoot(root, path);
87
+ return await readFile(target, "utf8");
88
+ }
89
+ catch (error) {
90
+ // A file that is GONE is a fact the diff reports; a path that ESCAPES the workspace is a refusal
91
+ // the route turns into a 403. They are told apart here so the route can do the right thing with
92
+ // each, rather than both becoming "no content".
93
+ if (error instanceof FilesError && (error.status === 403 || error.code === "outside_workspace"))
94
+ throw error;
95
+ return null;
96
+ }
97
+ }
@@ -0,0 +1,56 @@
1
+ import { type FileMeta } from "./files.js";
2
+ export type IndexedFile = {
3
+ path: string;
4
+ kind: "file" | "directory";
5
+ size: number | null;
6
+ mtimeMs: number | null;
7
+ etag: string | null;
8
+ text: boolean | null;
9
+ };
10
+ export type FileIndexEvent = {
11
+ type: "files.index";
12
+ workspaceId: string;
13
+ version: number;
14
+ changed: IndexedFile[];
15
+ removed: string[];
16
+ complete: boolean;
17
+ skipped: string[];
18
+ };
19
+ export type FileIndexSnapshot = {
20
+ version: number;
21
+ builtAt: number;
22
+ complete: boolean;
23
+ changed: IndexedFile[];
24
+ removed: string[];
25
+ skipped: string[];
26
+ };
27
+ /**
28
+ * Per-workspace index. The background pass uses readdir only because measurements on this repository
29
+ * show directory enumeration under a second while stat for every entry costs 2-3 seconds. Most entries
30
+ * deliberately have no etag: an etag belongs to a file a user has opened or a known write has touched,
31
+ * where the existing read/write metadata already paid for it. The client polls only those open files.
32
+ */
33
+ export declare class FileIndex {
34
+ readonly workspaceId: string;
35
+ private readonly broadcast;
36
+ readonly entries: Map<string, IndexedFile>;
37
+ private readonly history;
38
+ private root;
39
+ private queue;
40
+ private building;
41
+ private skipped;
42
+ version: number;
43
+ builtAt: number;
44
+ complete: boolean;
45
+ constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void);
46
+ start(root: string): void;
47
+ stop(): void;
48
+ snapshot(since?: number): FileIndexSnapshot;
49
+ refresh(path: string): Promise<void>;
50
+ /** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
51
+ * caller's file API operation has already passed the sole containment guard and read this metadata. */
52
+ recordFile(path: string, meta: FileMeta, mtimeMs?: number): void;
53
+ remove(path: string): void;
54
+ private scan;
55
+ private publish;
56
+ }
@@ -0,0 +1,150 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative } from "./files.js";
3
+ const MAX_DIRECTORIES = 4000;
4
+ const MAX_FILES = 30000;
5
+ const MAX_MS = 4000;
6
+ /**
7
+ * Per-workspace index. The background pass uses readdir only because measurements on this repository
8
+ * show directory enumeration under a second while stat for every entry costs 2-3 seconds. Most entries
9
+ * deliberately have no etag: an etag belongs to a file a user has opened or a known write has touched,
10
+ * where the existing read/write metadata already paid for it. The client polls only those open files.
11
+ */
12
+ export class FileIndex {
13
+ workspaceId;
14
+ broadcast;
15
+ entries = new Map();
16
+ history = [];
17
+ root = "";
18
+ queue = [];
19
+ building = false;
20
+ skipped = [];
21
+ version = 0;
22
+ builtAt = 0;
23
+ complete = false;
24
+ constructor(workspaceId, broadcast) {
25
+ this.workspaceId = workspaceId;
26
+ this.broadcast = broadcast;
27
+ }
28
+ start(root) {
29
+ if (this.building || this.builtAt)
30
+ return;
31
+ this.root = root;
32
+ this.building = true;
33
+ this.queue = [""];
34
+ void this.scan();
35
+ }
36
+ stop() { }
37
+ snapshot(since) {
38
+ const valid = Number.isFinite(since) && since !== undefined;
39
+ const deltas = valid ? this.history.filter((item) => item.version > since) : [];
40
+ return { version: this.version, builtAt: this.builtAt, complete: this.complete, skipped: this.skipped,
41
+ changed: valid ? deltas.flatMap((item) => item.changed) : [...this.entries.values()],
42
+ removed: valid ? deltas.flatMap((item) => item.removed) : [] };
43
+ }
44
+ async refresh(path) {
45
+ if (!this.root)
46
+ return;
47
+ const guard = createRootGuard(this.root);
48
+ try {
49
+ const absolute = await guard(path);
50
+ const info = await stat(absolute);
51
+ const rel = toRelative(this.root, absolute);
52
+ if (info.isDirectory())
53
+ this.publish([{ path: rel, kind: "directory", size: null, mtimeMs: null, etag: null, text: null }], []);
54
+ else {
55
+ const meta = await fileMeta(absolute);
56
+ this.recordFile(rel, meta, info.mtimeMs);
57
+ }
58
+ }
59
+ catch (error) {
60
+ const code = error.code;
61
+ if (code === "ENOENT" || code === "ENOTDIR")
62
+ this.remove(path);
63
+ else
64
+ throw error;
65
+ }
66
+ }
67
+ /** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
68
+ * caller's file API operation has already passed the sole containment guard and read this metadata. */
69
+ recordFile(path, meta, mtimeMs = Date.parse(meta.modifiedAt)) {
70
+ const previous = this.entries.get(path);
71
+ if (previous?.kind === "file" && previous.etag === meta.etag)
72
+ return;
73
+ this.publish([{ path, kind: "file", size: meta.size, mtimeMs, etag: meta.etag, text: isTextName(path) }], []);
74
+ }
75
+ remove(path) { if (this.entries.delete(path))
76
+ this.publish([], [path]); }
77
+ async scan() {
78
+ const started = Date.now();
79
+ let dirs = 0;
80
+ let files = 0;
81
+ let incomplete = false;
82
+ const changed = [];
83
+ const guard = createRootGuard(this.root);
84
+ scan: while (this.queue.length) {
85
+ if (dirs >= MAX_DIRECTORIES || files >= MAX_FILES || Date.now() - started >= MAX_MS) {
86
+ incomplete = true;
87
+ break;
88
+ }
89
+ const directory = this.queue.shift();
90
+ let listed;
91
+ try {
92
+ listed = await readdir(await guard(directory), { withFileTypes: true });
93
+ }
94
+ catch {
95
+ this.skipped.push(directory || "/");
96
+ incomplete = true;
97
+ continue;
98
+ }
99
+ dirs += 1;
100
+ for (const entry of listed) {
101
+ const path = directory ? directory + "/" + entry.name : entry.name;
102
+ if (entry.isSymbolicLink()) {
103
+ this.skipped.push(path);
104
+ continue;
105
+ }
106
+ if (entry.isDirectory()) {
107
+ if (SEARCH_SKIP_DIRS.has(entry.name)) {
108
+ this.skipped.push(path);
109
+ continue;
110
+ }
111
+ this.queue.push(path);
112
+ changed.push({ path, kind: "directory", size: null, mtimeMs: null, etag: null, text: null });
113
+ }
114
+ else if (entry.isFile()) {
115
+ if (files >= MAX_FILES || Date.now() - started >= MAX_MS) {
116
+ incomplete = true;
117
+ break scan;
118
+ }
119
+ files += 1;
120
+ changed.push({ path, kind: "file", size: null, mtimeMs: null, etag: null, text: isTextName(path) });
121
+ }
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 0));
124
+ }
125
+ this.building = false;
126
+ this.complete = !incomplete;
127
+ this.builtAt = Date.now();
128
+ // Quick Open does not need per-directory progress. One final batch bounds WS traffic and keeps the
129
+ // existing on-demand tree interactive throughout the scan (including dependency directories).
130
+ this.publish(changed, [], true);
131
+ }
132
+ publish(changed, removed, force = false) {
133
+ changed = changed.map((entry) => {
134
+ // A targeted stat may race the cheap directory walk. Do not let the later readdir-only result erase
135
+ // metadata that was already paid for and verified.
136
+ const previous = this.entries.get(entry.path);
137
+ const merged = previous?.kind === entry.kind && entry.size === null ? { ...entry, size: previous.size, mtimeMs: previous.mtimeMs, etag: previous.etag } : entry;
138
+ this.entries.set(entry.path, merged);
139
+ return merged;
140
+ });
141
+ if (!changed.length && !removed.length && !force)
142
+ return;
143
+ this.version += 1;
144
+ const event = { type: "files.index", workspaceId: this.workspaceId, version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
145
+ this.history.push({ version: this.version, changed, removed });
146
+ if (this.history.length > 128)
147
+ this.history.shift();
148
+ this.broadcast(event);
149
+ }
150
+ }
@@ -15,6 +15,14 @@ export declare const MAX_SEARCH_RESULTS = 500;
15
15
  /** How deep the recursive search walks. Twelve levels is past any real source tree; the cap is what
16
16
  * turns a pathologically deep dependency tree from a hang into a result. */
17
17
  export declare const MAX_SEARCH_DEPTH = 12;
18
+ /** Directories the recursive search skips. The TREE does not skip them - a directory the user cannot
19
+ * browse is a directory they cannot inspect a file in, and the binary handling below is only reachable
20
+ * if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
21
+ * from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
22
+ * reported in the response rather than being invisible. */
23
+ /** Shared exclusion list for recursive search and the quick-open index. The directory tree intentionally
24
+ * does not use it: users may still browse dependencies on demand. */
25
+ export declare const SEARCH_SKIP_DIRS: Set<string>;
18
26
  /**
19
27
  * Extensions this panel OPENS without needing a sniff. It is a hint used only to label a file in the tree
20
28
  * before it is read, and never as a permission: a `.png` is refused by name even if a sniffing pass would
@@ -52,7 +52,9 @@ export const MAX_SEARCH_DEPTH = 12;
52
52
  * if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
53
53
  * from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
54
54
  * reported in the response rather than being invisible. */
55
- const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".super-pi", ".next", ".cache", "coverage", ".venv"]);
55
+ /** Shared exclusion list for recursive search and the quick-open index. The directory tree intentionally
56
+ * does not use it: users may still browse dependencies on demand. */
57
+ export const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".super-pi", ".next", ".cache", "coverage", ".venv"]);
56
58
  /**
57
59
  * Extensions that are binary by definition, checked BEFORE any bytes are read.
58
60
  *