@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,168 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { FileCandidate } from "./catalog";
|
|
7
|
+
import { createFilePickerProviderFactory } from "./index";
|
|
8
|
+
|
|
9
|
+
const file = (path: string): FileCandidate => ({
|
|
10
|
+
insertPath: path,
|
|
11
|
+
displayPath: path,
|
|
12
|
+
matchHaystack: path,
|
|
13
|
+
isDirectory: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const currentProvider: AutocompleteProvider = {
|
|
17
|
+
async getSuggestions() {
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
applyCompletion(lines, cursorLine, cursorCol) {
|
|
22
|
+
return { lines, cursorLine, cursorCol };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const autocompleteOptions = (): { readonly signal: AbortSignal } => ({
|
|
27
|
+
signal: new AbortController().signal,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const flushPromises = async (): Promise<void> => {
|
|
31
|
+
await Promise.resolve();
|
|
32
|
+
await Promise.resolve();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
test("relative @ autocomplete refreshes in the background after using the session cache", async () => {
|
|
36
|
+
let catalog: readonly FileCandidate[] = [file("old.ts")];
|
|
37
|
+
let loads = 0;
|
|
38
|
+
const factory = createFilePickerProviderFactory({
|
|
39
|
+
loadRelativeCatalog: async () => {
|
|
40
|
+
loads += 1;
|
|
41
|
+
return catalog;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const provider = factory(currentProvider);
|
|
45
|
+
|
|
46
|
+
await flushPromises();
|
|
47
|
+
expect(loads).toBe(1);
|
|
48
|
+
|
|
49
|
+
catalog = [file("new.ts")];
|
|
50
|
+
const stale = await provider.getSuggestions(
|
|
51
|
+
["@new"],
|
|
52
|
+
0,
|
|
53
|
+
4,
|
|
54
|
+
autocompleteOptions()
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(stale).toBeNull();
|
|
58
|
+
expect(loads).toBe(2);
|
|
59
|
+
|
|
60
|
+
await flushPromises();
|
|
61
|
+
const fresh = await provider.getSuggestions(
|
|
62
|
+
["@new"],
|
|
63
|
+
0,
|
|
64
|
+
4,
|
|
65
|
+
autocompleteOptions()
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(fresh?.items.map((item) => item.value)).toContain("@new.ts");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("relative catalog cache survives provider rebuilds", async () => {
|
|
72
|
+
let catalog: readonly FileCandidate[] = [file("old.ts")];
|
|
73
|
+
const factory = createFilePickerProviderFactory({
|
|
74
|
+
loadRelativeCatalog: async () => catalog,
|
|
75
|
+
});
|
|
76
|
+
const firstProvider = factory(currentProvider);
|
|
77
|
+
|
|
78
|
+
await flushPromises();
|
|
79
|
+
catalog = [file("new.ts")];
|
|
80
|
+
await firstProvider.getSuggestions(["@new"], 0, 4, autocompleteOptions());
|
|
81
|
+
await flushPromises();
|
|
82
|
+
|
|
83
|
+
const rebuiltProvider = factory(currentProvider);
|
|
84
|
+
const fresh = await rebuiltProvider.getSuggestions(
|
|
85
|
+
["@new"],
|
|
86
|
+
0,
|
|
87
|
+
4,
|
|
88
|
+
autocompleteOptions()
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(fresh?.items.map((item) => item.value)).toContain("@new.ts");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("relative catalog refreshes are coalesced", async () => {
|
|
95
|
+
let resolveLoad: ((catalog: readonly FileCandidate[]) => void) | undefined;
|
|
96
|
+
let loads = 0;
|
|
97
|
+
const factory = createFilePickerProviderFactory({
|
|
98
|
+
loadRelativeCatalog: () => {
|
|
99
|
+
loads += 1;
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
resolveLoad = resolve;
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const provider = factory(currentProvider);
|
|
106
|
+
|
|
107
|
+
await provider.getSuggestions(["@a"], 0, 2, autocompleteOptions());
|
|
108
|
+
await provider.getSuggestions(["@ab"], 0, 3, autocompleteOptions());
|
|
109
|
+
|
|
110
|
+
expect(loads).toBe(1);
|
|
111
|
+
resolveLoad?.([file("ab.ts")]);
|
|
112
|
+
await flushPromises();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("refresh failure preserves the last good relative cache", async () => {
|
|
116
|
+
let shouldFail = false;
|
|
117
|
+
const factory = createFilePickerProviderFactory({
|
|
118
|
+
loadRelativeCatalog: async () => {
|
|
119
|
+
if (shouldFail) {
|
|
120
|
+
throw new Error("boom");
|
|
121
|
+
}
|
|
122
|
+
return [file("old.ts")];
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const provider = factory(currentProvider);
|
|
126
|
+
|
|
127
|
+
await flushPromises();
|
|
128
|
+
shouldFail = true;
|
|
129
|
+
await provider.getSuggestions(["@old"], 0, 4, autocompleteOptions());
|
|
130
|
+
await flushPromises();
|
|
131
|
+
|
|
132
|
+
const result = await provider.getSuggestions(
|
|
133
|
+
["@old"],
|
|
134
|
+
0,
|
|
135
|
+
4,
|
|
136
|
+
autocompleteOptions()
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(result?.items.map((item) => item.value)).toContain("@old.ts");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("absolute @ autocomplete also refreshes the relative catalog", async () => {
|
|
143
|
+
const workspace = await mkdtemp(join(tmpdir(), "pim-file-picker-absolute-"));
|
|
144
|
+
try {
|
|
145
|
+
let loads = 0;
|
|
146
|
+
const factory = createFilePickerProviderFactory({
|
|
147
|
+
loadRelativeCatalog: async () => {
|
|
148
|
+
loads += 1;
|
|
149
|
+
return [file("old.ts")];
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const provider = factory(currentProvider);
|
|
153
|
+
|
|
154
|
+
await flushPromises();
|
|
155
|
+
expect(loads).toBe(1);
|
|
156
|
+
|
|
157
|
+
await provider.getSuggestions(
|
|
158
|
+
[`@${workspace}`],
|
|
159
|
+
0,
|
|
160
|
+
workspace.length + 1,
|
|
161
|
+
autocompleteOptions()
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(loads).toBe(2);
|
|
165
|
+
} finally {
|
|
166
|
+
await rm(workspace, { force: true, recursive: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutocompleteProviderFactory,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
6
|
+
import { type FileCandidate, loadRelative } from "./catalog";
|
|
7
|
+
import { rank } from "./ranker";
|
|
8
|
+
|
|
9
|
+
const MAX_VISIBLE_ROWS = 50;
|
|
10
|
+
const AT_PREFIX = /(?:^|\s)@(\S*)$/;
|
|
11
|
+
|
|
12
|
+
export type FilePickerProviderFactoryOptions = {
|
|
13
|
+
readonly loadRelativeCatalog: () => Promise<readonly FileCandidate[]>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createFilePickerProviderFactory(
|
|
17
|
+
options: FilePickerProviderFactoryOptions
|
|
18
|
+
): AutocompleteProviderFactory {
|
|
19
|
+
let cachedRelative: readonly FileCandidate[] | undefined;
|
|
20
|
+
let relativeRefresh: Promise<void> | undefined;
|
|
21
|
+
|
|
22
|
+
const refreshRelative = (): void => {
|
|
23
|
+
relativeRefresh ??= options
|
|
24
|
+
.loadRelativeCatalog()
|
|
25
|
+
.then((catalog) => {
|
|
26
|
+
cachedRelative = catalog;
|
|
27
|
+
})
|
|
28
|
+
.catch(() => {
|
|
29
|
+
if (cachedRelative === undefined) {
|
|
30
|
+
cachedRelative = [];
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.finally(() => {
|
|
34
|
+
relativeRefresh = undefined;
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
refreshRelative();
|
|
39
|
+
|
|
40
|
+
return (current: AutocompleteProvider): AutocompleteProvider => ({
|
|
41
|
+
async getSuggestions(lines, cursorLine, cursorCol, autocompleteOptions) {
|
|
42
|
+
const line = lines[cursorLine] ?? "";
|
|
43
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
44
|
+
|
|
45
|
+
const atMatch = beforeCursor.match(AT_PREFIX);
|
|
46
|
+
if (!atMatch) {
|
|
47
|
+
return current.getSuggestions(
|
|
48
|
+
lines,
|
|
49
|
+
cursorLine,
|
|
50
|
+
cursorCol,
|
|
51
|
+
autocompleteOptions
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const query = atMatch[1] ?? "";
|
|
56
|
+
refreshRelative();
|
|
57
|
+
|
|
58
|
+
const items = await rank(query, {
|
|
59
|
+
cachedRelative,
|
|
60
|
+
limit: MAX_VISIBLE_ROWS,
|
|
61
|
+
});
|
|
62
|
+
if (items === undefined) {
|
|
63
|
+
return current.getSuggestions(
|
|
64
|
+
lines,
|
|
65
|
+
cursorLine,
|
|
66
|
+
cursorCol,
|
|
67
|
+
autocompleteOptions
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (items.length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
items: items.map((item) => ({
|
|
75
|
+
...item,
|
|
76
|
+
value: `@${item.value}`,
|
|
77
|
+
})),
|
|
78
|
+
prefix: `@${query}`,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
83
|
+
const result = current.applyCompletion(
|
|
84
|
+
lines,
|
|
85
|
+
cursorLine,
|
|
86
|
+
cursorCol,
|
|
87
|
+
item,
|
|
88
|
+
prefix
|
|
89
|
+
);
|
|
90
|
+
// Pi cancels autocomplete after Tab; for directories we want to keep
|
|
91
|
+
// drilling, so re-enter Tab on the next tick.
|
|
92
|
+
if (typeof item.value === "string" && item.value.endsWith("/")) {
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
try {
|
|
95
|
+
process.stdin.emit("data", "\t");
|
|
96
|
+
} catch {}
|
|
97
|
+
}, 0);
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
103
|
+
return (
|
|
104
|
+
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
|
|
105
|
+
true
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default function (pi: ExtensionAPI): void {
|
|
112
|
+
pi.on("session_start", (_event, ctx) => {
|
|
113
|
+
ctx.ui.addAutocompleteProvider(
|
|
114
|
+
createFilePickerProviderFactory({
|
|
115
|
+
loadRelativeCatalog: () => loadRelative({ root: ctx.cwd }),
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { FileCandidate } from "./catalog";
|
|
6
|
+
import { rank } from "./ranker";
|
|
7
|
+
|
|
8
|
+
let workspace: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
workspace = await mkdtemp(join(tmpdir(), "pim-file-ranker-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(workspace, { force: true, recursive: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const file = (path: string): FileCandidate => ({
|
|
19
|
+
insertPath: path,
|
|
20
|
+
displayPath: path,
|
|
21
|
+
matchHaystack: path,
|
|
22
|
+
isDirectory: false,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("relative query with no cache returns undefined (fallback)", async () => {
|
|
26
|
+
const result = await rank("foo", { cachedRelative: undefined });
|
|
27
|
+
|
|
28
|
+
expect(result).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("relative query with cache fuzzy-ranks the cached catalog", async () => {
|
|
32
|
+
const cachedRelative: readonly FileCandidate[] = [
|
|
33
|
+
file("src/util/log.ts"),
|
|
34
|
+
file("src/util/clock.ts"),
|
|
35
|
+
file("README.md"),
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const result = await rank("clk", { cachedRelative });
|
|
39
|
+
|
|
40
|
+
expect(result).toBeDefined();
|
|
41
|
+
expect(result?.[0]?.value).toBe("src/util/clock.ts");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("empty relative query returns cached catalog in given order", async () => {
|
|
45
|
+
const cachedRelative: readonly FileCandidate[] = [
|
|
46
|
+
file("a.ts"),
|
|
47
|
+
file("b.ts"),
|
|
48
|
+
file("c.ts"),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const result = await rank("", {
|
|
52
|
+
cachedRelative,
|
|
53
|
+
limit: 2,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result?.map((item) => item.value)).toEqual(["a.ts", "b.ts"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("absolute query lists the resolved directory", async () => {
|
|
60
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
61
|
+
await writeFile(join(workspace, "sub", "alpha.ts"), "a");
|
|
62
|
+
await writeFile(join(workspace, "sub", "beta.ts"), "b");
|
|
63
|
+
|
|
64
|
+
const result = await rank(`${join(workspace, "sub")}/`, {
|
|
65
|
+
cachedRelative: undefined,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result?.map((item) => item.label)).toEqual(["alpha.ts", "beta.ts"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("absolute query with residual fuzzy-ranks within the directory", async () => {
|
|
72
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
73
|
+
await writeFile(join(workspace, "sub", "alpha.ts"), "a");
|
|
74
|
+
await writeFile(join(workspace, "sub", "beta.ts"), "b");
|
|
75
|
+
|
|
76
|
+
const result = await rank(`${join(workspace, "sub")}/be`, {
|
|
77
|
+
cachedRelative: undefined,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result?.[0]?.label).toBe("beta.ts");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("directory candidates carry trailing slash in value and label", async () => {
|
|
84
|
+
await mkdir(join(workspace, "sub"), { recursive: true });
|
|
85
|
+
await mkdir(join(workspace, "sub", "child"), { recursive: true });
|
|
86
|
+
|
|
87
|
+
const result = await rank(`${join(workspace, "sub")}/`, {
|
|
88
|
+
cachedRelative: undefined,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const child = result?.find((item) => item.label === "child/");
|
|
92
|
+
expect(child).toBeDefined();
|
|
93
|
+
expect(child?.value.endsWith("/child/")).toBe(true);
|
|
94
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
FuzzyMatcher,
|
|
5
|
+
type FuzzyCandidate,
|
|
6
|
+
type FuzzyIndex,
|
|
7
|
+
} from "../../shared/FuzzyMatcher";
|
|
8
|
+
import { type FileCandidate, loadAbsolute } from "./catalog";
|
|
9
|
+
|
|
10
|
+
export type FileRankOptions = {
|
|
11
|
+
readonly cachedRelative: readonly FileCandidate[] | undefined;
|
|
12
|
+
readonly limit?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const isAbsoluteQuery = (query: string): boolean =>
|
|
16
|
+
query.startsWith("/") || query.startsWith("~");
|
|
17
|
+
|
|
18
|
+
let cachedIndex:
|
|
19
|
+
| {
|
|
20
|
+
readonly source: readonly FileCandidate[];
|
|
21
|
+
readonly index: FuzzyIndex<FileCandidate>;
|
|
22
|
+
}
|
|
23
|
+
| undefined;
|
|
24
|
+
|
|
25
|
+
const indexFor = (
|
|
26
|
+
candidates: readonly FileCandidate[]
|
|
27
|
+
): FuzzyIndex<FileCandidate> => {
|
|
28
|
+
if (cachedIndex?.source === candidates) {
|
|
29
|
+
return cachedIndex.index;
|
|
30
|
+
}
|
|
31
|
+
const fuzzy: FuzzyCandidate<FileCandidate>[] = candidates.map(
|
|
32
|
+
(candidate) => ({
|
|
33
|
+
item: candidate,
|
|
34
|
+
haystacks: [candidate.matchHaystack],
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
const index = FuzzyMatcher.prepare(fuzzy);
|
|
38
|
+
cachedIndex = { source: candidates, index };
|
|
39
|
+
return index;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export async function rank(
|
|
43
|
+
query: string,
|
|
44
|
+
options: FileRankOptions
|
|
45
|
+
): Promise<AutocompleteItem[] | undefined> {
|
|
46
|
+
if (isAbsoluteQuery(query)) {
|
|
47
|
+
const { candidates, residualQuery } = await loadAbsolute({ query });
|
|
48
|
+
return rankCandidates(candidates, residualQuery, options.limit);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (options.cachedRelative === undefined) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return rankCandidates(options.cachedRelative, query, options.limit);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rankCandidates = (
|
|
59
|
+
candidates: readonly FileCandidate[],
|
|
60
|
+
query: string,
|
|
61
|
+
limit: number | undefined
|
|
62
|
+
): AutocompleteItem[] => {
|
|
63
|
+
const hits = indexFor(candidates).find(query, { limit });
|
|
64
|
+
return hits.map((hit) => toItem(hit.item));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const toItem = (candidate: FileCandidate): AutocompleteItem => {
|
|
68
|
+
const suffix = candidate.isDirectory ? "/" : "";
|
|
69
|
+
const value = `${candidate.insertPath}${suffix}`;
|
|
70
|
+
const label = `${basename(candidate.insertPath)}${suffix}`;
|
|
71
|
+
return {
|
|
72
|
+
value,
|
|
73
|
+
label,
|
|
74
|
+
description: `${candidate.displayPath}${suffix}`,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { EMPTY_GIT, fetchGitStatus, parseGitStatus } from "./git";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
const tempRoot = async (): Promise<string> => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "pim-footer-git-"));
|
|
11
|
+
tempRoots.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await Promise.all(
|
|
17
|
+
tempRoots.map((root) => rm(root, { force: true, recursive: true }))
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("parseGitStatus", () => {
|
|
22
|
+
test("parses clean branch status", () => {
|
|
23
|
+
expect(
|
|
24
|
+
parseGitStatus(
|
|
25
|
+
[
|
|
26
|
+
"# branch.oid 123456",
|
|
27
|
+
"# branch.head main",
|
|
28
|
+
"# branch.upstream origin/main",
|
|
29
|
+
"# branch.ab +0 -0",
|
|
30
|
+
].join("\n")
|
|
31
|
+
)
|
|
32
|
+
).toEqual({
|
|
33
|
+
branch: "main",
|
|
34
|
+
dirty: false,
|
|
35
|
+
ahead: 0,
|
|
36
|
+
behind: 0,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("parses dirty state and ahead/behind counts", () => {
|
|
41
|
+
expect(
|
|
42
|
+
parseGitStatus(
|
|
43
|
+
[
|
|
44
|
+
"# branch.oid 123456",
|
|
45
|
+
"# branch.head feature/footer",
|
|
46
|
+
"# branch.upstream origin/feature/footer",
|
|
47
|
+
"# branch.ab +12 -3",
|
|
48
|
+
"1 .M N... 100644 100644 100644 abc abc src/file.ts",
|
|
49
|
+
"? scratch.txt",
|
|
50
|
+
].join("\n")
|
|
51
|
+
)
|
|
52
|
+
).toEqual({
|
|
53
|
+
branch: "feature/footer",
|
|
54
|
+
dirty: true,
|
|
55
|
+
ahead: 12,
|
|
56
|
+
behind: 3,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("labels detached heads explicitly", () => {
|
|
61
|
+
expect(parseGitStatus("# branch.head (detached)\n")).toEqual({
|
|
62
|
+
branch: "detached",
|
|
63
|
+
dirty: false,
|
|
64
|
+
ahead: 0,
|
|
65
|
+
behind: 0,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("fetchGitStatus", () => {
|
|
71
|
+
test("returns empty git state outside a git repository", async () => {
|
|
72
|
+
const root = await tempRoot();
|
|
73
|
+
|
|
74
|
+
expect(await fetchGitStatus(root)).toEqual(EMPTY_GIT);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type GitState = {
|
|
5
|
+
readonly branch: string | null;
|
|
6
|
+
readonly dirty: boolean;
|
|
7
|
+
readonly ahead: number;
|
|
8
|
+
readonly behind: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const EMPTY_GIT: GitState = {
|
|
12
|
+
branch: null,
|
|
13
|
+
dirty: false,
|
|
14
|
+
ahead: 0,
|
|
15
|
+
behind: 0,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function parseGitStatus(text: string): GitState {
|
|
19
|
+
let branch: string | null = null;
|
|
20
|
+
let ahead = 0;
|
|
21
|
+
let behind = 0;
|
|
22
|
+
let dirty = false;
|
|
23
|
+
for (const line of text.split("\n")) {
|
|
24
|
+
if (line.startsWith("# branch.head ")) {
|
|
25
|
+
const head = line.slice("# branch.head ".length);
|
|
26
|
+
branch = head === "(detached)" ? "detached" : head;
|
|
27
|
+
} else if (line.startsWith("# branch.ab ")) {
|
|
28
|
+
const m = /\+(\d+)\s+-(\d+)/.exec(line);
|
|
29
|
+
if (m) {
|
|
30
|
+
ahead = Number(m[1]);
|
|
31
|
+
behind = Number(m[2]);
|
|
32
|
+
}
|
|
33
|
+
} else if (line.length > 0 && !line.startsWith("#")) {
|
|
34
|
+
dirty = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { branch, dirty, ahead, behind };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function watchGitDir(cwd: string, onChange: () => void): () => void {
|
|
41
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
42
|
+
const fire = (): void => {
|
|
43
|
+
if (timer !== null) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
timer = setTimeout(() => {
|
|
47
|
+
timer = null;
|
|
48
|
+
onChange();
|
|
49
|
+
}, 200);
|
|
50
|
+
};
|
|
51
|
+
let watcher: FSWatcher | null = null;
|
|
52
|
+
try {
|
|
53
|
+
watcher = watch(join(cwd, ".git"), { persistent: false }, fire);
|
|
54
|
+
watcher.on("error", () => {});
|
|
55
|
+
} catch {
|
|
56
|
+
// not a git repo, .git missing, or .git is a worktree gitfile — skip
|
|
57
|
+
}
|
|
58
|
+
return (): void => {
|
|
59
|
+
if (timer !== null) {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = null;
|
|
62
|
+
}
|
|
63
|
+
if (watcher !== null) {
|
|
64
|
+
try {
|
|
65
|
+
watcher.close();
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function fetchGitStatus(cwd: string): Promise<GitState> {
|
|
72
|
+
try {
|
|
73
|
+
const proc = Bun.spawn(["git", "status", "--porcelain=v2", "--branch"], {
|
|
74
|
+
cwd,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
});
|
|
78
|
+
const text = await new Response(proc.stdout).text();
|
|
79
|
+
await proc.exited;
|
|
80
|
+
if (proc.exitCode !== 0) {
|
|
81
|
+
return EMPTY_GIT;
|
|
82
|
+
}
|
|
83
|
+
return parseGitStatus(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return EMPTY_GIT;
|
|
86
|
+
}
|
|
87
|
+
}
|