@aaroncql/pim-agent 0.2.0 โ 0.4.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/README.md +11 -1
- package/package.json +6 -6
- package/src/extensions/apply-patch/coordinator.ts +25 -7
- package/src/extensions/apply-patch/index.ts +2 -1
- package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
- package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/src/extensions/file-picker/catalog.ts +38 -33
- package/src/extensions/file-picker/filePickerWorker.ts +72 -0
- package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
- package/src/extensions/file-picker/index.ts +138 -83
- package/src/extensions/file-picker/ranker.ts +180 -12
- package/src/extensions/grep/grep.ts +45 -2
- package/src/extensions/web-search/ExaMcpClient.ts +20 -3
- package/src/shared/FileEnumerator.ts +492 -0
- package/src/shared/FileScanner.ts +15 -17
- package/src/shared/McpClient.ts +90 -28
- package/src/shared/RateLimiter.ts +50 -0
- package/src/telegram/Markdown.ts +82 -49
- package/src/telegram/Renderer.ts +37 -32
- package/src/telegram/SendFileTool.ts +5 -19
- package/src/telegram/Session.ts +1 -1
- package/src/shared/GitignoreFilter.ts +0 -142
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
<!-- omit in toc -->
|
|
2
2
|
# PIM - Pi IMproved
|
|
3
3
|
|
|
4
|
+
[](https://www.npmjs.com/package/@aaroncql/pim-agent)
|
|
5
|
+
[](https://www.npmjs.com/package/@aaroncql/pim-agent)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://bun.com)
|
|
8
|
+
|
|
4
9
|
_**Pim is to Pi what Vim is to Vi.**_
|
|
5
10
|
|
|
6
11
|
A Bun-native extension pack for [Pi](https://pi.dev/): web access, subagents, revamped core tools, ANSI-compatible themes, fzf-style completions, Telegram mode, and more. Preliminary score of [37.8% on Terminal-Bench 2.0](#terminal-bench-20) with locally hosted Qwen3.6-35B, rivalling Claude Code + Sonnet 4.5.
|
|
@@ -19,6 +24,7 @@ A Bun-native extension pack for [Pi](https://pi.dev/): web access, subagents, re
|
|
|
19
24
|
- [Setup](#setup)
|
|
20
25
|
- [Commands](#commands)
|
|
21
26
|
- [Features](#features)
|
|
27
|
+
- [Changelog](#changelog)
|
|
22
28
|
- [Developing](#developing)
|
|
23
29
|
|
|
24
30
|

|
|
@@ -226,10 +232,14 @@ For development, run standalone with `pim --mode telegram` instead.
|
|
|
226
232
|
|
|
227
233
|
- โฐ **Scheduled tasks** - your bot can create one-time, interval, or cron-based tasks that fire automatically; ask your bot to schedule something.
|
|
228
234
|
- ๐ **Live progress logs** - use `/logs` to choose what you see while the agent works: final replies, tool use, intermediate text, or thinking.
|
|
229
|
-
- ๐ **Markdown
|
|
235
|
+
- ๐ **Rich Markdown** - supports Telegram's [rich text formatting](https://telegram.org/blog/watch-apps-and-more#obscenely-rich-text-formatting-for-bots) with full markdown and LaTeX math support.
|
|
230
236
|
- ๐ **Rich media** - send photos, documents, videos, audio, and voice messages directly in chat; your bot can also send files back to you.
|
|
231
237
|
- ๐งต **Thread-specific prompts** - each chat (or thread) gets its own session and optional instructions; ask your bot to modify its instructions.
|
|
232
238
|
|
|
239
|
+
## Changelog
|
|
240
|
+
|
|
241
|
+
See [CHANGELOG.md](./CHANGELOG.md) for release notes.
|
|
242
|
+
|
|
233
243
|
## Developing
|
|
234
244
|
|
|
235
245
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaroncql/pim-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A Bun-native extension pack for Pi: web access, subagents, revamped core tools, ANSI-compatible themes, fzf-style completions, Telegram mode, and more.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,16 +48,16 @@
|
|
|
48
48
|
"@earendil-works/pi-coding-agent": "*"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
51
|
+
"@earendil-works/pi-coding-agent": "0.79.2",
|
|
52
52
|
"@types/bun": "latest",
|
|
53
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
54
|
-
"oxlint": "^1.
|
|
55
|
-
"prettier": "^3.8.
|
|
53
|
+
"@typescript/native-preview": "^7.0.0-dev.20260612.1",
|
|
54
|
+
"oxlint": "^1.69.0",
|
|
55
|
+
"prettier": "^3.8.4"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"diff": "^9.0.0",
|
|
59
59
|
"fzf": "^0.5.2",
|
|
60
|
-
"grammy": "^1.
|
|
60
|
+
"grammy": "^1.44.0",
|
|
61
61
|
"ignore": "^7.0.5",
|
|
62
62
|
"ky": "^2.0.2"
|
|
63
63
|
}
|
|
@@ -2,28 +2,46 @@ const EDIT_TOOL = "edit";
|
|
|
2
2
|
const APPLY_PATCH_TOOL = "apply_patch";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Pure reconcile over
|
|
6
|
-
* - If
|
|
7
|
-
* -
|
|
8
|
-
* model is GPT/Codex-family, else `edit`.
|
|
9
|
-
*
|
|
5
|
+
* Pure reconcile over available and active tool lists. Single-slot swap:
|
|
6
|
+
* - If exactly one of `edit` or `apply_patch` is available, use that tool.
|
|
7
|
+
* - If both are available, keep exactly one in the same position: `apply_patch`
|
|
8
|
+
* when the model is GPT/Codex-family, else `edit`.
|
|
9
|
+
* - If neither is active and availability does not force a choice, no-op
|
|
10
|
+
* (respect user opt-out). All other active tools are preserved in order.
|
|
10
11
|
*
|
|
11
12
|
* Returns the same array reference when nothing changes so callers can skip the
|
|
12
13
|
* prompt-rebuilding `setActiveTools` call.
|
|
13
14
|
*/
|
|
14
15
|
export function computeActiveTools(
|
|
16
|
+
available: readonly string[],
|
|
15
17
|
active: readonly string[],
|
|
16
18
|
isGpt: boolean
|
|
17
19
|
): readonly string[] {
|
|
18
20
|
const hasEdit = active.includes(EDIT_TOOL);
|
|
19
21
|
const hasApplyPatch = active.includes(APPLY_PATCH_TOOL);
|
|
22
|
+
const canEdit = available.includes(EDIT_TOOL);
|
|
23
|
+
const canApplyPatch = available.includes(APPLY_PATCH_TOOL);
|
|
20
24
|
|
|
21
25
|
if (!hasEdit && !hasApplyPatch) {
|
|
26
|
+
if (canEdit !== canApplyPatch) {
|
|
27
|
+
return [...active, canEdit ? EDIT_TOOL : APPLY_PATCH_TOOL];
|
|
28
|
+
}
|
|
29
|
+
return active;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!canEdit && !canApplyPatch) {
|
|
22
33
|
return active;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
const desired =
|
|
26
|
-
|
|
36
|
+
const desired =
|
|
37
|
+
canEdit && !canApplyPatch
|
|
38
|
+
? EDIT_TOOL
|
|
39
|
+
: canApplyPatch && !canEdit
|
|
40
|
+
? APPLY_PATCH_TOOL
|
|
41
|
+
: isGpt
|
|
42
|
+
? APPLY_PATCH_TOOL
|
|
43
|
+
: EDIT_TOOL;
|
|
44
|
+
const drop = desired === EDIT_TOOL ? APPLY_PATCH_TOOL : EDIT_TOOL;
|
|
27
45
|
|
|
28
46
|
if (active.includes(desired) && !active.includes(drop)) {
|
|
29
47
|
return active;
|
|
@@ -58,7 +58,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
58
58
|
|
|
59
59
|
const reconcile = (isGpt: boolean): void => {
|
|
60
60
|
const active = pi.getActiveTools();
|
|
61
|
-
const
|
|
61
|
+
const available = pi.getAllTools().map((tool) => tool.name);
|
|
62
|
+
const next = computeActiveTools(available, active, isGpt);
|
|
62
63
|
if (next !== active) {
|
|
63
64
|
pi.setActiveTools([...next]);
|
|
64
65
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export type RankFilePickerOptions = {
|
|
4
|
+
readonly limit?: number;
|
|
5
|
+
readonly signal?: AbortSignal;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type FilePickerSuggestionEngine = {
|
|
9
|
+
readonly refreshRelative: () => Promise<void>;
|
|
10
|
+
readonly rank: (
|
|
11
|
+
query: string,
|
|
12
|
+
options: RankFilePickerOptions
|
|
13
|
+
) => Promise<readonly AutocompleteItem[] | undefined>;
|
|
14
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { FileCandidate } from "./catalog";
|
|
3
|
+
import type {
|
|
4
|
+
FilePickerSuggestionEngine,
|
|
5
|
+
RankFilePickerOptions,
|
|
6
|
+
} from "./FilePickerSuggestionEngine";
|
|
7
|
+
import { rank } from "./ranker";
|
|
8
|
+
|
|
9
|
+
export type InProcessFilePickerSuggestionEngineOptions = {
|
|
10
|
+
readonly loadRelativeCatalog: () => Promise<readonly FileCandidate[]>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class InProcessFilePickerSuggestionEngine implements FilePickerSuggestionEngine {
|
|
14
|
+
private cachedRelative: readonly FileCandidate[] | undefined;
|
|
15
|
+
private refresh: Promise<void> | undefined;
|
|
16
|
+
|
|
17
|
+
public constructor(
|
|
18
|
+
private readonly options: InProcessFilePickerSuggestionEngineOptions
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
public refreshRelative(): Promise<void> {
|
|
22
|
+
this.refresh ??= this.options
|
|
23
|
+
.loadRelativeCatalog()
|
|
24
|
+
.then((catalog) => {
|
|
25
|
+
this.cachedRelative = catalog;
|
|
26
|
+
})
|
|
27
|
+
.catch(() => {
|
|
28
|
+
if (this.cachedRelative === undefined) {
|
|
29
|
+
this.cachedRelative = [];
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
this.refresh = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return this.refresh;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public async rank(
|
|
40
|
+
query: string,
|
|
41
|
+
options: RankFilePickerOptions
|
|
42
|
+
): Promise<readonly AutocompleteItem[] | undefined> {
|
|
43
|
+
if (options.signal?.aborted === true) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return rank(query, {
|
|
48
|
+
cachedRelative: this.cachedRelative,
|
|
49
|
+
limit: options.limit,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type {
|
|
3
|
+
FilePickerSuggestionEngine,
|
|
4
|
+
RankFilePickerOptions,
|
|
5
|
+
} from "./FilePickerSuggestionEngine";
|
|
6
|
+
import type {
|
|
7
|
+
FilePickerWorkerRequest,
|
|
8
|
+
FilePickerWorkerResponse,
|
|
9
|
+
} from "./filePickerWorkerMessages";
|
|
10
|
+
|
|
11
|
+
type PendingRefresh = {
|
|
12
|
+
readonly resolve: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RankRequest = {
|
|
16
|
+
readonly id: number;
|
|
17
|
+
readonly query: string;
|
|
18
|
+
readonly limit: number | undefined;
|
|
19
|
+
readonly resolve: (items: readonly AutocompleteItem[] | undefined) => void;
|
|
20
|
+
readonly reject: (error: unknown) => void;
|
|
21
|
+
readonly signal: AbortSignal | undefined;
|
|
22
|
+
readonly abortListener: (() => void) | undefined;
|
|
23
|
+
settled: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class WorkerFilePickerSuggestionEngine implements FilePickerSuggestionEngine {
|
|
27
|
+
private worker: Worker | undefined;
|
|
28
|
+
private nextRequestId = 0;
|
|
29
|
+
private refresh: Promise<void> | undefined;
|
|
30
|
+
private activeRank: RankRequest | undefined;
|
|
31
|
+
private queuedRank: RankRequest | undefined;
|
|
32
|
+
private readonly pendingRefreshes = new Map<number, PendingRefresh>();
|
|
33
|
+
|
|
34
|
+
public constructor(private readonly root: string) {}
|
|
35
|
+
|
|
36
|
+
public refreshRelative(): Promise<void> {
|
|
37
|
+
this.refresh ??= this.sendRefreshRelative().finally(() => {
|
|
38
|
+
this.refresh = undefined;
|
|
39
|
+
});
|
|
40
|
+
return this.refresh;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public rank(
|
|
44
|
+
query: string,
|
|
45
|
+
options: RankFilePickerOptions
|
|
46
|
+
): Promise<readonly AutocompleteItem[] | undefined> {
|
|
47
|
+
if (options.signal?.aborted === true) {
|
|
48
|
+
return Promise.resolve([]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const id = this.nextRequestId++;
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let request: RankRequest;
|
|
54
|
+
const abortListener = (): void => {
|
|
55
|
+
this.abortRank(request);
|
|
56
|
+
};
|
|
57
|
+
request = {
|
|
58
|
+
id,
|
|
59
|
+
query,
|
|
60
|
+
limit: options.limit,
|
|
61
|
+
resolve,
|
|
62
|
+
reject,
|
|
63
|
+
signal: options.signal,
|
|
64
|
+
abortListener: options.signal === undefined ? undefined : abortListener,
|
|
65
|
+
settled: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (options.signal !== undefined) {
|
|
69
|
+
options.signal.addEventListener("abort", abortListener, { once: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.enqueueRank(request);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public dispose(): void {
|
|
77
|
+
this.worker?.terminate();
|
|
78
|
+
this.worker = undefined;
|
|
79
|
+
this.failPending(new Error("file picker worker disposed"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private enqueueRank(request: RankRequest): void {
|
|
83
|
+
if (this.activeRank === undefined) {
|
|
84
|
+
this.sendRank(request);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.resolveRank(this.activeRank, []);
|
|
89
|
+
if (this.queuedRank !== undefined) {
|
|
90
|
+
this.resolveRank(this.queuedRank, []);
|
|
91
|
+
}
|
|
92
|
+
this.queuedRank = request;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private sendRank(request: RankRequest): void {
|
|
96
|
+
this.activeRank = request;
|
|
97
|
+
try {
|
|
98
|
+
this.post({
|
|
99
|
+
id: request.id,
|
|
100
|
+
type: "rank",
|
|
101
|
+
query: request.query,
|
|
102
|
+
limit: request.limit,
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (this.activeRank === request) {
|
|
106
|
+
this.activeRank = undefined;
|
|
107
|
+
}
|
|
108
|
+
this.rejectRank(request, error);
|
|
109
|
+
this.sendQueuedRank();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private sendQueuedRank(): void {
|
|
114
|
+
const request = this.queuedRank;
|
|
115
|
+
if (request === undefined || this.activeRank !== undefined) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.queuedRank = undefined;
|
|
120
|
+
if (request.settled) {
|
|
121
|
+
this.sendQueuedRank();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.sendRank(request);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private abortRank(request: RankRequest): void {
|
|
129
|
+
if (this.queuedRank === request) {
|
|
130
|
+
this.queuedRank = undefined;
|
|
131
|
+
}
|
|
132
|
+
this.resolveRank(request, []);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private resolveRank(
|
|
136
|
+
request: RankRequest,
|
|
137
|
+
items: readonly AutocompleteItem[] | undefined
|
|
138
|
+
): void {
|
|
139
|
+
if (request.settled) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
request.settled = true;
|
|
144
|
+
this.removeAbortListener(request);
|
|
145
|
+
request.resolve(items);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private rejectRank(request: RankRequest, error: unknown): void {
|
|
149
|
+
if (request.settled) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
request.settled = true;
|
|
154
|
+
this.removeAbortListener(request);
|
|
155
|
+
request.reject(error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private sendRefreshRelative(): Promise<void> {
|
|
159
|
+
const id = this.nextRequestId++;
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
this.pendingRefreshes.set(id, { resolve });
|
|
162
|
+
try {
|
|
163
|
+
this.post({
|
|
164
|
+
id,
|
|
165
|
+
type: "refreshRelative",
|
|
166
|
+
root: this.root,
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
this.pendingRefreshes.delete(id);
|
|
170
|
+
resolve();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private post(message: FilePickerWorkerRequest): void {
|
|
176
|
+
this.currentWorker().postMessage(message);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private currentWorker(): Worker {
|
|
180
|
+
if (this.worker !== undefined) {
|
|
181
|
+
return this.worker;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const worker = new Worker(
|
|
185
|
+
new URL("./filePickerWorker.ts", import.meta.url),
|
|
186
|
+
{
|
|
187
|
+
type: "module",
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
worker.onmessage = (
|
|
191
|
+
event: MessageEvent<FilePickerWorkerResponse>
|
|
192
|
+
): void => {
|
|
193
|
+
this.handleMessage(event.data);
|
|
194
|
+
};
|
|
195
|
+
worker.onerror = (event): void => {
|
|
196
|
+
this.worker = undefined;
|
|
197
|
+
worker.terminate();
|
|
198
|
+
this.failPending(new Error(event.message));
|
|
199
|
+
};
|
|
200
|
+
this.worker = worker;
|
|
201
|
+
return worker;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private handleMessage(message: FilePickerWorkerResponse): void {
|
|
205
|
+
switch (message.type) {
|
|
206
|
+
case "refreshRelative":
|
|
207
|
+
this.handleRefreshRelativeMessage(message);
|
|
208
|
+
break;
|
|
209
|
+
case "rank":
|
|
210
|
+
this.handleRankMessage(message);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private handleRefreshRelativeMessage(
|
|
216
|
+
message: Extract<
|
|
217
|
+
FilePickerWorkerResponse,
|
|
218
|
+
{ readonly type: "refreshRelative" }
|
|
219
|
+
>
|
|
220
|
+
): void {
|
|
221
|
+
const pending = this.pendingRefreshes.get(message.id);
|
|
222
|
+
if (pending === undefined) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.pendingRefreshes.delete(message.id);
|
|
227
|
+
pending.resolve();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private handleRankMessage(
|
|
231
|
+
message: Extract<FilePickerWorkerResponse, { readonly type: "rank" }>
|
|
232
|
+
): void {
|
|
233
|
+
const request = this.activeRank;
|
|
234
|
+
if (request === undefined || request.id !== message.id) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.activeRank = undefined;
|
|
239
|
+
if (message.ok) {
|
|
240
|
+
this.resolveRank(request, message.items);
|
|
241
|
+
} else {
|
|
242
|
+
this.rejectRank(request, new Error(message.error));
|
|
243
|
+
}
|
|
244
|
+
this.sendQueuedRank();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private failPending(error: unknown): void {
|
|
248
|
+
for (const [id, pending] of this.pendingRefreshes) {
|
|
249
|
+
this.pendingRefreshes.delete(id);
|
|
250
|
+
pending.resolve();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (this.activeRank !== undefined) {
|
|
254
|
+
this.rejectRank(this.activeRank, error);
|
|
255
|
+
this.activeRank = undefined;
|
|
256
|
+
}
|
|
257
|
+
if (this.queuedRank !== undefined) {
|
|
258
|
+
this.rejectRank(this.queuedRank, error);
|
|
259
|
+
this.queuedRank = undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private removeAbortListener(request: RankRequest): void {
|
|
264
|
+
if (request.signal !== undefined && request.abortListener !== undefined) {
|
|
265
|
+
request.signal.removeEventListener("abort", request.abortListener);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { isAbsolute, join, parse,
|
|
3
|
-
import {
|
|
2
|
+
import { isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
3
|
+
import { FileEnumerator } from "../../shared/FileEnumerator";
|
|
4
4
|
import { Paths } from "../../shared/Paths";
|
|
5
5
|
|
|
6
6
|
export type FileCandidate = {
|
|
@@ -27,7 +27,6 @@ export type GitSpawner = (
|
|
|
27
27
|
|
|
28
28
|
export type LoadRelativeOptions = {
|
|
29
29
|
readonly root: string;
|
|
30
|
-
readonly limit?: number;
|
|
31
30
|
readonly gitSpawner?: GitSpawner;
|
|
32
31
|
};
|
|
33
32
|
|
|
@@ -35,8 +34,6 @@ export type LoadAbsoluteOptions = {
|
|
|
35
34
|
readonly query: string;
|
|
36
35
|
};
|
|
37
36
|
|
|
38
|
-
const DEFAULT_LIMIT = 10_000;
|
|
39
|
-
|
|
40
37
|
const defaultGitSpawner: GitSpawner = async (args, options) => {
|
|
41
38
|
try {
|
|
42
39
|
const child = Bun.spawn(["git", ...args], {
|
|
@@ -126,16 +123,15 @@ async function runLoadRelative(
|
|
|
126
123
|
root: string,
|
|
127
124
|
options: LoadRelativeOptions
|
|
128
125
|
): Promise<readonly FileCandidate[]> {
|
|
129
|
-
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
130
126
|
const spawner = options.gitSpawner ?? defaultGitSpawner;
|
|
131
127
|
|
|
132
128
|
const fastPath = await tryGitListFiles(root, spawner);
|
|
133
129
|
if (fastPath !== undefined) {
|
|
134
|
-
return finalizeRelative(fastPath
|
|
130
|
+
return finalizeRelative(fastPath);
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
const fallback = await scanWithGlob(root);
|
|
138
|
-
return finalizeRelative(fallback
|
|
134
|
+
return finalizeRelative(fallback);
|
|
139
135
|
}
|
|
140
136
|
|
|
141
137
|
async function tryGitListFiles(
|
|
@@ -155,39 +151,48 @@ async function tryGitListFiles(
|
|
|
155
151
|
}
|
|
156
152
|
|
|
157
153
|
async function scanWithGlob(root: string): Promise<readonly string[]> {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
154
|
+
// FileEnumerator already returns ignore-respecting, root-relative POSIX file
|
|
155
|
+
// paths; directories are recovered from prefixes in finalizeRelative.
|
|
156
|
+
return FileEnumerator.enumerate(root, {
|
|
157
|
+
includeDotfiles: false,
|
|
158
|
+
includeIgnored: false,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function finalizeRelative(paths: readonly string[]): readonly FileCandidate[] {
|
|
163
|
+
const normalized = paths.map(Paths.toForwardSlashes);
|
|
164
|
+
|
|
165
|
+
// git ls-files only emits files; recover directories from their prefixes.
|
|
166
|
+
const directories = new Set<string>();
|
|
167
|
+
for (const path of normalized) {
|
|
168
|
+
for (
|
|
169
|
+
let slash = path.indexOf("/");
|
|
170
|
+
slash !== -1;
|
|
171
|
+
slash = path.indexOf("/", slash + 1)
|
|
172
|
+
) {
|
|
173
|
+
directories.add(path.slice(0, slash));
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
|
|
173
|
-
|
|
174
|
-
|
|
177
|
+
const candidates = [
|
|
178
|
+
...[...directories].map((path) => toRelativeCandidate(path, true)),
|
|
179
|
+
...normalized.map((path) => toRelativeCandidate(path, false)),
|
|
180
|
+
];
|
|
181
|
+
candidates.sort((a, b) => a.insertPath.localeCompare(b.insertPath));
|
|
175
182
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
limit: number
|
|
179
|
-
): readonly FileCandidate[] {
|
|
180
|
-
const normalized = paths.map(Paths.toForwardSlashes);
|
|
181
|
-
normalized.sort((a, b) => a.localeCompare(b));
|
|
183
|
+
return candidates;
|
|
184
|
+
}
|
|
182
185
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
function toRelativeCandidate(
|
|
187
|
+
path: string,
|
|
188
|
+
isDirectory: boolean
|
|
189
|
+
): FileCandidate {
|
|
190
|
+
return {
|
|
186
191
|
insertPath: path,
|
|
187
192
|
displayPath: path,
|
|
188
193
|
matchHaystack: path,
|
|
189
|
-
isDirectory
|
|
190
|
-
}
|
|
194
|
+
isDirectory,
|
|
195
|
+
};
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
async function findAnchor(absolutePath: string): Promise<string> {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { FileCandidate } from "./catalog";
|
|
2
|
+
import { loadRelative } from "./catalog";
|
|
3
|
+
import type {
|
|
4
|
+
FilePickerWorkerRequest,
|
|
5
|
+
FilePickerWorkerResponse,
|
|
6
|
+
} from "./filePickerWorkerMessages";
|
|
7
|
+
import { rank } from "./ranker";
|
|
8
|
+
|
|
9
|
+
let cachedRelative: readonly FileCandidate[] | undefined;
|
|
10
|
+
|
|
11
|
+
const toErrorMessage = (error: unknown): string => {
|
|
12
|
+
if (error instanceof Error) {
|
|
13
|
+
return error.message;
|
|
14
|
+
}
|
|
15
|
+
return String(error);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const refreshRelative = async (id: number, root: string): Promise<void> => {
|
|
19
|
+
try {
|
|
20
|
+
cachedRelative = await loadRelative({ root });
|
|
21
|
+
self.postMessage({
|
|
22
|
+
id,
|
|
23
|
+
type: "refreshRelative",
|
|
24
|
+
ok: true,
|
|
25
|
+
} satisfies FilePickerWorkerResponse);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (cachedRelative === undefined) {
|
|
28
|
+
cachedRelative = [];
|
|
29
|
+
}
|
|
30
|
+
self.postMessage({
|
|
31
|
+
id,
|
|
32
|
+
type: "refreshRelative",
|
|
33
|
+
ok: false,
|
|
34
|
+
error: toErrorMessage(error),
|
|
35
|
+
} satisfies FilePickerWorkerResponse);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const rankSuggestions = async (
|
|
40
|
+
id: number,
|
|
41
|
+
query: string,
|
|
42
|
+
limit: number | undefined
|
|
43
|
+
): Promise<void> => {
|
|
44
|
+
try {
|
|
45
|
+
const items = await rank(query, { cachedRelative, limit });
|
|
46
|
+
self.postMessage({
|
|
47
|
+
id,
|
|
48
|
+
type: "rank",
|
|
49
|
+
ok: true,
|
|
50
|
+
items,
|
|
51
|
+
} satisfies FilePickerWorkerResponse);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
self.postMessage({
|
|
54
|
+
id,
|
|
55
|
+
type: "rank",
|
|
56
|
+
ok: false,
|
|
57
|
+
error: toErrorMessage(error),
|
|
58
|
+
} satisfies FilePickerWorkerResponse);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
self.onmessage = (event: MessageEvent<FilePickerWorkerRequest>): void => {
|
|
63
|
+
const message = event.data;
|
|
64
|
+
switch (message.type) {
|
|
65
|
+
case "refreshRelative":
|
|
66
|
+
void refreshRelative(message.id, message.root);
|
|
67
|
+
break;
|
|
68
|
+
case "rank":
|
|
69
|
+
void rankSuggestions(message.id, message.query, message.limit);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
};
|