@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,435 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { ANCHOR_TYPE, createScopeNotice, reconcileScopeContext } from "./contracts.js";
|
|
6
|
+
import { displayError, displayPath } from "./display.js";
|
|
7
|
+
import type { ScopeMenuOptions, ScopeView } from "./menu.js";
|
|
8
|
+
import { assertScopeTarget, resolveScopeTarget, type ScopeTarget } from "./paths.js";
|
|
9
|
+
import { assertUsableLoadout, decideTool } from "./policy.js";
|
|
10
|
+
import { type ScopeSettings, ScopeSettingsStore } from "./settings.js";
|
|
11
|
+
import { createSnapshot, restoreScope, type ScopeSnapshot, type ScopeState, STATE_TYPE, sameScope } from "./state.js";
|
|
12
|
+
|
|
13
|
+
export type { ScopeView } from "./menu.js";
|
|
14
|
+
export type { ScopeSettings } from "./settings.js";
|
|
15
|
+
export type ShowMenu = (ctx: ExtensionCommandContext, options: ScopeMenuOptions) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
export interface ScopeOptions {
|
|
18
|
+
showMenu?: ShowMenu;
|
|
19
|
+
onChange?(ctx: ExtensionContext, view: ScopeView | undefined): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScopeService {
|
|
23
|
+
open(ctx: ExtensionCommandContext, signal?: AbortSignal): Promise<void>;
|
|
24
|
+
off(ctx: ExtensionContext): void;
|
|
25
|
+
getView(ctx: ExtensionContext): ScopeView;
|
|
26
|
+
getSettings(ctx: ExtensionContext): ScopeSettings;
|
|
27
|
+
saveShowHiddenFiles(ctx: ExtensionContext, value: boolean): Promise<void>;
|
|
28
|
+
signal(ctx: ExtensionContext): AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Flow {
|
|
32
|
+
controller: AbortController;
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
revision: number;
|
|
35
|
+
promise?: Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
interface Owner {
|
|
38
|
+
key: object;
|
|
39
|
+
controller: AbortController;
|
|
40
|
+
settings: ScopeSettingsStore;
|
|
41
|
+
scope: ScopeState;
|
|
42
|
+
revision: number;
|
|
43
|
+
checks: Set<Promise<void>>;
|
|
44
|
+
flow?: Flow;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const uninitialized = (): ScopeState => ({ state: "blocked", error: "Scope is not initialized; edits remain blocked" });
|
|
48
|
+
const showMenu: ShowMenu = async (ctx, options) => {
|
|
49
|
+
const { showScopeMenu } = await import("./menu.js");
|
|
50
|
+
options.signal.throwIfAborted();
|
|
51
|
+
if (options.isCurrent()) await showScopeMenu(ctx, options);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function registerScope(pi: ExtensionAPI, options: ScopeOptions = {}): ScopeService {
|
|
55
|
+
const show = options.showMenu ?? showMenu;
|
|
56
|
+
const owners = new Map<object, Owner>();
|
|
57
|
+
const starts = new WeakMap<object, object>();
|
|
58
|
+
|
|
59
|
+
function current(owner: Owner, _ctx?: ExtensionContext): boolean {
|
|
60
|
+
// Do not touch a captured context after its session has been replaced.
|
|
61
|
+
return !owner.controller.signal.aborted && owners.get(owner.key) === owner;
|
|
62
|
+
}
|
|
63
|
+
function check(owner: Owner, ctx: ExtensionContext, flow?: Flow): void {
|
|
64
|
+
owner.controller.signal.throwIfAborted();
|
|
65
|
+
flow?.signal.throwIfAborted();
|
|
66
|
+
if (!current(owner, ctx) || (flow && (owner.flow !== flow || owner.revision !== flow.revision))) {
|
|
67
|
+
throw new DOMException("Scope session or selection changed", "AbortError");
|
|
68
|
+
}
|
|
69
|
+
ctx.signal?.throwIfAborted();
|
|
70
|
+
}
|
|
71
|
+
function mutationCheck(owner: Owner, ctx: ExtensionContext, flow?: Flow): void {
|
|
72
|
+
check(owner, ctx, flow);
|
|
73
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
|
74
|
+
throw new Error("Stop the current run and clear pending messages before changing Scope");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function view(owner: Owner, ctx: ExtensionContext): ScopeView {
|
|
78
|
+
return {
|
|
79
|
+
state: owner.scope.state,
|
|
80
|
+
cwd: ctx.cwd,
|
|
81
|
+
targets: (owner.scope.snapshot?.targets ?? []).map((target) => ({ ...target })),
|
|
82
|
+
...(owner.scope.error ? { error: owner.scope.error } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function updateView(owner: Owner, ctx: ExtensionContext): void {
|
|
86
|
+
if (!current(owner, ctx)) return;
|
|
87
|
+
options.onChange?.(ctx, view(owner, ctx));
|
|
88
|
+
}
|
|
89
|
+
function notify(ctx: ExtensionContext, message: string, kind: "info" | "warning" | "error" = "info"): void {
|
|
90
|
+
if (ctx.hasUI) ctx.ui.notify(displayError(message).slice(0, 4_000), kind);
|
|
91
|
+
}
|
|
92
|
+
async function release(owner: Owner, ctx: ExtensionContext): Promise<void> {
|
|
93
|
+
let report: Promise<void> | undefined;
|
|
94
|
+
if (!owner.controller.signal.aborted) {
|
|
95
|
+
owner.controller.abort(new DOMException("Scope session ended", "AbortError"));
|
|
96
|
+
report = Promise.resolve().then(() => options.onChange?.(ctx, undefined));
|
|
97
|
+
}
|
|
98
|
+
const settled = await Promise.allSettled([owner.flow?.promise, owner.settings.flush(), report, ...owner.checks]);
|
|
99
|
+
if (settled[1].status === "rejected") throw settled[1].reason;
|
|
100
|
+
if (settled[2].status === "rejected") throw settled[2].reason;
|
|
101
|
+
}
|
|
102
|
+
async function initialize(ctx: ExtensionContext): Promise<void> {
|
|
103
|
+
const key = ctx.sessionManager;
|
|
104
|
+
const token = {};
|
|
105
|
+
starts.set(key, token);
|
|
106
|
+
const previous = owners.get(key);
|
|
107
|
+
const owner: Owner = {
|
|
108
|
+
key,
|
|
109
|
+
controller: new AbortController(),
|
|
110
|
+
// Keep the existing settings path so the package rename preserves user preferences.
|
|
111
|
+
settings: new ScopeSettingsStore(join(getAgentDir(), "pi-scope.json")),
|
|
112
|
+
scope: uninitialized(),
|
|
113
|
+
revision: 0,
|
|
114
|
+
checks: new Set(),
|
|
115
|
+
};
|
|
116
|
+
owners.set(key, owner);
|
|
117
|
+
if (previous) await release(previous, ctx);
|
|
118
|
+
if (starts.get(key) !== token || !current(owner, ctx)) return;
|
|
119
|
+
try {
|
|
120
|
+
owner.scope = restoreScope(ctx.sessionManager.getBranch(), ctx.cwd);
|
|
121
|
+
} catch {
|
|
122
|
+
owner.scope = {
|
|
123
|
+
state: "blocked",
|
|
124
|
+
error: "Scope restoration failed; edits remain blocked. Use /scope to repair or /scope off to exit.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (owner.scope.state === "active") {
|
|
128
|
+
try {
|
|
129
|
+
assertUsableLoadout(pi);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
owner.scope = { ...owner.scope, state: "blocked", error: displayError(error) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await owner.settings.load();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (current(owner, ctx)) notify(ctx, `Scope settings were not loaded: ${displayError(error)}`, "warning");
|
|
138
|
+
}
|
|
139
|
+
if (starts.get(key) !== token || !current(owner, ctx)) return;
|
|
140
|
+
updateView(owner, ctx);
|
|
141
|
+
if (owner.scope.state === "blocked") notify(ctx, owner.scope.error ?? "Scope restoration failed", "error");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function publish(owner: Owner, ctx: ExtensionContext): void {
|
|
145
|
+
const notice = createScopeNotice(owner.scope);
|
|
146
|
+
if (!notice) return;
|
|
147
|
+
try {
|
|
148
|
+
// sendMessage has no persistence acknowledgement. Context verifies retention and stores
|
|
149
|
+
// a fixed restoration anchor if delivery is deferred or the handoff was compacted away.
|
|
150
|
+
pi.sendMessage(notice, { triggerTurn: false });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
notify(ctx, `Scope is applied, but its model notice needs recovery: ${displayError(error)}`, "warning");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function commit(
|
|
156
|
+
owner: Owner,
|
|
157
|
+
ctx: ExtensionContext,
|
|
158
|
+
snapshot: ScopeSnapshot,
|
|
159
|
+
flow?: Flow,
|
|
160
|
+
signal?: AbortSignal,
|
|
161
|
+
): void {
|
|
162
|
+
signal?.throwIfAborted();
|
|
163
|
+
mutationCheck(owner, ctx, flow);
|
|
164
|
+
owner.scope = { state: "blocked", error: "Scope update is incomplete; edits remain blocked" };
|
|
165
|
+
owner.revision += 1;
|
|
166
|
+
if (flow) flow.revision = owner.revision;
|
|
167
|
+
try {
|
|
168
|
+
pi.appendEntry(STATE_TYPE, snapshot);
|
|
169
|
+
check(owner, ctx, flow);
|
|
170
|
+
const restored = restoreScope(ctx.sessionManager.getBranch(), ctx.cwd);
|
|
171
|
+
if (restored.state === "blocked" || !sameScope(restored.snapshot, snapshot) || !restored.entryId) {
|
|
172
|
+
throw new Error("Scope state was not retained by the session");
|
|
173
|
+
}
|
|
174
|
+
owner.scope = restored;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// SessionManager can update memory before a disk failure. Do not claim rollback or
|
|
177
|
+
// silently fall back to unrestricted editing when publication is uncertain.
|
|
178
|
+
if (current(owner, ctx)) {
|
|
179
|
+
owner.scope = {
|
|
180
|
+
state: "blocked",
|
|
181
|
+
error: `Scope state could not be saved; edits remain blocked. Repair with /scope or /scope off. ${displayError(error)}`,
|
|
182
|
+
};
|
|
183
|
+
updateView(owner, ctx);
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
updateView(owner, ctx);
|
|
188
|
+
publish(owner, ctx);
|
|
189
|
+
}
|
|
190
|
+
async function apply(
|
|
191
|
+
owner: Owner,
|
|
192
|
+
ctx: ExtensionContext,
|
|
193
|
+
targets: readonly ScopeTarget[],
|
|
194
|
+
flow: Flow,
|
|
195
|
+
newFile = false,
|
|
196
|
+
operation?: AbortSignal,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
const signal = AbortSignal.any([owner.controller.signal, flow.signal, ...(operation ? [operation] : [])]);
|
|
199
|
+
signal.throwIfAborted();
|
|
200
|
+
mutationCheck(owner, ctx, flow);
|
|
201
|
+
const proposed = createSnapshot(resolve(ctx.cwd), targets);
|
|
202
|
+
// Newly resolved existing files must still exist at commit. Keep the
|
|
203
|
+
// requirement across canonical alias deduplication; live reserved paths
|
|
204
|
+
// without resolution metadata may still be missing.
|
|
205
|
+
const existing = new Set(
|
|
206
|
+
targets.filter((target) => "exists" in target && target.exists === true).map((target) => target.canonical),
|
|
207
|
+
);
|
|
208
|
+
if (newFile && proposed.targets?.length !== 1) throw new Error("New file requires exactly one target");
|
|
209
|
+
assertUsableLoadout(pi);
|
|
210
|
+
const checked: ScopeTarget[] = [];
|
|
211
|
+
for (const target of proposed.targets ?? []) {
|
|
212
|
+
const candidate = await resolveScopeTarget(
|
|
213
|
+
pathToFileURL(target.path).href,
|
|
214
|
+
ctx.cwd,
|
|
215
|
+
newFile ? "new" : existing.has(target.canonical) ? "existing" : "either",
|
|
216
|
+
signal,
|
|
217
|
+
);
|
|
218
|
+
signal.throwIfAborted();
|
|
219
|
+
mutationCheck(owner, ctx, flow);
|
|
220
|
+
if (candidate.canonical !== target.canonical)
|
|
221
|
+
throw new Error(`Scope target changed: ${displayPath(target.path)}; select it again`);
|
|
222
|
+
checked.push({ path: candidate.path, canonical: candidate.canonical });
|
|
223
|
+
}
|
|
224
|
+
assertUsableLoadout(pi);
|
|
225
|
+
const snapshot = createSnapshot(resolve(ctx.cwd), checked);
|
|
226
|
+
createScopeNotice({ state: "active", snapshot, entryId: "preview" });
|
|
227
|
+
if (owner.scope.state !== "blocked" && sameScope(owner.scope.snapshot, snapshot)) {
|
|
228
|
+
notify(ctx, "Scope unchanged");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
commit(owner, ctx, snapshot, flow, signal);
|
|
232
|
+
notify(ctx, `Editing limited to ${checked.length} file${checked.length === 1 ? "" : "s"}`);
|
|
233
|
+
}
|
|
234
|
+
function off(owner: Owner, ctx: ExtensionContext, flow?: Flow): void {
|
|
235
|
+
mutationCheck(owner, ctx, flow);
|
|
236
|
+
if (owner.scope.state === "inactive") {
|
|
237
|
+
notify(ctx, "Scope is already inactive");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
commit(owner, ctx, createSnapshot(resolve(ctx.cwd), null), flow);
|
|
241
|
+
notify(ctx, "Scope restriction ended");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pi.on("session_start", (_event, ctx) => initialize(ctx));
|
|
245
|
+
pi.on("session_tree", (_event, ctx) => initialize(ctx));
|
|
246
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
247
|
+
const key = ctx.sessionManager;
|
|
248
|
+
starts.delete(key);
|
|
249
|
+
const owner = owners.get(key);
|
|
250
|
+
if (!owner) return;
|
|
251
|
+
try {
|
|
252
|
+
await release(owner, ctx);
|
|
253
|
+
} finally {
|
|
254
|
+
if (owners.get(key) === owner) owners.delete(key);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
259
|
+
const owner = owners.get(ctx.sessionManager);
|
|
260
|
+
const scope = owner && current(owner) ? owner.scope : uninitialized();
|
|
261
|
+
if (scope.state === "inactive") return;
|
|
262
|
+
const decision = decideTool(event.toolName, pi.getAllTools(), scope);
|
|
263
|
+
if (decision.kind === "pass") return;
|
|
264
|
+
if (decision.kind === "block") return { block: true, reason: displayError(decision.reason).slice(0, 4_000) };
|
|
265
|
+
const revision = owner?.revision;
|
|
266
|
+
let work: Promise<void> | undefined;
|
|
267
|
+
try {
|
|
268
|
+
const path =
|
|
269
|
+
event.input && typeof event.input === "object" && "path" in event.input ? event.input.path : undefined;
|
|
270
|
+
if (!owner || !scope.snapshot?.targets || typeof path !== "string")
|
|
271
|
+
throw new Error("Invalid Scope write/edit call");
|
|
272
|
+
check(owner, ctx);
|
|
273
|
+
const signal = ctx.signal ? AbortSignal.any([owner.controller.signal, ctx.signal]) : owner.controller.signal;
|
|
274
|
+
work = assertScopeTarget(path, ctx.cwd, scope.snapshot.targets, signal);
|
|
275
|
+
owner.checks.add(work);
|
|
276
|
+
await work;
|
|
277
|
+
check(owner, ctx);
|
|
278
|
+
if (owner.revision !== revision || owner.scope !== scope)
|
|
279
|
+
throw new Error("Scope changed while checking this call");
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return { block: true, reason: `Scope blocked this edit: ${displayError(error).slice(0, 4_000)}` };
|
|
282
|
+
} finally {
|
|
283
|
+
if (work) owner?.checks.delete(work);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Legacy context folds native prompt/tool deltas into the leading system message
|
|
288
|
+
// after an insertion. Keep the full transcript so anchored recovery stays additive.
|
|
289
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
290
|
+
const owner = owners.get(ctx.sessionManager);
|
|
291
|
+
if (!owner || !current(owner, ctx) || ctx.signal?.aborted) return;
|
|
292
|
+
try {
|
|
293
|
+
if (owner.scope.state === "active") {
|
|
294
|
+
try {
|
|
295
|
+
assertUsableLoadout(pi);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
owner.scope = { ...owner.scope, state: "blocked", error: displayError(error) };
|
|
298
|
+
owner.revision += 1;
|
|
299
|
+
owner.flow?.controller.abort(new DOMException("Scope tool configuration changed", "AbortError"));
|
|
300
|
+
updateView(owner, ctx);
|
|
301
|
+
notify(ctx, owner.scope.error ?? "Scope tool configuration is unsupported", "error");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const result = reconcileScopeContext(event.messages, ctx.sessionManager.getBranch(), owner.scope);
|
|
305
|
+
if (result.anchor) {
|
|
306
|
+
pi.appendEntry(ANCHOR_TYPE, result.anchor);
|
|
307
|
+
check(owner, ctx);
|
|
308
|
+
}
|
|
309
|
+
return { messages: result.messages };
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (!current(owner) || ctx.signal?.aborted) return;
|
|
312
|
+
owner.scope = {
|
|
313
|
+
...owner.scope,
|
|
314
|
+
state: "blocked",
|
|
315
|
+
error: "Scope context could not be restored; edits remain blocked. Repair with /scope or /scope off.",
|
|
316
|
+
};
|
|
317
|
+
owner.revision += 1;
|
|
318
|
+
owner.flow?.controller.abort(new DOMException("Scope context restoration failed", "AbortError"));
|
|
319
|
+
updateView(owner, ctx);
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
function getOwner(ctx: ExtensionContext): Owner {
|
|
325
|
+
const owner = owners.get(ctx.sessionManager);
|
|
326
|
+
if (!owner) throw new Error("Scope is not initialized; reload the session first");
|
|
327
|
+
check(owner, ctx);
|
|
328
|
+
return owner;
|
|
329
|
+
}
|
|
330
|
+
function assertNoFlow(owner: Owner): void {
|
|
331
|
+
if (owner.flow) throw new Error("A Scope menu is already open; close it before issuing another Scope command");
|
|
332
|
+
}
|
|
333
|
+
const service: ScopeService = {
|
|
334
|
+
async open(ctx, signal) {
|
|
335
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("/edit-scope requires Pi TUI mode");
|
|
336
|
+
const owner = getOwner(ctx);
|
|
337
|
+
mutationCheck(owner, ctx);
|
|
338
|
+
assertNoFlow(owner);
|
|
339
|
+
signal?.throwIfAborted();
|
|
340
|
+
const controller = new AbortController();
|
|
341
|
+
const flow: Flow = {
|
|
342
|
+
controller,
|
|
343
|
+
signal: AbortSignal.any([
|
|
344
|
+
owner.controller.signal,
|
|
345
|
+
controller.signal,
|
|
346
|
+
...(ctx.signal ? [ctx.signal] : []),
|
|
347
|
+
...(signal ? [signal] : []),
|
|
348
|
+
]),
|
|
349
|
+
revision: owner.revision,
|
|
350
|
+
};
|
|
351
|
+
owner.flow = flow;
|
|
352
|
+
const menu: ScopeMenuOptions = {
|
|
353
|
+
signal: flow.signal,
|
|
354
|
+
isCurrent: () =>
|
|
355
|
+
current(owner, ctx) && !flow.signal.aborted && owner.flow === flow && owner.revision === flow.revision,
|
|
356
|
+
getView: () => {
|
|
357
|
+
check(owner, ctx, flow);
|
|
358
|
+
return service.getView(ctx);
|
|
359
|
+
},
|
|
360
|
+
getSettings: () => {
|
|
361
|
+
check(owner, ctx, flow);
|
|
362
|
+
return service.getSettings(ctx);
|
|
363
|
+
},
|
|
364
|
+
saveShowHiddenFiles: async (value) => {
|
|
365
|
+
check(owner, ctx, flow);
|
|
366
|
+
await service.saveShowHiddenFiles(ctx, value);
|
|
367
|
+
check(owner, ctx, flow);
|
|
368
|
+
},
|
|
369
|
+
apply: (targets, newFile, operation) => {
|
|
370
|
+
const work = apply(owner, ctx, targets, flow, newFile, operation);
|
|
371
|
+
owner.checks.add(work);
|
|
372
|
+
void work.then(
|
|
373
|
+
() => owner.checks.delete(work),
|
|
374
|
+
() => owner.checks.delete(work),
|
|
375
|
+
);
|
|
376
|
+
return work;
|
|
377
|
+
},
|
|
378
|
+
off: async () => off(owner, ctx, flow),
|
|
379
|
+
};
|
|
380
|
+
flow.promise = Promise.resolve().then(() => {
|
|
381
|
+
check(owner, ctx, flow);
|
|
382
|
+
return show(ctx, menu);
|
|
383
|
+
});
|
|
384
|
+
try {
|
|
385
|
+
await flow.promise;
|
|
386
|
+
check(owner, ctx, flow);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (current(owner, ctx)) throw error;
|
|
389
|
+
} finally {
|
|
390
|
+
controller.abort(new DOMException("Scope menu closed", "AbortError"));
|
|
391
|
+
if (owner.flow === flow) owner.flow = undefined;
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
off(ctx) {
|
|
395
|
+
const owner = getOwner(ctx);
|
|
396
|
+
mutationCheck(owner, ctx);
|
|
397
|
+
assertNoFlow(owner);
|
|
398
|
+
off(owner, ctx);
|
|
399
|
+
},
|
|
400
|
+
getView(ctx) {
|
|
401
|
+
return view(getOwner(ctx), ctx);
|
|
402
|
+
},
|
|
403
|
+
getSettings(ctx) {
|
|
404
|
+
return getOwner(ctx).settings.current;
|
|
405
|
+
},
|
|
406
|
+
async saveShowHiddenFiles(ctx, value) {
|
|
407
|
+
const owner = getOwner(ctx);
|
|
408
|
+
await owner.settings.saveShowHiddenFiles(value);
|
|
409
|
+
check(owner, ctx);
|
|
410
|
+
},
|
|
411
|
+
signal(ctx) {
|
|
412
|
+
return getOwner(ctx).controller.signal;
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// Both routes use the same service; /scope keeps historical notices usable.
|
|
417
|
+
for (const commandName of ["edit-scope", "scope"]) {
|
|
418
|
+
pi.registerCommand(commandName, {
|
|
419
|
+
description:
|
|
420
|
+
commandName === "scope"
|
|
421
|
+
? "Compatibility alias for /edit-scope"
|
|
422
|
+
: "Temporarily restrict editing to selected files",
|
|
423
|
+
getArgumentCompletions: (prefix) =>
|
|
424
|
+
"off".startsWith(prefix) ? [{ value: "off", label: "off", description: "End the editing restriction" }] : [],
|
|
425
|
+
handler: async (args, ctx) => {
|
|
426
|
+
const route = args.trim();
|
|
427
|
+
if (route !== "" && route !== "off") throw new Error(`Usage: /${commandName} or /${commandName} off`);
|
|
428
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error(`/${commandName} requires Pi TUI mode`);
|
|
429
|
+
if (route === "off") return service.off(ctx);
|
|
430
|
+
await service.open(ctx);
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return service;
|
|
435
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Dir, Dirent } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Internal discovery limits, not settings; exceeding any limit fails the whole scan. */
|
|
6
|
+
export const MAX_SCOPE_FILES = 5_000;
|
|
7
|
+
export const MAX_SCOPE_ENTRIES = 20_000;
|
|
8
|
+
/** cwd is depth zero; directories through depth 64 are scanned. */
|
|
9
|
+
export const MAX_SCOPE_DEPTH = 64;
|
|
10
|
+
|
|
11
|
+
/** Exact directory names skipped at every depth; .gitignore is not read. */
|
|
12
|
+
export const SCOPE_EXCLUDED_DIRECTORIES: readonly string[] = Object.freeze([
|
|
13
|
+
".git",
|
|
14
|
+
".hg",
|
|
15
|
+
".svn",
|
|
16
|
+
"node_modules",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
"build",
|
|
21
|
+
"coverage",
|
|
22
|
+
"dist",
|
|
23
|
+
"target",
|
|
24
|
+
".next",
|
|
25
|
+
]);
|
|
26
|
+
const excludedDirectories = new Set(SCOPE_EXCLUDED_DIRECTORIES);
|
|
27
|
+
|
|
28
|
+
function scanError(action: string, path: string, cause: unknown): Error {
|
|
29
|
+
const code = cause && typeof cause === "object" && "code" in cause ? ` (${String(cause.code)})` : "";
|
|
30
|
+
return new Error(`Scope cannot ${action} ${JSON.stringify(path)}${code}. Check access and retry.`, { cause });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function closeDirectory(handle: Dir, path: string, signal?: AbortSignal): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
await handle.close();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
throw scanError("close directory", path, error);
|
|
39
|
+
}
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scan breadth-first and return sorted absolute lexical paths beneath cwd. Skip symlink entries and
|
|
45
|
+
* nonregular files. Hidden entries still count toward the work cap. No writes,
|
|
46
|
+
* .gitignore processing, or partial results. A symlink used as cwd is permitted.
|
|
47
|
+
*/
|
|
48
|
+
export async function discoverScopeFiles(
|
|
49
|
+
cwd: string,
|
|
50
|
+
showHiddenFiles: boolean,
|
|
51
|
+
signal?: AbortSignal,
|
|
52
|
+
): Promise<string[]> {
|
|
53
|
+
const files: string[] = [];
|
|
54
|
+
let entryCount = 0;
|
|
55
|
+
const directories = [{ directory: resolve(cwd), depth: 0 }];
|
|
56
|
+
|
|
57
|
+
// Appended directories are visited after every directory already in the queue.
|
|
58
|
+
for (const { directory, depth } of directories) {
|
|
59
|
+
signal?.throwIfAborted();
|
|
60
|
+
if (depth > MAX_SCOPE_DEPTH) {
|
|
61
|
+
throw new Error(`Scope scan exceeds depth ${MAX_SCOPE_DEPTH}. Open a narrower working directory.`);
|
|
62
|
+
}
|
|
63
|
+
let handle: Awaited<ReturnType<typeof fs.opendir>>;
|
|
64
|
+
try {
|
|
65
|
+
handle = await fs.opendir(directory);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
throw scanError("open directory", directory, error);
|
|
69
|
+
}
|
|
70
|
+
// Own the handle before checking cancellation, including an abort during open.
|
|
71
|
+
try {
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
while (true) {
|
|
74
|
+
let entry: Dirent | null;
|
|
75
|
+
try {
|
|
76
|
+
entry = await handle.read();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
throw scanError("read directory", directory, error);
|
|
80
|
+
}
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
if (!entry) break;
|
|
83
|
+
entryCount += 1;
|
|
84
|
+
if (entryCount > MAX_SCOPE_ENTRIES) {
|
|
85
|
+
throw new Error(`Scope scan exceeds ${MAX_SCOPE_ENTRIES} entries. Open a narrower working directory.`);
|
|
86
|
+
}
|
|
87
|
+
if (entry.isSymbolicLink() || (!showHiddenFiles && entry.name.startsWith("."))) continue;
|
|
88
|
+
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
|
|
89
|
+
const path = resolve(directory, entry.name);
|
|
90
|
+
let stats: Awaited<ReturnType<typeof fs.lstat>>;
|
|
91
|
+
try {
|
|
92
|
+
stats = await fs.lstat(path);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
throw scanError("inspect entry", path, error);
|
|
96
|
+
}
|
|
97
|
+
signal?.throwIfAborted();
|
|
98
|
+
// Recheck metadata so a previously observed directory symlink is not followed.
|
|
99
|
+
if (stats.isSymbolicLink()) continue;
|
|
100
|
+
if (stats.isDirectory()) {
|
|
101
|
+
if (!excludedDirectories.has(entry.name)) directories.push({ directory: path, depth: depth + 1 });
|
|
102
|
+
} else if (stats.isFile()) {
|
|
103
|
+
files.push(path);
|
|
104
|
+
if (files.length > MAX_SCOPE_FILES) {
|
|
105
|
+
throw new Error(`Scope scan exceeds ${MAX_SCOPE_FILES} files. Open a narrower working directory.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
// Always drain close, even on cancellation or read failure. Do not race I/O
|
|
111
|
+
// against an abort event and leave directory handles owned by a stale flow.
|
|
112
|
+
await closeDirectory(handle, directory, signal);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
signal?.throwIfAborted();
|
|
117
|
+
return files.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
118
|
+
}
|