@narumitw/pi-files 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
- package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
- package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
- package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
- package/dist/chunks/chunk-F5QFC255.ts +101 -0
- package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
- package/dist/chunks/chunk-NFNGJETD.ts +77 -0
- package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
- package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
- package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
- package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
- package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
- package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
- package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
- package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
- package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
- package/dist/chunks/menu-P6IBMROH.ts +876 -0
- package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
- package/dist/index.ts +1789 -0
- package/dist/index.ts.map +7 -0
- package/docs/edit-scope-reference.md +142 -0
- package/docs/file-context-reference.md +91 -0
- package/docs/migration.md +105 -0
- package/docs/verification.md +99 -0
- package/package.json +58 -4
- package/src/content-search-session.ts +204 -0
- package/src/content-search-ui.ts +159 -0
- package/src/content-search.ts +170 -0
- package/src/context-service.ts +515 -0
- package/src/external-editor.ts +143 -0
- package/src/file-browser-ui.ts +128 -0
- package/src/file-browser.ts +76 -0
- package/src/file-context-explorer.ts +992 -0
- package/src/file-context-menu.ts +360 -0
- package/src/file-context-preview-ui.ts +354 -0
- package/src/file-context-settings.ts +301 -0
- package/src/file-context.ts +321 -0
- package/src/file-search.ts +246 -0
- package/src/files-menu.ts +125 -0
- package/src/files-settings.ts +314 -0
- package/src/files-widget.ts +65 -0
- package/src/files.ts +148 -0
- package/src/git-context.ts +485 -0
- package/src/index.ts +1 -0
- package/src/scope/contracts.ts +243 -0
- package/src/scope/display.ts +27 -0
- package/src/scope/edit-scope.ts +435 -0
- package/src/scope/files.ts +118 -0
- package/src/scope/input.ts +207 -0
- package/src/scope/menu.ts +277 -0
- package/src/scope/paths.ts +142 -0
- package/src/scope/picker-keys.ts +154 -0
- package/src/scope/picker.ts +484 -0
- package/src/scope/policy.ts +55 -0
- package/src/scope/settings.ts +138 -0
- package/src/scope/state.ts +91 -0
- package/src/scope/ui.ts +106 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const MAX_CONTENT_QUERY_LENGTH = 256;
|
|
2
|
+
const DEFAULT_MAX_RESULTS = 100;
|
|
3
|
+
|
|
4
|
+
export interface ContentMatchRange {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ContentSearchMatch {
|
|
10
|
+
path: string;
|
|
11
|
+
lineNumber: number;
|
|
12
|
+
line: string;
|
|
13
|
+
ranges: ContentMatchRange[];
|
|
14
|
+
fuzzy: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContentSearchResult {
|
|
18
|
+
matches: ContentSearchMatch[];
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
skippedFiles: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ContentSearchOptions {
|
|
24
|
+
caseSensitive?: boolean;
|
|
25
|
+
fuzzy?: boolean;
|
|
26
|
+
maxResults?: number;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SearchableTextFile {
|
|
31
|
+
path: string;
|
|
32
|
+
lines: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type IndexedCharacter = { start: number; end: number; comparable: string };
|
|
36
|
+
|
|
37
|
+
type LoadSearchableFile = (path: string, signal?: AbortSignal) => Promise<SearchableTextFile>;
|
|
38
|
+
|
|
39
|
+
export async function searchProjectContents(
|
|
40
|
+
files: readonly string[],
|
|
41
|
+
loadFile: LoadSearchableFile,
|
|
42
|
+
query: string,
|
|
43
|
+
options: ContentSearchOptions = {},
|
|
44
|
+
): Promise<ContentSearchResult> {
|
|
45
|
+
const signal = options.signal;
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
if (!query.trim() || query.length > MAX_CONTENT_QUERY_LENGTH) {
|
|
48
|
+
return { matches: [], truncated: false, skippedFiles: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const maxResults = Math.max(1, options.maxResults ?? DEFAULT_MAX_RESULTS);
|
|
52
|
+
const caseSensitive = options.caseSensitive ?? false;
|
|
53
|
+
const literalExpression = new RegExp(escapeRegExp(query), caseSensitive ? "g" : "gi");
|
|
54
|
+
const fuzzyQuery = options.fuzzy ? indexedCharacters(query, caseSensitive) : [];
|
|
55
|
+
const matches: ContentSearchMatch[] = [];
|
|
56
|
+
let truncated = false;
|
|
57
|
+
let skippedFiles = 0;
|
|
58
|
+
|
|
59
|
+
for (const path of files) {
|
|
60
|
+
signal?.throwIfAborted();
|
|
61
|
+
let file: SearchableTextFile;
|
|
62
|
+
try {
|
|
63
|
+
file = await loadFile(path, signal);
|
|
64
|
+
} catch (error: unknown) {
|
|
65
|
+
if (isAbortError(error) || signal?.aborted) throw error;
|
|
66
|
+
skippedFiles += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
signal?.throwIfAborted();
|
|
70
|
+
|
|
71
|
+
for (let lineIndex = 0; lineIndex < file.lines.length; lineIndex += 1) {
|
|
72
|
+
const line = file.lines[lineIndex] ?? "";
|
|
73
|
+
const literalRanges = findLiteralRanges(line, literalExpression);
|
|
74
|
+
if (literalRanges.length > 0) {
|
|
75
|
+
if (matches.length < maxResults) {
|
|
76
|
+
matches.push({
|
|
77
|
+
path: file.path,
|
|
78
|
+
lineNumber: lineIndex + 1,
|
|
79
|
+
line,
|
|
80
|
+
ranges: literalRanges,
|
|
81
|
+
fuzzy: false,
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
truncated = true;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!options.fuzzy) continue;
|
|
90
|
+
const fuzzyRanges = findSubsequenceRanges(line, fuzzyQuery, caseSensitive);
|
|
91
|
+
if (!fuzzyRanges) continue;
|
|
92
|
+
if (matches.length < maxResults) {
|
|
93
|
+
matches.push({
|
|
94
|
+
path: file.path,
|
|
95
|
+
lineNumber: lineIndex + 1,
|
|
96
|
+
line,
|
|
97
|
+
ranges: fuzzyRanges,
|
|
98
|
+
fuzzy: true,
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
truncated = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { matches, truncated, skippedFiles };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findLiteralRanges(line: string, expression: RegExp): ContentMatchRange[] {
|
|
110
|
+
const ranges: ContentMatchRange[] = [];
|
|
111
|
+
expression.lastIndex = 0;
|
|
112
|
+
for (let match = expression.exec(line); match; match = expression.exec(line)) {
|
|
113
|
+
const start = match.index;
|
|
114
|
+
ranges.push({ start, end: start + match[0].length });
|
|
115
|
+
}
|
|
116
|
+
return ranges;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findSubsequenceRanges(
|
|
120
|
+
line: string,
|
|
121
|
+
queryCharacters: readonly IndexedCharacter[],
|
|
122
|
+
caseSensitive: boolean,
|
|
123
|
+
): ContentMatchRange[] | undefined {
|
|
124
|
+
const lineCharacters = indexedCharacters(line, caseSensitive);
|
|
125
|
+
const positions: Array<{ start: number; end: number }> = [];
|
|
126
|
+
let queryIndex = 0;
|
|
127
|
+
for (let lineIndex = 0; lineIndex < lineCharacters.length && queryIndex < queryCharacters.length; lineIndex += 1) {
|
|
128
|
+
const lineCharacter = lineCharacters[lineIndex];
|
|
129
|
+
const queryCharacter = queryCharacters[queryIndex];
|
|
130
|
+
if (!lineCharacter || !queryCharacter || lineCharacter.comparable !== queryCharacter.comparable) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
positions.push({ start: lineCharacter.start, end: lineCharacter.end });
|
|
134
|
+
queryIndex += 1;
|
|
135
|
+
}
|
|
136
|
+
if (queryIndex !== queryCharacters.length) return undefined;
|
|
137
|
+
|
|
138
|
+
const ranges: ContentMatchRange[] = [];
|
|
139
|
+
for (const position of positions) {
|
|
140
|
+
const previous = ranges.at(-1);
|
|
141
|
+
if (previous?.end === position.start) previous.end = position.end;
|
|
142
|
+
else ranges.push(position);
|
|
143
|
+
}
|
|
144
|
+
return ranges;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function indexedCharacters(value: string, caseSensitive: boolean): IndexedCharacter[] {
|
|
148
|
+
const characters: IndexedCharacter[] = [];
|
|
149
|
+
for (let start = 0; start < value.length; ) {
|
|
150
|
+
const codePoint = value.codePointAt(start);
|
|
151
|
+
if (codePoint === undefined) break;
|
|
152
|
+
const character = String.fromCodePoint(codePoint);
|
|
153
|
+
const end = start + character.length;
|
|
154
|
+
characters.push({
|
|
155
|
+
start,
|
|
156
|
+
end,
|
|
157
|
+
comparable: caseSensitive ? character : character.toLowerCase(),
|
|
158
|
+
});
|
|
159
|
+
start = end;
|
|
160
|
+
}
|
|
161
|
+
return characters;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function escapeRegExp(value: string): string {
|
|
165
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isAbortError(error: unknown): boolean {
|
|
169
|
+
return error instanceof Error && error.name === "AbortError";
|
|
170
|
+
}
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
3
|
+
import { editProjectFileInExternalEditor } from "./external-editor.js";
|
|
4
|
+
import {
|
|
5
|
+
appendPendingQuote,
|
|
6
|
+
discoverProjectFiles,
|
|
7
|
+
type FileQuote,
|
|
8
|
+
formatQuoteContext,
|
|
9
|
+
loadProjectTextFile,
|
|
10
|
+
MAX_PENDING_QUOTE_BYTES,
|
|
11
|
+
MAX_PENDING_QUOTES,
|
|
12
|
+
MAX_QUOTE_BYTES,
|
|
13
|
+
MAX_QUOTE_LINES,
|
|
14
|
+
} from "./file-context.js";
|
|
15
|
+
import type { FileQuoteExplorer, FileQuoteExplorerResult } from "./file-context-explorer.js";
|
|
16
|
+
import type { FileContextMenuQuote, FileContextMenuState } from "./file-context-menu.js";
|
|
17
|
+
import {
|
|
18
|
+
awaitFileContextSettingsWrites,
|
|
19
|
+
fileContextSettingsPath,
|
|
20
|
+
type LoadedFileContextSettings,
|
|
21
|
+
loadFileContextSettings,
|
|
22
|
+
updateFileContextSettings,
|
|
23
|
+
} from "./file-context-settings.js";
|
|
24
|
+
import { publishFilesWidget } from "./files-widget.js";
|
|
25
|
+
import { createGitContext } from "./git-context.js";
|
|
26
|
+
|
|
27
|
+
interface ActiveExplorer {
|
|
28
|
+
controller: AbortController;
|
|
29
|
+
component?: FileQuoteExplorer;
|
|
30
|
+
}
|
|
31
|
+
interface PendingQuote {
|
|
32
|
+
id: string;
|
|
33
|
+
quote: FileQuote;
|
|
34
|
+
}
|
|
35
|
+
interface ExplorerRunOptions {
|
|
36
|
+
menuOwned?: boolean;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
type ExplorerRunResult = "stay" | "close";
|
|
40
|
+
type ExplorerDiscovery = [string[], Awaited<ReturnType<typeof createGitContext>>];
|
|
41
|
+
|
|
42
|
+
export interface ContextOptions {
|
|
43
|
+
settingsPath?: string;
|
|
44
|
+
loadSettings?: () => Promise<LoadedFileContextSettings>;
|
|
45
|
+
updateSettings?: typeof updateFileContextSettings;
|
|
46
|
+
discoverFiles?: typeof discoverProjectFiles;
|
|
47
|
+
createGit?: typeof createGitContext;
|
|
48
|
+
onChange?(ctx: ExtensionContext, quotes: readonly FileQuote[]): void;
|
|
49
|
+
}
|
|
50
|
+
export interface ContextService {
|
|
51
|
+
open(ctx: ExtensionCommandContext, start?: "main" | "remove"): Promise<void>;
|
|
52
|
+
browse(ctx: ExtensionContext): Promise<void>;
|
|
53
|
+
getState(ctx: ExtensionContext): FileContextMenuState;
|
|
54
|
+
saveShortcut(ctx: ExtensionContext, shortcut: KeyId | null, signal: AbortSignal): Promise<void>;
|
|
55
|
+
signal(ctx: ExtensionContext): AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
interface ContextOwner {
|
|
58
|
+
controller: AbortController;
|
|
59
|
+
open: ContextService["open"];
|
|
60
|
+
browse: ContextService["browse"];
|
|
61
|
+
getState(): FileContextMenuState;
|
|
62
|
+
saveShortcut: ContextService["saveShortcut"];
|
|
63
|
+
refresh(ctx: ExtensionContext): Promise<void>;
|
|
64
|
+
consume(ctx: ExtensionContext): FileQuote[];
|
|
65
|
+
release(ctx: ExtensionContext): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Registers package-owned context services, not a second extension factory. */
|
|
69
|
+
export async function registerFileContext(
|
|
70
|
+
pi: ExtensionAPI,
|
|
71
|
+
dependencies: ContextOptions = {},
|
|
72
|
+
): Promise<ContextService> {
|
|
73
|
+
const settingsPath = dependencies.settingsPath ?? fileContextSettingsPath();
|
|
74
|
+
const loadSettings = dependencies.loadSettings ?? (() => loadFileContextSettings(settingsPath));
|
|
75
|
+
const updateSettings = dependencies.updateSettings ?? updateFileContextSettings;
|
|
76
|
+
// Shortcut registration precedes session_start; reading preferences creates no resources.
|
|
77
|
+
const initialSettings = await loadSettings();
|
|
78
|
+
const discoverFiles = dependencies.discoverFiles ?? discoverProjectFiles;
|
|
79
|
+
const createGit = dependencies.createGit ?? createGitContext;
|
|
80
|
+
const owners = new Map<object, ContextOwner>();
|
|
81
|
+
const requireOwner = (ctx: ExtensionContext): ContextOwner => {
|
|
82
|
+
const owner = owners.get(ctx.sessionManager);
|
|
83
|
+
if (!owner || owner.controller.signal.aborted) throw new Error("File Context is not initialized; reload first");
|
|
84
|
+
return owner;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function createOwner(context: ExtensionContext): ContextOwner {
|
|
88
|
+
let loadedSettings = initialSettings;
|
|
89
|
+
let pendingQuotes: PendingQuote[] = [];
|
|
90
|
+
let nextPendingQuoteId = 1;
|
|
91
|
+
const activeSessionManager = context.sessionManager;
|
|
92
|
+
const sessionGeneration = 0;
|
|
93
|
+
const sessionController = new AbortController();
|
|
94
|
+
const activeExplorers = new Set<ActiveExplorer>();
|
|
95
|
+
const nativeWork = new Set<Promise<unknown>>();
|
|
96
|
+
const gitContexts = new Set<NonNullable<Awaited<ReturnType<typeof createGitContext>>>>();
|
|
97
|
+
const track = <T>(work: Promise<T>): Promise<T> => {
|
|
98
|
+
nativeWork.add(work);
|
|
99
|
+
void work.then(
|
|
100
|
+
() => nativeWork.delete(work),
|
|
101
|
+
() => nativeWork.delete(work),
|
|
102
|
+
);
|
|
103
|
+
return work;
|
|
104
|
+
};
|
|
105
|
+
let activeExplorerLaunch: { promise: Promise<void> } | undefined;
|
|
106
|
+
let activeMenuLaunch: { promise: Promise<void> } | undefined;
|
|
107
|
+
const updatePendingWidget = (ctx: ExtensionContext) =>
|
|
108
|
+
(dependencies.onChange ?? publishFilesWidget)(
|
|
109
|
+
ctx,
|
|
110
|
+
pendingQuotes.map((item) => item.quote),
|
|
111
|
+
);
|
|
112
|
+
const clearPending = (ctx: ExtensionContext) => {
|
|
113
|
+
pendingQuotes = [];
|
|
114
|
+
nextPendingQuoteId = 1;
|
|
115
|
+
updatePendingWidget(ctx);
|
|
116
|
+
};
|
|
117
|
+
const validatePending = (quote: FileQuote) => {
|
|
118
|
+
appendPendingQuote(
|
|
119
|
+
pendingQuotes.map((item) => item.quote),
|
|
120
|
+
quote,
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
const appendPending = (quote: FileQuote, ctx: ExtensionContext) => {
|
|
124
|
+
validatePending(quote);
|
|
125
|
+
pendingQuotes = [...pendingQuotes, { id: `quote-${nextPendingQuoteId++}`, quote }];
|
|
126
|
+
updatePendingWidget(ctx);
|
|
127
|
+
};
|
|
128
|
+
const isCurrentSession = (key: unknown, generation: number) =>
|
|
129
|
+
key === activeSessionManager &&
|
|
130
|
+
generation === sessionGeneration &&
|
|
131
|
+
owners.get(activeSessionManager) === runtime &&
|
|
132
|
+
!sessionController.signal.aborted;
|
|
133
|
+
const menuQuote = ({ id, quote }: PendingQuote): FileContextMenuQuote => ({
|
|
134
|
+
id,
|
|
135
|
+
path: quote.path,
|
|
136
|
+
startLine: quote.startLine,
|
|
137
|
+
endLine: quote.endLine,
|
|
138
|
+
text: quote.text,
|
|
139
|
+
});
|
|
140
|
+
const cancelExplorers = () => {
|
|
141
|
+
for (const explorer of activeExplorers) {
|
|
142
|
+
explorer.controller.abort(new DOMException("File Context explorer closed", "AbortError"));
|
|
143
|
+
explorer.component?.dispose();
|
|
144
|
+
}
|
|
145
|
+
activeExplorers.clear();
|
|
146
|
+
};
|
|
147
|
+
const runExplorer = async (ctx: ExtensionContext, options: ExplorerRunOptions = {}): Promise<ExplorerRunResult> => {
|
|
148
|
+
if (ctx.mode !== "tui") {
|
|
149
|
+
rejectCommand(ctx, "File Context requires Pi's interactive TUI.");
|
|
150
|
+
return "close";
|
|
151
|
+
}
|
|
152
|
+
const owner = ctx.sessionManager;
|
|
153
|
+
const generation = sessionGeneration;
|
|
154
|
+
const activeExplorer: ActiveExplorer = { controller: new AbortController() };
|
|
155
|
+
let explorerGit: NonNullable<Awaited<ReturnType<typeof createGitContext>>> | undefined;
|
|
156
|
+
const { controller } = activeExplorer;
|
|
157
|
+
const flowSignal = AbortSignal.any([
|
|
158
|
+
controller.signal,
|
|
159
|
+
sessionController.signal,
|
|
160
|
+
...(ctx.signal ? [ctx.signal] : []),
|
|
161
|
+
...(options.signal ? [options.signal] : []),
|
|
162
|
+
]);
|
|
163
|
+
activeExplorers.add(activeExplorer);
|
|
164
|
+
try {
|
|
165
|
+
const [{ runCustomInteraction, runTask }, { FileQuoteExplorer }] = await Promise.all([
|
|
166
|
+
import("@narumitw/pi-tui-kit"),
|
|
167
|
+
import("./file-context-explorer.js"),
|
|
168
|
+
]);
|
|
169
|
+
if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close";
|
|
170
|
+
const task = await runTask(ctx, {
|
|
171
|
+
label: "Scanning project files…",
|
|
172
|
+
signal: flowSignal,
|
|
173
|
+
isCurrent: () => isCurrentSession(owner, generation) && !flowSignal.aborted,
|
|
174
|
+
task: ({ signal }) =>
|
|
175
|
+
Promise.all([track(discoverFiles(ctx.cwd, { signal })), track(createGit(ctx.cwd, signal))]),
|
|
176
|
+
onError: (_taskContext, error) => {
|
|
177
|
+
ctx.ui.notify(
|
|
178
|
+
`File Context could not scan project files: ${escapeTerminalControls(formatError(error))}. Open File Context to retry.`,
|
|
179
|
+
"error",
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (task.kind === "cancelled" || task.kind === "error") {
|
|
184
|
+
return options.menuOwned ? "stay" : "close";
|
|
185
|
+
}
|
|
186
|
+
if (task.kind === "stale") return "close";
|
|
187
|
+
const discovery: ExplorerDiscovery = task.value;
|
|
188
|
+
if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close";
|
|
189
|
+
const [files, gitContext] = discovery;
|
|
190
|
+
if (gitContext) {
|
|
191
|
+
explorerGit = gitContext;
|
|
192
|
+
gitContexts.add(gitContext);
|
|
193
|
+
}
|
|
194
|
+
if (files.length === 0) {
|
|
195
|
+
ctx.ui.notify("File Context found no project files. Choose Add context snippet to retry.", "warning");
|
|
196
|
+
return options.menuOwned ? "stay" : "close";
|
|
197
|
+
}
|
|
198
|
+
const interaction = await runCustomInteraction<FileQuoteExplorerResult | undefined, ExtensionContext>(ctx, {
|
|
199
|
+
signal: flowSignal,
|
|
200
|
+
isCurrent: () => isCurrentSession(owner, generation),
|
|
201
|
+
onError: () => {},
|
|
202
|
+
create: ({ tui, theme, keybindings, signal: interactionSignal, complete }) => {
|
|
203
|
+
const component = new FileQuoteExplorer({
|
|
204
|
+
tui,
|
|
205
|
+
theme,
|
|
206
|
+
keybindings,
|
|
207
|
+
files,
|
|
208
|
+
cwd: ctx.cwd,
|
|
209
|
+
loadFile: (path, signal) =>
|
|
210
|
+
track(
|
|
211
|
+
loadProjectTextFile(ctx.cwd, path, {
|
|
212
|
+
signal: signal ? AbortSignal.any([signal, interactionSignal]) : interactionSignal,
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
editFile: (path, signal) =>
|
|
216
|
+
track(
|
|
217
|
+
editProjectFileInExternalEditor({
|
|
218
|
+
root: ctx.cwd,
|
|
219
|
+
projectPath: path,
|
|
220
|
+
tui,
|
|
221
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
222
|
+
signal: signal ? AbortSignal.any([signal, interactionSignal]) : interactionSignal,
|
|
223
|
+
isCurrent: () => isCurrentSession(owner, generation) && !interactionSignal.aborted,
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
gitContext,
|
|
227
|
+
rootNavigation: options.menuOwned,
|
|
228
|
+
getSelectedContextState: () => ({
|
|
229
|
+
count: pendingQuotes.length,
|
|
230
|
+
totalBytes: pendingQuotes.reduce(
|
|
231
|
+
(total, item) => total + Buffer.byteLength(item.quote.text, "utf8"),
|
|
232
|
+
0,
|
|
233
|
+
),
|
|
234
|
+
maximumCount: MAX_PENDING_QUOTES,
|
|
235
|
+
maximumBytes: MAX_PENDING_QUOTE_BYTES,
|
|
236
|
+
maximumSnippetLines: MAX_QUOTE_LINES,
|
|
237
|
+
maximumSnippetBytes: MAX_QUOTE_BYTES,
|
|
238
|
+
}),
|
|
239
|
+
validateQuote: validatePending,
|
|
240
|
+
onAddAndContinue: (quote) => {
|
|
241
|
+
if (!isCurrentSession(owner, generation) || interactionSignal.aborted) {
|
|
242
|
+
throw new DOMException("File Context session replaced", "AbortError");
|
|
243
|
+
}
|
|
244
|
+
appendPending(quote, ctx);
|
|
245
|
+
try {
|
|
246
|
+
ctx.ui.notify(
|
|
247
|
+
`Added to next prompt context: ${escapeTerminalControls(quote.path)} · lines ${quote.startLine}-${quote.endLine}.`,
|
|
248
|
+
"info",
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
// The widget already reflects the selected snapshot.
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
done: complete,
|
|
255
|
+
});
|
|
256
|
+
activeExplorer.component = component;
|
|
257
|
+
return component;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
if (interaction.kind === "error") throw interaction.error;
|
|
261
|
+
const result = interaction.kind === "completed" ? interaction.value : undefined;
|
|
262
|
+
if (!isCurrentSession(owner, generation) || flowSignal.aborted) return "close";
|
|
263
|
+
if (result?.kind === "quote") {
|
|
264
|
+
appendPending(result.quote, ctx);
|
|
265
|
+
return "close";
|
|
266
|
+
}
|
|
267
|
+
if (result?.kind === "reference") {
|
|
268
|
+
ctx.ui.pasteToEditor(formatFileReference(result.path));
|
|
269
|
+
return "close";
|
|
270
|
+
}
|
|
271
|
+
return result?.kind === "back" ? "stay" : "close";
|
|
272
|
+
} catch (error: unknown) {
|
|
273
|
+
if (!isCurrentSession(owner, generation) || flowSignal.aborted || isAbortError(error)) {
|
|
274
|
+
return "close";
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
ctx.ui.notify(
|
|
278
|
+
`File Context failed: ${escapeTerminalControls(formatError(error))}. Open File Context to retry.`,
|
|
279
|
+
"error",
|
|
280
|
+
);
|
|
281
|
+
} catch {
|
|
282
|
+
// The session may have been replaced while the picker was open.
|
|
283
|
+
}
|
|
284
|
+
return options.menuOwned ? "stay" : "close";
|
|
285
|
+
} finally {
|
|
286
|
+
activeExplorers.delete(activeExplorer);
|
|
287
|
+
if (explorerGit) {
|
|
288
|
+
await explorerGit.whenIdle();
|
|
289
|
+
gitContexts.delete(explorerGit);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const openExplorer = (ctx: ExtensionContext): Promise<void> => {
|
|
295
|
+
if (activeExplorerLaunch) return activeExplorerLaunch.promise;
|
|
296
|
+
if (activeMenuLaunch) return activeMenuLaunch.promise;
|
|
297
|
+
const launch = { promise: runExplorer(ctx).then(() => undefined) };
|
|
298
|
+
activeExplorerLaunch = launch;
|
|
299
|
+
void launch.promise
|
|
300
|
+
.finally(() => {
|
|
301
|
+
if (activeExplorerLaunch === launch) activeExplorerLaunch = undefined;
|
|
302
|
+
})
|
|
303
|
+
.catch(() => {});
|
|
304
|
+
return launch.promise;
|
|
305
|
+
};
|
|
306
|
+
const openMenu = (ctx: ExtensionCommandContext, start: "main" | "remove" = "main"): Promise<void> => {
|
|
307
|
+
if (ctx.mode !== "tui") {
|
|
308
|
+
rejectCommand(ctx, "File Context requires Pi's interactive TUI.");
|
|
309
|
+
return Promise.resolve();
|
|
310
|
+
}
|
|
311
|
+
if (activeMenuLaunch) return activeMenuLaunch.promise;
|
|
312
|
+
if (activeExplorerLaunch) return activeExplorerLaunch.promise;
|
|
313
|
+
if (start === "remove" && pendingQuotes.length === 0) {
|
|
314
|
+
ctx.ui.notify("File Context has no context selected for the next prompt.", "warning");
|
|
315
|
+
return Promise.resolve();
|
|
316
|
+
}
|
|
317
|
+
const owner = ctx.sessionManager;
|
|
318
|
+
const generation = sessionGeneration;
|
|
319
|
+
const ownerController = {
|
|
320
|
+
signal: AbortSignal.any([sessionController.signal, ...(ctx.signal ? [ctx.signal] : [])]),
|
|
321
|
+
};
|
|
322
|
+
const isCurrent = () => isCurrentSession(owner, generation) && !ownerController.signal.aborted;
|
|
323
|
+
const promise = import("./file-context-menu.js")
|
|
324
|
+
.then(({ showFileContextMenu }) => {
|
|
325
|
+
if (!isCurrent()) return;
|
|
326
|
+
return showFileContextMenu(ctx, {
|
|
327
|
+
start,
|
|
328
|
+
signal: ownerController.signal,
|
|
329
|
+
isCurrent,
|
|
330
|
+
getState: () => {
|
|
331
|
+
if (!isCurrent()) throw new DOMException("File Context session replaced", "AbortError");
|
|
332
|
+
const quotes = pendingQuotes.map(menuQuote);
|
|
333
|
+
return {
|
|
334
|
+
quotes,
|
|
335
|
+
shortcut: loadedSettings.settings.openShortcut,
|
|
336
|
+
maximumQuotes: MAX_PENDING_QUOTES,
|
|
337
|
+
maximumBytes: MAX_PENDING_QUOTE_BYTES,
|
|
338
|
+
totalBytes: quotes.reduce((total, quote) => total + Buffer.byteLength(quote.text, "utf8"), 0),
|
|
339
|
+
settingsPath,
|
|
340
|
+
settingsInvalidReason: loadedSettings.invalidReason,
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
addQuote: (signal) => runExplorer(ctx, { menuOwned: true, signal }),
|
|
344
|
+
saveShortcut: async (shortcut, signal) => {
|
|
345
|
+
if (!isCurrent() || signal.aborted) {
|
|
346
|
+
throw new DOMException("File Context session replaced", "AbortError");
|
|
347
|
+
}
|
|
348
|
+
if (pendingQuotes.length > 0)
|
|
349
|
+
throw new Error("Submit or remove selected context before changing the shortcut");
|
|
350
|
+
const saved = await updateSettings(shortcut, { settingsPath, signal });
|
|
351
|
+
if (!isCurrent() || signal.aborted) return;
|
|
352
|
+
loadedSettings = { settings: saved };
|
|
353
|
+
},
|
|
354
|
+
removeQuote: (id, signal) => {
|
|
355
|
+
if (!isCurrent() || signal.aborted) return { kind: "missing" };
|
|
356
|
+
const index = pendingQuotes.findIndex((item) => item.id === id);
|
|
357
|
+
const selected = pendingQuotes[index];
|
|
358
|
+
if (!selected) return { kind: "missing" };
|
|
359
|
+
pendingQuotes = pendingQuotes.filter((_item, itemIndex) => itemIndex !== index);
|
|
360
|
+
updatePendingWidget(ctx);
|
|
361
|
+
return {
|
|
362
|
+
kind: "removed",
|
|
363
|
+
quote: menuQuote(selected),
|
|
364
|
+
remaining: pendingQuotes.length,
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
})
|
|
369
|
+
.then(() => undefined);
|
|
370
|
+
const launch = { promise };
|
|
371
|
+
activeMenuLaunch = launch;
|
|
372
|
+
const clearLaunch = () => {
|
|
373
|
+
if (activeMenuLaunch === launch) activeMenuLaunch = undefined;
|
|
374
|
+
};
|
|
375
|
+
void promise.then(clearLaunch, clearLaunch);
|
|
376
|
+
return promise;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const runtime: ContextOwner = {
|
|
380
|
+
controller: sessionController,
|
|
381
|
+
open: openMenu,
|
|
382
|
+
browse: openExplorer,
|
|
383
|
+
getState: () => ({
|
|
384
|
+
quotes: pendingQuotes.map(menuQuote),
|
|
385
|
+
shortcut: loadedSettings.settings.openShortcut,
|
|
386
|
+
maximumQuotes: MAX_PENDING_QUOTES,
|
|
387
|
+
maximumBytes: MAX_PENDING_QUOTE_BYTES,
|
|
388
|
+
totalBytes: pendingQuotes.reduce((total, item) => total + Buffer.byteLength(item.quote.text, "utf8"), 0),
|
|
389
|
+
settingsPath,
|
|
390
|
+
settingsInvalidReason: loadedSettings.invalidReason,
|
|
391
|
+
}),
|
|
392
|
+
saveShortcut: async (ctx, shortcut, signal) => {
|
|
393
|
+
if (pendingQuotes.length) throw new Error("Submit or remove selected context before changing the shortcut");
|
|
394
|
+
const operation = AbortSignal.any([signal, sessionController.signal, ...(ctx.signal ? [ctx.signal] : [])]);
|
|
395
|
+
operation.throwIfAborted();
|
|
396
|
+
const saved = await updateSettings(shortcut, { settingsPath, signal: operation });
|
|
397
|
+
if (!isCurrentSession(activeSessionManager, sessionGeneration) || operation.aborted) return;
|
|
398
|
+
loadedSettings = { settings: saved };
|
|
399
|
+
},
|
|
400
|
+
refresh: async (ctx) => {
|
|
401
|
+
const refreshed = await loadSettings();
|
|
402
|
+
if (!isCurrentSession(activeSessionManager, sessionGeneration)) return;
|
|
403
|
+
loadedSettings = refreshed;
|
|
404
|
+
if (refreshed.warning && ctx.hasUI) ctx.ui.notify(escapeTerminalControls(refreshed.warning), "warning");
|
|
405
|
+
},
|
|
406
|
+
consume: (ctx) => {
|
|
407
|
+
const quotes = pendingQuotes.map((item) => item.quote);
|
|
408
|
+
clearPending(ctx);
|
|
409
|
+
return quotes;
|
|
410
|
+
},
|
|
411
|
+
release: async (ctx) => {
|
|
412
|
+
// A menu can be waiting for reload, which itself waits for this shutdown hook.
|
|
413
|
+
const work = [activeExplorerLaunch?.promise];
|
|
414
|
+
sessionController.abort(new DOMException("File Context session ended", "AbortError"));
|
|
415
|
+
cancelExplorers();
|
|
416
|
+
const settled = await Promise.allSettled([
|
|
417
|
+
awaitFileContextSettingsWrites(settingsPath),
|
|
418
|
+
Promise.resolve().then(() => clearPending(ctx)),
|
|
419
|
+
...work,
|
|
420
|
+
...nativeWork,
|
|
421
|
+
...[...gitContexts].map((git) => git.whenIdle()),
|
|
422
|
+
]);
|
|
423
|
+
for (const result of settled.slice(0, 2)) if (result.status === "rejected") throw result.reason;
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
return runtime;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const service: ContextService = {
|
|
430
|
+
open: (ctx, start) => requireOwner(ctx).open(ctx, start),
|
|
431
|
+
browse: (ctx) => requireOwner(ctx).browse(ctx),
|
|
432
|
+
getState: (ctx) => requireOwner(ctx).getState(),
|
|
433
|
+
saveShortcut: (ctx, shortcut, signal) => requireOwner(ctx).saveShortcut(ctx, shortcut, signal),
|
|
434
|
+
signal: (ctx) => requireOwner(ctx).controller.signal,
|
|
435
|
+
};
|
|
436
|
+
if (initialSettings.settings.openShortcut) {
|
|
437
|
+
pi.registerShortcut(initialSettings.settings.openShortcut, {
|
|
438
|
+
description: "Open File Context",
|
|
439
|
+
handler: service.browse,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
pi.registerCommand("file-context", {
|
|
443
|
+
description: "Open the File Context menu",
|
|
444
|
+
getArgumentCompletions: (prefix) => {
|
|
445
|
+
const items = [
|
|
446
|
+
{ value: "browse", label: "browse", description: "Open the file browser directly" },
|
|
447
|
+
{ value: "remove", label: "remove", description: "Review selected context directly" },
|
|
448
|
+
].filter((item) => item.value.startsWith(prefix.trimStart().toLowerCase()));
|
|
449
|
+
return items.length ? items : null;
|
|
450
|
+
},
|
|
451
|
+
handler: async (args, ctx) => {
|
|
452
|
+
const route = args.trim().toLowerCase();
|
|
453
|
+
if (route !== "" && route !== "browse" && route !== "remove")
|
|
454
|
+
return rejectCommand(ctx, "Usage: /file-context [browse|remove]");
|
|
455
|
+
if (ctx.mode !== "tui") return rejectCommand(ctx, "File Context requires Pi's interactive TUI.");
|
|
456
|
+
if (route === "browse") await service.browse(ctx);
|
|
457
|
+
else await service.open(ctx, route === "remove" ? "remove" : "main");
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
461
|
+
const key = ctx.sessionManager;
|
|
462
|
+
const previous = owners.get(key);
|
|
463
|
+
const owner = createOwner(ctx);
|
|
464
|
+
owners.set(key, owner);
|
|
465
|
+
if (previous) await previous.release(ctx);
|
|
466
|
+
if (owners.get(key) !== owner || owner.controller.signal.aborted) return;
|
|
467
|
+
(dependencies.onChange ?? publishFilesWidget)(ctx, []);
|
|
468
|
+
await owner.refresh(ctx);
|
|
469
|
+
});
|
|
470
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
471
|
+
const owner = owners.get(ctx.sessionManager);
|
|
472
|
+
if (event.messageType === "custom" || !owner || owner.controller.signal.aborted || !owner.getState().quotes.length)
|
|
473
|
+
return;
|
|
474
|
+
return {
|
|
475
|
+
message: { customType: "file-context-quotes", content: formatQuoteContext(owner.consume(ctx)), display: false },
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
479
|
+
const key = ctx.sessionManager;
|
|
480
|
+
const owner = owners.get(key);
|
|
481
|
+
if (!owner) return;
|
|
482
|
+
try {
|
|
483
|
+
await owner.release(ctx);
|
|
484
|
+
} finally {
|
|
485
|
+
if (owners.get(key) === owner) owners.delete(key);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
return service;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function formatFileReference(path: string): string {
|
|
492
|
+
const escaped = path.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
493
|
+
return /\s|["\\]/.test(path) ? `@"${escaped}" ` : `@${path} `;
|
|
494
|
+
}
|
|
495
|
+
function escapeTerminalControls(text: string): string {
|
|
496
|
+
return [...text]
|
|
497
|
+
.map((character) => {
|
|
498
|
+
const code = character.charCodeAt(0);
|
|
499
|
+
return code <= 31 || (code >= 127 && code <= 159) ? `\\x${code.toString(16).padStart(2, "0")}` : character;
|
|
500
|
+
})
|
|
501
|
+
.join("");
|
|
502
|
+
}
|
|
503
|
+
function rejectCommand(ctx: ExtensionContext, message: string): void {
|
|
504
|
+
if (ctx.hasUI) {
|
|
505
|
+
ctx.ui.notify(message, "warning");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
throw new Error(message);
|
|
509
|
+
}
|
|
510
|
+
function isAbortError(error: unknown): boolean {
|
|
511
|
+
return error instanceof Error && error.name === "AbortError";
|
|
512
|
+
}
|
|
513
|
+
function formatError(error: unknown): string {
|
|
514
|
+
return error instanceof Error ? error.message : String(error);
|
|
515
|
+
}
|