@aaroncql/pim-agent 0.0.1
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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type DiffRenderState, DiffView } from "../../shared/DiffView";
|
|
3
|
+
import { Paths } from "../../shared/Paths";
|
|
4
|
+
import { Tools } from "../../shared/Tools";
|
|
5
|
+
import { editFile, formatEditSummary } from "./edit";
|
|
6
|
+
import { type EditInput, editSchema } from "./schema";
|
|
7
|
+
|
|
8
|
+
const ERROR_PREVIEW_LINES = 12;
|
|
9
|
+
|
|
10
|
+
export default function (pi: ExtensionAPI): void {
|
|
11
|
+
Tools.register(pi, {
|
|
12
|
+
name: "edit",
|
|
13
|
+
label: "edit",
|
|
14
|
+
description:
|
|
15
|
+
"Replace strings in a UTF-8 text file. Prefer edit over write for changes to existing files.",
|
|
16
|
+
parameters: editSchema,
|
|
17
|
+
renderShell: "self",
|
|
18
|
+
executionMode: "sequential",
|
|
19
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
20
|
+
const { path, edits } = params as EditInput;
|
|
21
|
+
|
|
22
|
+
if (signal?.aborted) {
|
|
23
|
+
throw new Error("Edit aborted before execution.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const absolutePath = Paths.resolve(path, ctx.cwd);
|
|
27
|
+
const outcome = await editFile(absolutePath, edits);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: formatEditSummary(path, outcome) }],
|
|
31
|
+
details: outcome,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
renderCall(args, theme, context) {
|
|
35
|
+
const rawPath = typeof args?.path === "string" ? args.path : undefined;
|
|
36
|
+
return DiffView.renderDiffCall({
|
|
37
|
+
label: "Edit",
|
|
38
|
+
rawPath,
|
|
39
|
+
theme,
|
|
40
|
+
context: context as typeof context & { state: DiffRenderState },
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
renderResult(result, options, theme, context) {
|
|
44
|
+
return DiffView.renderDiffResult({
|
|
45
|
+
label: "Edit",
|
|
46
|
+
result,
|
|
47
|
+
options,
|
|
48
|
+
theme,
|
|
49
|
+
context: context as typeof context & { state: DiffRenderState },
|
|
50
|
+
previewLines: ERROR_PREVIEW_LINES,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const editSchema = Type.Object({
|
|
4
|
+
path: Type.String({
|
|
5
|
+
description: "Absolute or relative path to file (resolved against cwd).",
|
|
6
|
+
}),
|
|
7
|
+
edits: Type.Array(
|
|
8
|
+
Type.Object(
|
|
9
|
+
{
|
|
10
|
+
oldString: Type.String({
|
|
11
|
+
description:
|
|
12
|
+
"Use the actual file content without the `LINE:` prefix from read output. Must be unique unless replaceAll=true. Include enough surrounding context for uniqueness.",
|
|
13
|
+
}),
|
|
14
|
+
newString: Type.String({
|
|
15
|
+
description:
|
|
16
|
+
"Replacement text. Empty string deletes the matched range.",
|
|
17
|
+
}),
|
|
18
|
+
replaceAll: Type.Optional(
|
|
19
|
+
Type.Boolean({
|
|
20
|
+
description:
|
|
21
|
+
"If true, replaces every occurrence of oldString. Defaults to false.",
|
|
22
|
+
})
|
|
23
|
+
),
|
|
24
|
+
},
|
|
25
|
+
{ additionalProperties: false }
|
|
26
|
+
),
|
|
27
|
+
{
|
|
28
|
+
minItems: 1,
|
|
29
|
+
description:
|
|
30
|
+
"Non-empty atomic batch of edits. Batched edits resolve against the initial file state and must not overlap. For sequential transformations where edit 2 depends on edit 1's result, use separate tool calls.",
|
|
31
|
+
}
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type EditInput = Static<typeof editSchema>;
|
|
36
|
+
|
|
37
|
+
export type RawEdit = EditInput["edits"][number];
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
loadAbsolute,
|
|
7
|
+
loadRelative,
|
|
8
|
+
type GitSpawnResult,
|
|
9
|
+
type GitSpawner,
|
|
10
|
+
} from "./catalog";
|
|
11
|
+
|
|
12
|
+
let workspace: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
workspace = await mkdtemp(join(tmpdir(), "pim-file-catalog-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(workspace, { force: true, recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const failingSpawner: GitSpawner = async () => ({
|
|
23
|
+
exitCode: 1,
|
|
24
|
+
stdout: "",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const succeedingSpawner = (files: readonly string[]): GitSpawner => {
|
|
28
|
+
return async () => ({
|
|
29
|
+
exitCode: 0,
|
|
30
|
+
stdout: files.join("\n"),
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("loadRelative — fast path (git ls-files)", () => {
|
|
35
|
+
test("returns the listed files as forward-slash relative paths", async () => {
|
|
36
|
+
const candidates = await loadRelative({
|
|
37
|
+
root: workspace,
|
|
38
|
+
gitSpawner: succeedingSpawner([
|
|
39
|
+
"src/foo.ts",
|
|
40
|
+
"src/bar/baz.ts",
|
|
41
|
+
"README.md",
|
|
42
|
+
]),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const paths = candidates.map((c) => c.displayPath);
|
|
46
|
+
expect(paths).toContain("README.md");
|
|
47
|
+
expect(paths).toContain("src/foo.ts");
|
|
48
|
+
expect(paths).toContain("src/bar/baz.ts");
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
expect(candidate.isDirectory).toBe(false);
|
|
51
|
+
expect(candidate.insertPath).toBe(candidate.displayPath);
|
|
52
|
+
expect(candidate.matchHaystack).toBe(candidate.displayPath);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("sorts ascending by relative path", async () => {
|
|
57
|
+
const candidates = await loadRelative({
|
|
58
|
+
root: workspace,
|
|
59
|
+
gitSpawner: succeedingSpawner(["zeta.ts", "alpha.ts", "mu.ts"]),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(candidates.map((c) => c.displayPath)).toEqual([
|
|
63
|
+
"alpha.ts",
|
|
64
|
+
"mu.ts",
|
|
65
|
+
"zeta.ts",
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("limit truncates after sort", async () => {
|
|
70
|
+
const candidates = await loadRelative({
|
|
71
|
+
root: workspace,
|
|
72
|
+
gitSpawner: succeedingSpawner(["c.ts", "a.ts", "b.ts", "d.ts"]),
|
|
73
|
+
limit: 2,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(candidates.map((c) => c.displayPath)).toEqual(["a.ts", "b.ts"]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("loadRelative — fallback (Bun.Glob)", () => {
|
|
81
|
+
test("walks the directory and skips gitignored entries", async () => {
|
|
82
|
+
await writeFile(join(workspace, ".gitignore"), "ignored.txt\n");
|
|
83
|
+
await writeFile(join(workspace, "kept.ts"), "kept");
|
|
84
|
+
await writeFile(join(workspace, "ignored.txt"), "ignored");
|
|
85
|
+
await mkdir(join(workspace, "nested"), { recursive: true });
|
|
86
|
+
await writeFile(join(workspace, "nested", "deep.ts"), "deep");
|
|
87
|
+
await mkdir(join(workspace, "node_modules"), { recursive: true });
|
|
88
|
+
await writeFile(join(workspace, "node_modules", "junk.js"), "junk");
|
|
89
|
+
|
|
90
|
+
const candidates = await loadRelative({
|
|
91
|
+
root: workspace,
|
|
92
|
+
gitSpawner: failingSpawner,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const paths = candidates.map((c) => c.displayPath);
|
|
96
|
+
expect(paths).toContain("kept.ts");
|
|
97
|
+
expect(paths).toContain("nested/deep.ts");
|
|
98
|
+
expect(paths).not.toContain("ignored.txt");
|
|
99
|
+
expect(paths.some((p) => p.includes("node_modules"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("empty workspace produces an empty list", async () => {
|
|
103
|
+
const candidates = await loadRelative({
|
|
104
|
+
root: workspace,
|
|
105
|
+
gitSpawner: failingSpawner,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(candidates).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("loadRelative — coalescing", () => {
|
|
113
|
+
test("two concurrent calls with the same root share one spawn", async () => {
|
|
114
|
+
let invocations = 0;
|
|
115
|
+
const spawner: GitSpawner = async () => {
|
|
116
|
+
invocations += 1;
|
|
117
|
+
await new Promise((resolveSleep) => setTimeout(resolveSleep, 10));
|
|
118
|
+
return { exitCode: 0, stdout: "x.ts\n" } satisfies GitSpawnResult;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const [a, b] = await Promise.all([
|
|
122
|
+
loadRelative({ root: workspace, gitSpawner: spawner }),
|
|
123
|
+
loadRelative({ root: workspace, gitSpawner: spawner }),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(invocations).toBe(1);
|
|
127
|
+
expect(a).toBe(b);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("subsequent call after settle re-runs the spawner", async () => {
|
|
131
|
+
let invocations = 0;
|
|
132
|
+
const spawner: GitSpawner = async () => {
|
|
133
|
+
invocations += 1;
|
|
134
|
+
return { exitCode: 0, stdout: "x.ts\n" };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
await loadRelative({ root: workspace, gitSpawner: spawner });
|
|
138
|
+
await loadRelative({ root: workspace, gitSpawner: spawner });
|
|
139
|
+
|
|
140
|
+
expect(invocations).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("loadAbsolute", () => {
|
|
145
|
+
test("anchor at exact directory lists its children", async () => {
|
|
146
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
147
|
+
await writeFile(join(workspace, "sub", "alpha.ts"), "a");
|
|
148
|
+
await writeFile(join(workspace, "sub", "beta.ts"), "b");
|
|
149
|
+
await mkdir(join(workspace, "sub", "child"), { recursive: true });
|
|
150
|
+
|
|
151
|
+
const result = await loadAbsolute({
|
|
152
|
+
query: `${join(workspace, "sub")}/`,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result.residualQuery).toBe("");
|
|
156
|
+
const names = result.candidates.map((c) => c.matchHaystack);
|
|
157
|
+
expect(names).toEqual(["child", "alpha.ts", "beta.ts"]);
|
|
158
|
+
const child = result.candidates.find((c) => c.matchHaystack === "child");
|
|
159
|
+
expect(child?.isDirectory).toBe(true);
|
|
160
|
+
const alpha = result.candidates.find((c) => c.matchHaystack === "alpha.ts");
|
|
161
|
+
expect(alpha?.isDirectory).toBe(false);
|
|
162
|
+
expect(alpha?.insertPath).toBe(join(workspace, "sub", "alpha.ts"));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("anchor at deepest existing prefix produces residual", async () => {
|
|
166
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
167
|
+
await writeFile(join(workspace, "sub", "partial.ts"), "p");
|
|
168
|
+
|
|
169
|
+
const result = await loadAbsolute({
|
|
170
|
+
query: `${join(workspace, "sub")}/parti`,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result.residualQuery).toBe("parti");
|
|
174
|
+
expect(result.candidates.map((c) => c.matchHaystack)).toEqual([
|
|
175
|
+
"partial.ts",
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("walks past a file that is an exact prefix to the parent directory", async () => {
|
|
180
|
+
await mkdir(join(workspace, "etc"), { recursive: true });
|
|
181
|
+
await writeFile(join(workspace, "etc", "passwd"), "fake");
|
|
182
|
+
|
|
183
|
+
const result = await loadAbsolute({
|
|
184
|
+
query: `${join(workspace, "etc", "passwd")}/foo`,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result.residualQuery).toBe("passwd/foo");
|
|
188
|
+
expect(result.candidates.map((c) => c.matchHaystack)).toEqual(["passwd"]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("normalizes .. segments before walking", async () => {
|
|
192
|
+
await mkdir(join(workspace, "alpha"), { recursive: true });
|
|
193
|
+
await writeFile(join(workspace, "alpha", "x.ts"), "x");
|
|
194
|
+
await mkdir(join(workspace, "beta"), { recursive: true });
|
|
195
|
+
|
|
196
|
+
const result = await loadAbsolute({
|
|
197
|
+
query: `${join(workspace, "beta")}/../alpha/`,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.residualQuery).toBe("");
|
|
201
|
+
expect(result.candidates.map((c) => c.matchHaystack)).toEqual(["x.ts"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("nonexistent suffix walks up to the deepest existing ancestor", async () => {
|
|
205
|
+
await mkdir(join(workspace, "real"), { recursive: true });
|
|
206
|
+
await writeFile(join(workspace, "real", "kept.ts"), "k");
|
|
207
|
+
|
|
208
|
+
const result = await loadAbsolute({
|
|
209
|
+
query: `${join(workspace, "real")}/nope/foo`,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(result.residualQuery).toBe("nope/foo");
|
|
213
|
+
expect(result.candidates.map((c) => c.matchHaystack)).toEqual(["kept.ts"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("symlinked directory is treated as a directory anchor", async () => {
|
|
217
|
+
await mkdir(join(workspace, "real"), { recursive: true });
|
|
218
|
+
await writeFile(join(workspace, "real", "x.ts"), "x");
|
|
219
|
+
await symlink(join(workspace, "real"), join(workspace, "link"));
|
|
220
|
+
|
|
221
|
+
const result = await loadAbsolute({
|
|
222
|
+
query: `${join(workspace, "link")}/`,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.candidates.map((c) => c.matchHaystack)).toEqual(["x.ts"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("dotfiles hidden unless residual starts with a dot", async () => {
|
|
229
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
230
|
+
await writeFile(join(workspace, "sub", "visible.ts"), "v");
|
|
231
|
+
await writeFile(join(workspace, "sub", ".hidden"), "h");
|
|
232
|
+
|
|
233
|
+
const without = await loadAbsolute({
|
|
234
|
+
query: `${join(workspace, "sub")}/`,
|
|
235
|
+
});
|
|
236
|
+
expect(without.candidates.map((c) => c.matchHaystack)).toEqual([
|
|
237
|
+
"visible.ts",
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const withDot = await loadAbsolute({
|
|
241
|
+
query: `${join(workspace, "sub")}/.h`,
|
|
242
|
+
});
|
|
243
|
+
expect(withDot.candidates.map((c) => c.matchHaystack)).toContain(".hidden");
|
|
244
|
+
expect(withDot.residualQuery).toBe(".h");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("directories sort before files within the same anchor", async () => {
|
|
248
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
249
|
+
await writeFile(join(workspace, "sub", "z-file.ts"), "z");
|
|
250
|
+
await mkdir(join(workspace, "sub", "a-dir"), { recursive: true });
|
|
251
|
+
await writeFile(join(workspace, "sub", "a-file.ts"), "a");
|
|
252
|
+
|
|
253
|
+
const result = await loadAbsolute({
|
|
254
|
+
query: `${join(workspace, "sub")}/`,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.candidates.map((c) => c.matchHaystack)).toEqual([
|
|
258
|
+
"a-dir",
|
|
259
|
+
"a-file.ts",
|
|
260
|
+
"z-file.ts",
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { GitignoreFilter } from "../../shared/GitignoreFilter";
|
|
4
|
+
import { Paths } from "../../shared/Paths";
|
|
5
|
+
|
|
6
|
+
export type FileCandidate = {
|
|
7
|
+
readonly insertPath: string;
|
|
8
|
+
readonly displayPath: string;
|
|
9
|
+
readonly matchHaystack: string;
|
|
10
|
+
readonly isDirectory: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AbsoluteCatalog = {
|
|
14
|
+
readonly candidates: readonly FileCandidate[];
|
|
15
|
+
readonly residualQuery: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type GitSpawnResult = {
|
|
19
|
+
readonly exitCode: number;
|
|
20
|
+
readonly stdout: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type GitSpawner = (
|
|
24
|
+
args: readonly string[],
|
|
25
|
+
options: { readonly cwd: string }
|
|
26
|
+
) => Promise<GitSpawnResult>;
|
|
27
|
+
|
|
28
|
+
export type LoadRelativeOptions = {
|
|
29
|
+
readonly root: string;
|
|
30
|
+
readonly limit?: number;
|
|
31
|
+
readonly gitSpawner?: GitSpawner;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type LoadAbsoluteOptions = {
|
|
35
|
+
readonly query: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const DEFAULT_LIMIT = 10_000;
|
|
39
|
+
|
|
40
|
+
const defaultGitSpawner: GitSpawner = async (args, options) => {
|
|
41
|
+
try {
|
|
42
|
+
const child = Bun.spawn(["git", ...args], {
|
|
43
|
+
cwd: options.cwd,
|
|
44
|
+
stdout: "pipe",
|
|
45
|
+
stderr: "ignore",
|
|
46
|
+
stdin: "ignore",
|
|
47
|
+
});
|
|
48
|
+
const stdout = await new Response(child.stdout).text();
|
|
49
|
+
const exitCode = await child.exited;
|
|
50
|
+
return { exitCode, stdout };
|
|
51
|
+
} catch {
|
|
52
|
+
return { exitCode: -1, stdout: "" };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const inflightRelative = new Map<string, Promise<readonly FileCandidate[]>>();
|
|
57
|
+
|
|
58
|
+
export function loadRelative(
|
|
59
|
+
options: LoadRelativeOptions
|
|
60
|
+
): Promise<readonly FileCandidate[]> {
|
|
61
|
+
const root = resolve(options.root);
|
|
62
|
+
const existing = inflightRelative.get(root);
|
|
63
|
+
if (existing !== undefined) {
|
|
64
|
+
return existing;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const promise = runLoadRelative(root, options).finally(() => {
|
|
68
|
+
inflightRelative.delete(root);
|
|
69
|
+
});
|
|
70
|
+
inflightRelative.set(root, promise);
|
|
71
|
+
return promise;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadAbsolute(
|
|
75
|
+
options: LoadAbsoluteOptions
|
|
76
|
+
): Promise<AbsoluteCatalog> {
|
|
77
|
+
const expanded = Paths.expandHome(options.query);
|
|
78
|
+
const normalized = isAbsolute(expanded) ? resolve(expanded) : expanded;
|
|
79
|
+
const anchor = await findAnchor(normalized);
|
|
80
|
+
const residualSlice = normalized.slice(anchor.length);
|
|
81
|
+
const residualQuery =
|
|
82
|
+
residualSlice.startsWith(sep) || residualSlice.startsWith("/")
|
|
83
|
+
? residualSlice.slice(1)
|
|
84
|
+
: residualSlice;
|
|
85
|
+
|
|
86
|
+
let entries: { readonly name: string; readonly isDirectory: boolean }[];
|
|
87
|
+
try {
|
|
88
|
+
const dirents = await readdir(anchor, { withFileTypes: true });
|
|
89
|
+
entries = dirents.map((dirent) => ({
|
|
90
|
+
name: dirent.name,
|
|
91
|
+
isDirectory: dirent.isDirectory(),
|
|
92
|
+
}));
|
|
93
|
+
} catch {
|
|
94
|
+
return { candidates: [], residualQuery };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const includeDot = residualQuery.startsWith(".");
|
|
98
|
+
const filtered = entries.filter((entry) => {
|
|
99
|
+
if (!includeDot && entry.name.startsWith(".")) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
filtered.sort((a, b) => {
|
|
106
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
107
|
+
return a.isDirectory ? -1 : 1;
|
|
108
|
+
}
|
|
109
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const candidates: FileCandidate[] = filtered.map((entry) => {
|
|
113
|
+
const fullPath = join(anchor, entry.name);
|
|
114
|
+
return {
|
|
115
|
+
insertPath: fullPath,
|
|
116
|
+
displayPath: fullPath,
|
|
117
|
+
matchHaystack: entry.name,
|
|
118
|
+
isDirectory: entry.isDirectory,
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return { candidates, residualQuery };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runLoadRelative(
|
|
126
|
+
root: string,
|
|
127
|
+
options: LoadRelativeOptions
|
|
128
|
+
): Promise<readonly FileCandidate[]> {
|
|
129
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
130
|
+
const spawner = options.gitSpawner ?? defaultGitSpawner;
|
|
131
|
+
|
|
132
|
+
const fastPath = await tryGitListFiles(root, spawner);
|
|
133
|
+
if (fastPath !== undefined) {
|
|
134
|
+
return finalizeRelative(fastPath, limit);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fallback = await scanWithGlob(root);
|
|
138
|
+
return finalizeRelative(fallback, limit);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function tryGitListFiles(
|
|
142
|
+
root: string,
|
|
143
|
+
spawner: GitSpawner
|
|
144
|
+
): Promise<readonly string[] | undefined> {
|
|
145
|
+
const result = await spawner(
|
|
146
|
+
["ls-files", "--cached", "--others", "--exclude-standard"],
|
|
147
|
+
{ cwd: root }
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (result.exitCode !== 0) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result.stdout.split("\n").filter((line) => line.length > 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function scanWithGlob(root: string): Promise<readonly string[]> {
|
|
158
|
+
const filter = await GitignoreFilter.for(root);
|
|
159
|
+
const glob = new Bun.Glob("**/*");
|
|
160
|
+
const matches: string[] = [];
|
|
161
|
+
|
|
162
|
+
for await (const absolutePath of glob.scan({
|
|
163
|
+
cwd: root,
|
|
164
|
+
absolute: true,
|
|
165
|
+
onlyFiles: true,
|
|
166
|
+
dot: false,
|
|
167
|
+
})) {
|
|
168
|
+
if (!filter.ignores(absolutePath)) {
|
|
169
|
+
matches.push(Paths.toForwardSlashes(relative(root, absolutePath)));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return matches;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function finalizeRelative(
|
|
177
|
+
paths: readonly string[],
|
|
178
|
+
limit: number
|
|
179
|
+
): readonly FileCandidate[] {
|
|
180
|
+
const normalized = paths.map(Paths.toForwardSlashes);
|
|
181
|
+
normalized.sort((a, b) => a.localeCompare(b));
|
|
182
|
+
|
|
183
|
+
const truncated =
|
|
184
|
+
normalized.length > limit ? normalized.slice(0, limit) : normalized;
|
|
185
|
+
return truncated.map((path) => ({
|
|
186
|
+
insertPath: path,
|
|
187
|
+
displayPath: path,
|
|
188
|
+
matchHaystack: path,
|
|
189
|
+
isDirectory: false,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function findAnchor(absolutePath: string): Promise<string> {
|
|
194
|
+
if (!isAbsolute(absolutePath)) {
|
|
195
|
+
return parse(process.cwd()).root;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const filesystemRoot = parse(absolutePath).root;
|
|
199
|
+
let current = absolutePath;
|
|
200
|
+
|
|
201
|
+
while (true) {
|
|
202
|
+
try {
|
|
203
|
+
const metadata = await stat(current);
|
|
204
|
+
if (metadata.isDirectory()) {
|
|
205
|
+
return current;
|
|
206
|
+
}
|
|
207
|
+
} catch {}
|
|
208
|
+
|
|
209
|
+
if (current === filesystemRoot) {
|
|
210
|
+
return filesystemRoot;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const parent = resolve(current, "..");
|
|
214
|
+
if (parent === current) {
|
|
215
|
+
return filesystemRoot;
|
|
216
|
+
}
|
|
217
|
+
current = parent;
|
|
218
|
+
}
|
|
219
|
+
}
|