@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -2
  4. package/dist/chunks/chunk-3GQ7VS22.ts +273 -0
  5. package/dist/chunks/chunk-3GQ7VS22.ts.map +7 -0
  6. package/dist/chunks/chunk-AXW73HDJ.ts +31 -0
  7. package/dist/chunks/chunk-AXW73HDJ.ts.map +7 -0
  8. package/dist/chunks/chunk-F5QFC255.ts +101 -0
  9. package/dist/chunks/chunk-F5QFC255.ts.map +7 -0
  10. package/dist/chunks/chunk-NFNGJETD.ts +77 -0
  11. package/dist/chunks/chunk-NFNGJETD.ts.map +7 -0
  12. package/dist/chunks/chunk-OPGTWNJI.ts +258 -0
  13. package/dist/chunks/chunk-OPGTWNJI.ts.map +7 -0
  14. package/dist/chunks/chunk-WOTDNRTG.ts +242 -0
  15. package/dist/chunks/chunk-WOTDNRTG.ts.map +7 -0
  16. package/dist/chunks/file-context-explorer-WR6GQZHF.ts +1793 -0
  17. package/dist/chunks/file-context-explorer-WR6GQZHF.ts.map +7 -0
  18. package/dist/chunks/file-context-menu-VPLHUUQ6.ts +302 -0
  19. package/dist/chunks/file-context-menu-VPLHUUQ6.ts.map +7 -0
  20. package/dist/chunks/files-menu-MES4MQ4F.ts +119 -0
  21. package/dist/chunks/files-menu-MES4MQ4F.ts.map +7 -0
  22. package/dist/chunks/files-settings-NYXQU2HV.ts +295 -0
  23. package/dist/chunks/files-settings-NYXQU2HV.ts.map +7 -0
  24. package/dist/chunks/menu-P6IBMROH.ts +876 -0
  25. package/dist/chunks/menu-P6IBMROH.ts.map +7 -0
  26. package/dist/index.ts +1789 -0
  27. package/dist/index.ts.map +7 -0
  28. package/docs/edit-scope-reference.md +142 -0
  29. package/docs/file-context-reference.md +91 -0
  30. package/docs/migration.md +105 -0
  31. package/docs/verification.md +99 -0
  32. package/package.json +58 -4
  33. package/src/content-search-session.ts +204 -0
  34. package/src/content-search-ui.ts +159 -0
  35. package/src/content-search.ts +170 -0
  36. package/src/context-service.ts +515 -0
  37. package/src/external-editor.ts +143 -0
  38. package/src/file-browser-ui.ts +128 -0
  39. package/src/file-browser.ts +76 -0
  40. package/src/file-context-explorer.ts +992 -0
  41. package/src/file-context-menu.ts +360 -0
  42. package/src/file-context-preview-ui.ts +354 -0
  43. package/src/file-context-settings.ts +301 -0
  44. package/src/file-context.ts +321 -0
  45. package/src/file-search.ts +246 -0
  46. package/src/files-menu.ts +125 -0
  47. package/src/files-settings.ts +314 -0
  48. package/src/files-widget.ts +65 -0
  49. package/src/files.ts +148 -0
  50. package/src/git-context.ts +485 -0
  51. package/src/index.ts +1 -0
  52. package/src/scope/contracts.ts +243 -0
  53. package/src/scope/display.ts +27 -0
  54. package/src/scope/edit-scope.ts +435 -0
  55. package/src/scope/files.ts +118 -0
  56. package/src/scope/input.ts +207 -0
  57. package/src/scope/menu.ts +277 -0
  58. package/src/scope/paths.ts +142 -0
  59. package/src/scope/picker-keys.ts +154 -0
  60. package/src/scope/picker.ts +484 -0
  61. package/src/scope/policy.ts +55 -0
  62. package/src/scope/settings.ts +138 -0
  63. package/src/scope/state.ts +91 -0
  64. package/src/scope/ui.ts +106 -0
@@ -0,0 +1,876 @@
1
+ // @generated by scripts/build-runtime.mjs; do not edit.
2
+ // @ts-nocheck -- generated JavaScript uses a .ts extension for Pi's Jiti loader.
3
+ import {
4
+ MAX_TARGETS,
5
+ createSnapshot
6
+ } from "./chunk-NFNGJETD.ts";
7
+ import {
8
+ ScopeInput,
9
+ exactRows,
10
+ scopeFrame,
11
+ scopeOverlayContext,
12
+ showScopePathInput
13
+ } from "./chunk-3GQ7VS22.ts";
14
+ import {
15
+ assertScopeTarget,
16
+ resolveScopeTarget
17
+ } from "./chunk-F5QFC255.ts";
18
+ import {
19
+ displayError,
20
+ displayFilePath,
21
+ displayPath
22
+ } from "./chunk-AXW73HDJ.ts";
23
+
24
+ // src/scope/menu.ts
25
+ import { isAbsolute as isAbsolute2, relative as relative2, sep as sep2 } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
28
+
29
+ // src/scope/files.ts
30
+ import fs from "node:fs/promises";
31
+ import { resolve } from "node:path";
32
+ var MAX_SCOPE_FILES = 5e3;
33
+ var MAX_SCOPE_ENTRIES = 2e4;
34
+ var MAX_SCOPE_DEPTH = 64;
35
+ var SCOPE_EXCLUDED_DIRECTORIES = Object.freeze([
36
+ ".git",
37
+ ".hg",
38
+ ".svn",
39
+ "node_modules",
40
+ ".venv",
41
+ "venv",
42
+ "__pycache__",
43
+ "build",
44
+ "coverage",
45
+ "dist",
46
+ "target",
47
+ ".next"
48
+ ]);
49
+ var excludedDirectories = new Set(SCOPE_EXCLUDED_DIRECTORIES);
50
+ function scanError(action, path, cause) {
51
+ const code = cause && typeof cause === "object" && "code" in cause ? ` (${String(cause.code)})` : "";
52
+ return new Error(`Scope cannot ${action} ${JSON.stringify(path)}${code}. Check access and retry.`, { cause });
53
+ }
54
+ async function closeDirectory(handle, path, signal) {
55
+ try {
56
+ await handle.close();
57
+ } catch (error) {
58
+ signal?.throwIfAborted();
59
+ throw scanError("close directory", path, error);
60
+ }
61
+ signal?.throwIfAborted();
62
+ }
63
+ async function discoverScopeFiles(cwd, showHiddenFiles, signal) {
64
+ const files = [];
65
+ let entryCount = 0;
66
+ const directories = [{ directory: resolve(cwd), depth: 0 }];
67
+ for (const { directory, depth } of directories) {
68
+ signal?.throwIfAborted();
69
+ if (depth > MAX_SCOPE_DEPTH) {
70
+ throw new Error(`Scope scan exceeds depth ${MAX_SCOPE_DEPTH}. Open a narrower working directory.`);
71
+ }
72
+ let handle;
73
+ try {
74
+ handle = await fs.opendir(directory);
75
+ } catch (error) {
76
+ signal?.throwIfAborted();
77
+ throw scanError("open directory", directory, error);
78
+ }
79
+ try {
80
+ signal?.throwIfAborted();
81
+ while (true) {
82
+ let entry;
83
+ try {
84
+ entry = await handle.read();
85
+ } catch (error) {
86
+ signal?.throwIfAborted();
87
+ throw scanError("read directory", directory, error);
88
+ }
89
+ signal?.throwIfAborted();
90
+ if (!entry) break;
91
+ entryCount += 1;
92
+ if (entryCount > MAX_SCOPE_ENTRIES) {
93
+ throw new Error(`Scope scan exceeds ${MAX_SCOPE_ENTRIES} entries. Open a narrower working directory.`);
94
+ }
95
+ if (entry.isSymbolicLink() || !showHiddenFiles && entry.name.startsWith(".")) continue;
96
+ if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
97
+ const path = resolve(directory, entry.name);
98
+ let stats;
99
+ try {
100
+ stats = await fs.lstat(path);
101
+ } catch (error) {
102
+ signal?.throwIfAborted();
103
+ throw scanError("inspect entry", path, error);
104
+ }
105
+ signal?.throwIfAborted();
106
+ if (stats.isSymbolicLink()) continue;
107
+ if (stats.isDirectory()) {
108
+ if (!excludedDirectories.has(entry.name)) directories.push({ directory: path, depth: depth + 1 });
109
+ } else if (stats.isFile()) {
110
+ files.push(path);
111
+ if (files.length > MAX_SCOPE_FILES) {
112
+ throw new Error(`Scope scan exceeds ${MAX_SCOPE_FILES} files. Open a narrower working directory.`);
113
+ }
114
+ }
115
+ }
116
+ } finally {
117
+ await closeDirectory(handle, directory, signal);
118
+ }
119
+ }
120
+ signal?.throwIfAborted();
121
+ return files.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
122
+ }
123
+
124
+ // src/scope/picker.ts
125
+ import { isAbsolute, relative, sep } from "node:path";
126
+ import {
127
+ fuzzyFilter,
128
+ SelectList,
129
+ stripTerminalSequences,
130
+ visibleWidth
131
+ } from "@earendil-works/pi-tui";
132
+ import { runCustomInteraction } from "@narumitw/pi-tui-kit";
133
+
134
+ // src/scope/picker-keys.ts
135
+ import { getKeybindings, matchesKey, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
136
+ import { formatInteractionHints } from "@narumitw/pi-tui-kit";
137
+ var selection = [
138
+ ["cancel", "tui.select.cancel"],
139
+ ["up", "tui.select.up"],
140
+ ["down", "tui.select.down"],
141
+ ["pageUp", "tui.select.pageUp"],
142
+ ["pageDown", "tui.select.pageDown"],
143
+ ["apply", "tui.select.confirm"]
144
+ ];
145
+ var editing = [
146
+ "tui.editor.undo",
147
+ "tui.input.submit",
148
+ "tui.editor.deleteCharBackward",
149
+ "tui.editor.deleteCharForward",
150
+ "tui.editor.deleteWordBackward",
151
+ "tui.editor.deleteWordForward",
152
+ "tui.editor.deleteToLineStart",
153
+ "tui.editor.deleteToLineEnd",
154
+ "tui.editor.yank",
155
+ "tui.editor.yankPop",
156
+ "tui.editor.cursorLeft",
157
+ "tui.editor.cursorRight",
158
+ "tui.editor.cursorLineStart",
159
+ "tui.editor.cursorLineEnd",
160
+ "tui.editor.cursorWordLeft",
161
+ "tui.editor.cursorWordRight"
162
+ ];
163
+ var viewportBindings = Object.keys(TUI_KEYBINDINGS).filter(
164
+ (binding) => binding.startsWith("tui.altScreen.") && !binding.startsWith("tui.altScreen.search")
165
+ );
166
+ var hintInputs = [
167
+ "\r",
168
+ "\n",
169
+ "\x1BOM",
170
+ "\x1B",
171
+ " ",
172
+ "\x1B[Z",
173
+ "",
174
+ "",
175
+ "\x1Bo",
176
+ "\x1B[111;3u",
177
+ "\x1B[A",
178
+ "\x1B[B",
179
+ "\x1B[5~",
180
+ "\x1B[6~",
181
+ ...["OP", "OQ", "OR", "OS", "[15~", "[17~", "[18~", "[19~", "[20~", "[21~", "[23~", "[24~"].map(
182
+ (suffix) => `\x1B${suffix}`
183
+ ),
184
+ ..."abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 "
185
+ ];
186
+ function pickerKeys(tui, keybindings) {
187
+ const hostAllows = (data) => {
188
+ if (tui.onDebug && matchesKey(data, "shift+ctrl+d")) return false;
189
+ if (tui.mode !== "fullscreen") return true;
190
+ const host = getKeybindings();
191
+ if (host.matches(data, "tui.altScreen.search")) return false;
192
+ return tui.hasOverlay() || !viewportBindings.some((binding) => host.matches(data, binding));
193
+ };
194
+ const filesAction = (data) => {
195
+ if (matchesKey(data, "ctrl+c")) return "close";
196
+ for (const [name, binding] of selection) if (keybindings.matches(data, binding)) return name;
197
+ if (data === "\n" || editing.some((binding) => keybindings.matches(data, binding))) return "input";
198
+ if (keybindings.matches(data, "tui.input.tab")) return "mark";
199
+ if (matchesKey(data, "shift+tab")) return "markUp";
200
+ if (keybindings.matches(data, "app.session.togglePath")) return "paths";
201
+ };
202
+ const addKey = () => ["ctrl+o", "alt+o"].find((key) => {
203
+ const inputs = hintInputs.filter((input) => matchesKey(input, key));
204
+ return inputs.length > 0 && inputs.every((input) => hostAllows(input) && filesAction(input) === void 0);
205
+ });
206
+ const action = (data, panel) => {
207
+ if (panel === "files") {
208
+ const native = filesAction(data);
209
+ if (native) return native;
210
+ const add = addKey();
211
+ if (add && matchesKey(data, add)) return "add";
212
+ return;
213
+ }
214
+ if (matchesKey(data, "ctrl+c")) return "close";
215
+ if (keybindings.matches(data, "tui.select.cancel")) return "cancel";
216
+ if (keybindings.matches(data, "tui.editor.undo")) return "input";
217
+ if (keybindings.matches(data, "tui.input.submit") || data === "\n") return "submit";
218
+ return "input";
219
+ };
220
+ const bindings = (panel) => ({
221
+ getKeys(name) {
222
+ const binding = name === "mark" ? "tui.input.tab" : name === "paths" ? "app.session.togglePath" : name === "submit" ? "tui.input.submit" : selection.find(([action2]) => action2 === name)?.[1];
223
+ const candidates = name === "markUp" ? ["shift+tab"] : name === "add" ? [addKey()].filter((key2) => key2 !== void 0) : binding ? keybindings.getKeys(binding) : [];
224
+ const key = candidates.find((key2) => {
225
+ const inputs = hintInputs.filter((input) => matchesKey(input, key2));
226
+ return inputs.length > 0 && inputs.every((input) => hostAllows(input) && action(input, panel) === name);
227
+ });
228
+ return key ? [key] : [];
229
+ }
230
+ });
231
+ const label = (name, text, panel = "files") => formatInteractionHints(bindings(panel), [{ bindings: [name], label: text }]) || text;
232
+ const hints = (panel, count, busy) => {
233
+ const keys = bindings(panel);
234
+ const controls = formatInteractionHints(keys, [
235
+ ...!busy ? panel === "files" ? [
236
+ { bindings: ["mark"], label: "select" },
237
+ { bindings: ["apply"], label: count ? "apply" : "apply (select files)" },
238
+ { bindings: ["markUp"], label: "select \u2191" }
239
+ ] : [{ bindings: ["submit"], label: "add file" }] : [],
240
+ { bindings: ["cancel"], excludeKeys: ["ctrl+c"], label: panel === "files" ? "cancel" : "back" },
241
+ ...!keys.getKeys("cancel").length && hostAllows("") ? [{ keys: ["ctrl+c"], label: "close" }] : []
242
+ ]);
243
+ return panel === "files" ? `${count} selected \u2022 ${controls}` : controls;
244
+ };
245
+ return { action, hints, label };
246
+ }
247
+
248
+ // src/scope/picker.ts
249
+ var LIST_ROWS = 10;
250
+ function outsideCwd(cwd, path) {
251
+ const fromCwd = relative(cwd, path);
252
+ return isAbsolute(fromCwd) || fromCwd === ".." || fromCwd.startsWith(`..${sep}`);
253
+ }
254
+ function showScopePicker(ctx, options) {
255
+ return runCustomInteraction(ctx, {
256
+ signal: options.signal,
257
+ isCurrent: options.isCurrent,
258
+ create: ({ tui, theme, keybindings, signal: ownerSignal, complete }) => {
259
+ const controller = new AbortController();
260
+ const signal = AbortSignal.any([ownerSignal, controller.signal]);
261
+ const placeholder = (text) => theme.fg("dim", text);
262
+ const search = new ScopeInput("Search files\u2026", placeholder);
263
+ const pathInput = new ScopeInput("Existing file path\u2026", placeholder);
264
+ const keys = pickerKeys(tui, keybindings);
265
+ const checked = new Set(options.targets.map((target) => target.path));
266
+ const added = /* @__PURE__ */ new Map();
267
+ let panel = "files";
268
+ let focused = false;
269
+ let fullPaths = false;
270
+ let paths = [];
271
+ let rows = [];
272
+ let list;
273
+ let loading = true;
274
+ let validating;
275
+ let operation;
276
+ let pending = Promise.resolve();
277
+ let disposed = false;
278
+ let closing = false;
279
+ let error = "";
280
+ let notice = "";
281
+ let pressed;
282
+ let mouseLayout;
283
+ const cancelPress = () => {
284
+ if (pressed) pressed = { kind: "cancelled" };
285
+ };
286
+ const relativePath = (path) => outsideCwd(options.cwd, path) ? path : relative(options.cwd, path) || ".";
287
+ const pathLabel = (path) => displayFilePath(fullPaths ? path : relativePath(path));
288
+ const syncFocus = () => {
289
+ search.input.focused = focused && panel === "files";
290
+ pathInput.input.focused = focused && panel === "path";
291
+ };
292
+ const current = () => {
293
+ if (disposed || closing || signal.aborted) return false;
294
+ if (options.isCurrent()) return true;
295
+ closing = true;
296
+ complete({ kind: "cancelled", reason: "close" });
297
+ controller.abort();
298
+ return false;
299
+ };
300
+ const close = (value) => {
301
+ if (!current()) return;
302
+ closing = true;
303
+ complete(value);
304
+ controller.abort();
305
+ };
306
+ const rebuild = (selectedId = list?.getSelectedItem()?.value) => {
307
+ cancelPress();
308
+ const query = displayPath(search.input.getValue()).slice(1, -1);
309
+ rows = fuzzyFilter(paths, query, (path) => displayPath(relativePath(path)).slice(1, -1)).map((path) => ({
310
+ value: path,
311
+ label: `${checked.has(path) ? "[x]" : "[ ]"} ${outsideCwd(options.cwd, path) ? "(outside) " : ""}${pathLabel(path)}`
312
+ }));
313
+ list = new SelectList(rows, LIST_ROWS, {
314
+ selectedPrefix: (text) => theme.fg("accent", text),
315
+ selectedText: (text) => theme.fg("accent", text),
316
+ description: (text) => theme.fg("muted", text),
317
+ scrollInfo: (text) => theme.fg("dim", text),
318
+ noMatch: (text) => theme.fg("warning", text)
319
+ });
320
+ list.setSelectedIndex(
321
+ Math.max(
322
+ 0,
323
+ rows.findIndex((row) => row.value === selectedId)
324
+ )
325
+ );
326
+ };
327
+ const runValidation = (kind, work) => {
328
+ const owned = new AbortController();
329
+ const operationSignal = AbortSignal.any([signal, owned.signal]);
330
+ operation = owned;
331
+ validating = kind;
332
+ error = "";
333
+ pending = (async () => {
334
+ try {
335
+ await work(operationSignal);
336
+ } catch (failure) {
337
+ if (current() && !operationSignal.aborted) error = displayError(failure);
338
+ } finally {
339
+ if (operation === owned) {
340
+ operation = void 0;
341
+ validating = void 0;
342
+ }
343
+ if (current()) tui.requestRender();
344
+ }
345
+ })();
346
+ };
347
+ const apply = () => {
348
+ if (!current() || loading || validating) return;
349
+ const selectedPaths = paths.filter((path) => checked.has(path));
350
+ if (!selectedPaths.length) {
351
+ error = "Select at least one file first.";
352
+ return;
353
+ }
354
+ runValidation("selection", async (operationSignal) => {
355
+ const targets = await options.validate(selectedPaths, operationSignal, [...added.values()]);
356
+ if (current() && !operationSignal.aborted) close({ kind: "selected", targets });
357
+ });
358
+ };
359
+ const addPath = () => {
360
+ if (!current() || loading || validating) return;
361
+ const raw = pathInput.input.getValue();
362
+ if (!raw.length) {
363
+ error = "Enter an existing file path.";
364
+ return;
365
+ }
366
+ runValidation("path", async (operationSignal) => {
367
+ const target = await options.resolvePath(raw, operationSignal);
368
+ if (!current() || operationSignal.aborted) return;
369
+ if (!added.has(target.path) && added.size >= MAX_TARGETS) {
370
+ throw new Error(`Add no more than ${MAX_TARGETS} distinct paths in one picker.`);
371
+ }
372
+ const previous = list.getSelectedItem()?.value;
373
+ added.set(target.path, { path: target.path, canonical: target.canonical });
374
+ if (!paths.includes(target.path)) paths.push(target.path);
375
+ checked.add(target.path);
376
+ pathInput.input.setValue("");
377
+ panel = "files";
378
+ syncFocus();
379
+ rebuild(target.path);
380
+ if (!rows.some((row) => row.value === target.path))
381
+ list.setSelectedIndex(
382
+ Math.max(
383
+ 0,
384
+ rows.findIndex((row) => row.value === previous)
385
+ )
386
+ );
387
+ notice = `Added ${pathLabel(target.path)}`;
388
+ });
389
+ };
390
+ const mark = (id, move = 0) => {
391
+ if (!current() || loading || validating) return;
392
+ const index = rows.findIndex((row) => row.value === id);
393
+ if (index < 0) return;
394
+ if (checked.has(id)) checked.delete(id);
395
+ else checked.add(id);
396
+ error = "";
397
+ notice = "";
398
+ const next = rows[Math.max(0, Math.min(rows.length - 1, index + move))]?.value;
399
+ rebuild(next);
400
+ };
401
+ const perform = (command) => {
402
+ if (command === "close") close({ kind: "cancelled", reason: "close" });
403
+ else if (command === "cancel") {
404
+ if (panel === "files") close({ kind: "cancelled", reason: "back" });
405
+ else {
406
+ operation?.abort();
407
+ panel = "files";
408
+ error = "";
409
+ syncFocus();
410
+ }
411
+ } else if (loading || validating) return;
412
+ else if (command === "apply") apply();
413
+ else if (command === "submit") addPath();
414
+ else if (command === "add") {
415
+ panel = "path";
416
+ error = "";
417
+ notice = "";
418
+ cancelPress();
419
+ syncFocus();
420
+ } else if (command === "paths") {
421
+ fullPaths = !fullPaths;
422
+ rebuild();
423
+ } else if (command === "mark" || command === "markUp") {
424
+ const id = list.getSelectedItem()?.value;
425
+ if (id) mark(id, command === "mark" ? 1 : -1);
426
+ } else if (command === "up" || command === "down" || command === "pageUp" || command === "pageDown") {
427
+ const index = rows.findIndex((row) => row.value === list.getSelectedItem()?.value);
428
+ if (index < 0) return;
429
+ if (command === "pageUp" || command === "pageDown")
430
+ list.setSelectedIndex(index + (command === "pageUp" ? -LIST_ROWS : LIST_ROWS));
431
+ else list.setSelectedIndex((index + (command === "up" ? -1 : 1) + rows.length) % rows.length);
432
+ }
433
+ };
434
+ const handleInput = (data) => {
435
+ if (!current()) return;
436
+ cancelPress();
437
+ const activeInput = search.isPasting ? search : pathInput.isPasting ? pathInput : panel === "files" ? search : pathInput;
438
+ const previous = activeInput.input.getValue();
439
+ let remaining;
440
+ activeInput.handleInput(
441
+ data,
442
+ (data2) => {
443
+ const command = keys.action(data2, panel);
444
+ if (command === "cancel" || command === "close") {
445
+ perform(command);
446
+ return true;
447
+ }
448
+ if (validating) return true;
449
+ if (!command || command === "input") return false;
450
+ perform(command);
451
+ return true;
452
+ },
453
+ !validating,
454
+ (tail) => {
455
+ remaining = tail;
456
+ }
457
+ );
458
+ if (!current()) return;
459
+ if (activeInput.input.getValue() !== previous) {
460
+ error = "";
461
+ notice = "";
462
+ if (activeInput === search && !loading) rebuild("");
463
+ }
464
+ if (remaining) handleInput(remaining);
465
+ if (current()) tui.requestRender();
466
+ };
467
+ rebuild();
468
+ pending = (async () => {
469
+ try {
470
+ const discovered = await options.discover(signal);
471
+ if (!current()) return;
472
+ paths = [.../* @__PURE__ */ new Set([...options.targets.map((target) => target.path), ...discovered])];
473
+ loading = false;
474
+ rebuild();
475
+ tui.requestRender();
476
+ } catch (failure) {
477
+ if (current()) close({ kind: "error", error: failure });
478
+ }
479
+ })();
480
+ return {
481
+ get focused() {
482
+ return focused;
483
+ },
484
+ set focused(value) {
485
+ focused = value;
486
+ syncFocus();
487
+ },
488
+ render(width) {
489
+ const safeWidth = Math.max(1, width);
490
+ const activeInput = panel === "files" ? search : pathInput;
491
+ const pasting = search.isPasting || pathInput.isPasting;
492
+ const busy = loading || validating !== void 0 || pasting;
493
+ const selectedIndex = rows.findIndex((row) => row.value === list.getSelectedItem()?.value);
494
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(LIST_ROWS / 2), rows.length - LIST_ROWS));
495
+ const activeRow = rows[selectedIndex];
496
+ const content = [...activeInput.render(safeWidth)];
497
+ const filesStart = content.length + 1;
498
+ let focusedRow = 0;
499
+ if (panel === "files") {
500
+ const listRows = loading ? [theme.fg("dim", "Scanning files\u2026")] : rows.length ? list.render(safeWidth) : [theme.fg("dim", "No matching files")];
501
+ content.push("", ...listRows);
502
+ focusedRow = rows.length && !loading ? filesStart + selectedIndex - start : 0;
503
+ const target = added.get(activeRow?.value ?? "") ?? options.targets.find((target2) => target2.path === activeRow?.value);
504
+ const preview = [
505
+ ...activeRow && !stripTerminalSequences(listRows[selectedIndex - start] ?? "").includes(activeRow.label) ? [`Path: ${pathLabel(activeRow.value)}`] : [],
506
+ ...target && (outsideCwd(options.cwd, target.path) || outsideCwd(options.cwd, target.canonical)) ? ["Outside the current directory."] : [],
507
+ ...target && target.path !== target.canonical ? [`Canonical target: ${pathLabel(target.canonical)}`] : []
508
+ ];
509
+ content.push(
510
+ ...preview.flatMap((line) => exactRows(line, safeWidth).map((part) => theme.fg("muted", part)))
511
+ );
512
+ } else if (pathInput.input.getValue()) {
513
+ content.push(
514
+ ...exactRows(`Path: ${displayPath(pathInput.input.getValue())}`, safeWidth).map(
515
+ (line) => theme.fg("muted", line)
516
+ )
517
+ );
518
+ }
519
+ if (notice) content.push(...exactRows(notice, safeWidth).map((line) => theme.fg("muted", line)));
520
+ if (error) content.push(...exactRows(error, safeWidth).map((line) => theme.fg("error", line)));
521
+ if (validating)
522
+ content.push(
523
+ theme.fg(
524
+ "dim",
525
+ operation?.signal.aborted ? "Finishing file check\u2026" : validating === "path" ? "Checking file\u2026" : "Checking selected files\u2026"
526
+ )
527
+ );
528
+ const pathModeLabel = fullPaths ? "Relative paths" : "Full paths";
529
+ const buttons = panel === "files" ? [
530
+ { action: "apply", label: options.completionLabel, enabled: !busy && checked.size > 0 },
531
+ { action: "add", label: pasting ? "Add file\u2026" : keys.label("add", "Add file\u2026"), enabled: !busy },
532
+ {
533
+ action: "paths",
534
+ label: pasting ? pathModeLabel : keys.label("paths", pathModeLabel),
535
+ enabled: !busy
536
+ }
537
+ ] : [
538
+ { action: "cancel", label: "Back", enabled: !pasting },
539
+ { action: "submit", label: "Add file", enabled: !busy && pathInput.input.getValue().length > 0 }
540
+ ];
541
+ let column = 0;
542
+ const spans = buttons.map((button) => {
543
+ const text = `[ ${button.label} ]`;
544
+ const span = {
545
+ start: column,
546
+ end: column + visibleWidth(text),
547
+ action: button.action,
548
+ enabled: button.enabled
549
+ };
550
+ column = span.end + 2;
551
+ return { ...span, text: theme.fg(button.enabled ? "muted" : "dim", text) };
552
+ });
553
+ const buttonRow = content.length;
554
+ content.push(spans.map((span) => span.text).join(" "));
555
+ const frame = scopeFrame(
556
+ tui,
557
+ theme,
558
+ width,
559
+ panel === "files" ? "Select files" : "Add existing file",
560
+ [`Cwd: ${displayFilePath(options.cwd)}`],
561
+ content,
562
+ [focusedRow, 0, buttonRow],
563
+ focusedRow,
564
+ [pasting ? `${checked.size} selected \u2022 Pasting\u2026` : keys.hints(panel, checked.size, busy)]
565
+ );
566
+ const buttonFrameRow = frame.contentRows.find((row) => row.contentIndex === buttonRow)?.frameIndex;
567
+ mouseLayout = {
568
+ width,
569
+ input: frame.contentRows.find((row) => row.contentIndex === 0)?.frameIndex,
570
+ rows: new Map(
571
+ panel === "files" ? frame.contentRows.flatMap((row) => {
572
+ const native = row.contentIndex - filesStart;
573
+ const id = rows[start + native]?.value;
574
+ return native >= 0 && native < Math.min(LIST_ROWS, rows.length) && id ? [[row.frameIndex, id]] : [];
575
+ }) : []
576
+ ),
577
+ buttons: buttonFrameRow === void 0 ? [] : spans.filter((span) => span.enabled && span.end <= width).map((span) => ({ row: buttonFrameRow, start: span.start, end: span.end, action: span.action }))
578
+ };
579
+ return frame.lines;
580
+ },
581
+ handleInput,
582
+ handleMouse(event) {
583
+ if (!current() || !mouseLayout || event.width !== mouseLayout.width || search.isPasting || pathInput.isPasting)
584
+ return;
585
+ if (event.type === "click" && event.button === "left" && pressed) {
586
+ const target = pressed;
587
+ pressed = void 0;
588
+ if (target.kind === "file") mark(target.id);
589
+ else if (target.kind === "button") perform(target.action);
590
+ return { handled: true, render: target.kind !== "cancelled" };
591
+ }
592
+ if (event.type === "press" && event.button === "left") pressed = void 0;
593
+ const button = mouseLayout.buttons.find(
594
+ (button2) => button2.row === event.y && event.x >= button2.start && event.x < button2.end
595
+ );
596
+ if (button && event.button === "left") {
597
+ if (event.type === "press") pressed = { kind: "button", action: button.action };
598
+ else if (event.type === "click") perform(button.action);
599
+ else return;
600
+ return { handled: true, render: true, focus: event.type === "press" };
601
+ }
602
+ if (loading || validating) return;
603
+ if (event.y === mouseLayout.input) {
604
+ const result = (panel === "files" ? search : pathInput).handleMouse({ ...event, y: 0, height: 1 });
605
+ if (event.type === "press" && result?.handled) pressed = { kind: "cancelled" };
606
+ return result;
607
+ }
608
+ const id = mouseLayout.rows.get(event.y);
609
+ if (!id) return;
610
+ if (event.type === "wheel" && event.wheelDelta) {
611
+ cancelPress();
612
+ const selected = rows.findIndex((row) => row.value === list.getSelectedItem()?.value);
613
+ list.setSelectedIndex(Math.max(0, Math.min(rows.length - 1, selected + (event.wheelDelta < 0 ? -1 : 1))));
614
+ } else if (event.type === "press" && event.button === "left") {
615
+ pressed = { kind: "file", id };
616
+ list.setSelectedIndex(rows.findIndex((row) => row.value === id));
617
+ } else if (event.type === "click" && event.button === "left") mark(id);
618
+ else return;
619
+ return { handled: true, render: true, focus: event.type === "press" };
620
+ },
621
+ invalidate() {
622
+ cancelPress();
623
+ mouseLayout = void 0;
624
+ search.input.invalidate();
625
+ pathInput.input.invalidate();
626
+ list.invalidate();
627
+ },
628
+ waitForPending: () => pending,
629
+ dispose() {
630
+ if (disposed) return;
631
+ disposed = true;
632
+ pressed = void 0;
633
+ controller.abort();
634
+ }
635
+ };
636
+ }
637
+ });
638
+ }
639
+
640
+ // src/scope/menu.ts
641
+ function stateLines(view) {
642
+ return [
643
+ `Cwd: ${displayPath(view.cwd)}`,
644
+ view.state === "inactive" ? "Not restricted" : `Editing limited to ${view.targets.length} file${view.targets.length === 1 ? "" : "s"}`,
645
+ ...view.state === "blocked" ? ["Restriction blocked: repair the allowed files or explicitly end the restriction."] : [],
646
+ ...view.error ? [displayError(view.error)] : []
647
+ ];
648
+ }
649
+ async function showScopeMenu(context, options) {
650
+ if (context.mode !== "tui" || !context.hasUI) throw new Error("/edit-scope requires interactive TUI mode.");
651
+ const ctx = scopeOverlayContext(context);
652
+ const current = () => !options.signal.aborted && options.isCurrent();
653
+ const notify = (error) => {
654
+ if (current()) ctx.ui.notify(displayError(error), "error");
655
+ };
656
+ let pendingNew;
657
+ const definition = defineMenu({
658
+ start: "main",
659
+ screens: {
660
+ main: ({ state }) => ({
661
+ kind: "actions",
662
+ title: "Edit Scope",
663
+ lines: stateLines(state),
664
+ hint: "close",
665
+ items: [
666
+ {
667
+ id: "select",
668
+ label: state.state === "inactive" ? "Select files\u2026" : "Change allowed files\u2026",
669
+ action: "select"
670
+ },
671
+ { id: "new", label: state.state === "inactive" ? "New file\u2026" : "Use only a new file\u2026", action: "new" },
672
+ { id: "settings", label: "Settings", to: "settings" },
673
+ { id: "status", label: "Status", to: "status" },
674
+ { id: "help", label: "Help", to: "help" },
675
+ ...state.state === "inactive" ? [] : [{ id: "off", label: "End restriction", action: "off" }],
676
+ { id: "close", label: "Close", close: true }
677
+ ]
678
+ }),
679
+ "new-review": ({ state }) => {
680
+ if (!pendingNew) throw new Error("No new-file target to review.");
681
+ const outside = [pendingNew.path, pendingNew.canonical].some((path) => {
682
+ const fromCwd = relative2(state.cwd, path);
683
+ return isAbsolute2(fromCwd) || fromCwd === ".." || fromCwd.startsWith(`..${sep2}`);
684
+ });
685
+ return {
686
+ kind: "review",
687
+ title: "Review new file",
688
+ content: [
689
+ ...state.state === "inactive" ? [] : ["This replaces the complete allowed-file set with one file."],
690
+ ...outside ? ["Outside the current directory."] : [],
691
+ ...pendingNew.canonical !== pendingNew.path ? ["The canonical target differs from the entered path."] : [],
692
+ `Path: ${displayPath(pendingNew.path)}`,
693
+ ...pendingNew.canonical !== pendingNew.path ? [`Canonical target: ${displayPath(pendingNew.canonical)}`] : [],
694
+ "Reserved only. The first normal write creates the file and parent directories."
695
+ ].join("\n"),
696
+ format: { kind: "text" },
697
+ viewportSize: "adaptive",
698
+ confirm: {
699
+ id: "apply-new",
700
+ label: state.state === "inactive" ? "Start restriction" : "Replace allowed files with this new file",
701
+ action: "apply-new"
702
+ },
703
+ hint: "back"
704
+ };
705
+ },
706
+ settings: () => ({
707
+ kind: "settings",
708
+ title: "Edit Scope Settings",
709
+ lines: ["User setting. Changes are saved immediately."],
710
+ items: [
711
+ {
712
+ id: "hidden",
713
+ label: "Show hidden files",
714
+ description: "Include hidden files and directories in the picker. This does not change the active restriction.",
715
+ currentValue: options.getSettings().showHiddenFiles ? "true" : "false",
716
+ values: ["false", "true"],
717
+ action: "hidden"
718
+ }
719
+ ]
720
+ }),
721
+ status: ({ state }) => ({
722
+ kind: "review",
723
+ title: "Edit Scope Status",
724
+ lines: stateLines(state),
725
+ content: [
726
+ ...state.targets.flatMap((target, index) => [
727
+ `${index + 1}. ${displayPath(target.path)}`,
728
+ ...target.path === target.canonical ? [] : [` Canonical: ${displayPath(target.canonical)}`]
729
+ ]),
730
+ "",
731
+ "Missing selected paths are reserved. The first normal write creates the file and parent directories.",
732
+ "While restricted, write/edit are limited to exact allowed targets. Supported readers remain available.",
733
+ "Shell commands, tests, formatters, and opaque writing/delegation tools are blocked; use /edit-scope off first.",
734
+ "This guard is not an OS sandbox. User shell commands, other programs, filesystem races, and trusted extensions are outside its boundary."
735
+ ].join("\n"),
736
+ format: { kind: "text" },
737
+ viewportSize: "adaptive",
738
+ hint: "back"
739
+ }),
740
+ help: () => ({
741
+ kind: "detail",
742
+ title: "Edit Scope Help",
743
+ lines: [
744
+ "Select files to start a restriction or replace the complete allowed-file set.",
745
+ "Typing searches files, including spaces and directory fragments. By default, Tab toggles and moves down, Shift+Tab toggles and moves up, and Enter applies the checked files. Marking stops at the first or last match. The footer follows your configured keys.",
746
+ "Project paths are relative to cwd by default. Use Full paths to switch views without changing the search or selected files.",
747
+ "Use Add file for an existing full, ~/ or relative path, including files outside cwd. This separate field preserves the search and checked draft; adding does not apply the restriction.",
748
+ "New file reserves one target; it replaces all allowed files and creates no content here.",
749
+ "Cancelling a draft, closing this menu, and run completion do not release the restriction.",
750
+ "Use End restriction or /edit-scope off to release it explicitly.",
751
+ "Discovery starts at cwd, skips symlinks and fixed generated/dependency/version-control directories, and does not use .gitignore.",
752
+ "Settings controls only hidden-file discovery. Selected targets remain visible even when discovery would exclude them.",
753
+ "Status lists every selected path and the tool limitations."
754
+ ],
755
+ hint: "back"
756
+ })
757
+ },
758
+ actions: {
759
+ select: async ({ signal }) => {
760
+ const view = options.getView();
761
+ const live = new Map(view.targets.map((target) => [target.path, target]));
762
+ const result = await showScopePicker(ctx, {
763
+ cwd: view.cwd,
764
+ targets: view.targets,
765
+ completionLabel: view.state === "inactive" ? "Start restriction" : "Apply changes",
766
+ signal,
767
+ isCurrent: current,
768
+ discover: (scanSignal) => discoverScopeFiles(view.cwd, options.getSettings().showHiddenFiles, scanSignal),
769
+ resolvePath: async (raw, validationSignal) => {
770
+ const target = await resolveScopeTarget(raw, view.cwd, "existing", validationSignal);
771
+ if (!current() || validationSignal.aborted)
772
+ throw validationSignal.reason ?? new Error("Scope flow is stale.");
773
+ return target;
774
+ },
775
+ validate: async (paths, validationSignal, added) => {
776
+ if (paths.length > MAX_TARGETS) throw new Error(`Select no more than ${MAX_TARGETS} files.`);
777
+ const manual = new Map(added.map((target) => [target.path, target]));
778
+ const known = new Map([...live, ...manual]);
779
+ const targets = [];
780
+ for (const path of paths) {
781
+ const nativePath = pathToFileURL(path).href;
782
+ const existing = known.get(path);
783
+ if (existing) {
784
+ let checked = existing;
785
+ if (manual.has(path)) {
786
+ checked = await resolveScopeTarget(nativePath, view.cwd, "existing", validationSignal);
787
+ if (!current() || validationSignal.aborted)
788
+ throw validationSignal.reason ?? new Error("Scope flow is stale.");
789
+ if (checked.canonical !== existing.canonical)
790
+ throw new Error(`The added target changed: ${displayPath(path)}. Add it again.`);
791
+ }
792
+ await assertScopeTarget(nativePath, view.cwd, [existing], validationSignal);
793
+ if (!current() || validationSignal.aborted)
794
+ throw validationSignal.reason ?? new Error("Scope flow is stale.");
795
+ targets.push(checked);
796
+ } else {
797
+ const target = await resolveScopeTarget(nativePath, view.cwd, "existing", validationSignal);
798
+ if (!current() || validationSignal.aborted)
799
+ throw validationSignal.reason ?? new Error("Scope flow is stale.");
800
+ targets.push(target);
801
+ }
802
+ }
803
+ createSnapshot(view.cwd, targets);
804
+ return targets;
805
+ }
806
+ });
807
+ if (!current() || signal.aborted || result.kind === "stale") return { kind: "close" };
808
+ if (result.kind !== "completed") return { kind: "stay" };
809
+ if (result.value.kind === "cancelled") return { kind: result.value.reason === "close" ? "close" : "stay" };
810
+ if (result.value.kind === "error") {
811
+ notify(result.value.error);
812
+ return { kind: "stay" };
813
+ }
814
+ if (result.value.targets.length === 0) {
815
+ ctx.ui.notify("No files selected. The restriction is unchanged.", "info");
816
+ return { kind: "stay" };
817
+ }
818
+ const selected = result.value.targets;
819
+ const applied = await runTask(ctx, {
820
+ label: "Applying editing scope\u2026",
821
+ signal,
822
+ isCurrent: current,
823
+ task: ({ signal: operation }) => options.apply(selected, false, operation),
824
+ onError: (_ctx, error) => notify(error)
825
+ });
826
+ return { kind: applied.kind === "completed" || applied.kind === "stale" ? "close" : "stay" };
827
+ },
828
+ new: async ({ signal }) => {
829
+ const view = options.getView();
830
+ const result = await showScopePathInput(ctx, {
831
+ cwd: view.cwd,
832
+ signal,
833
+ isCurrent: current
834
+ });
835
+ if (!current() || signal.aborted || result.kind === "stale") return { kind: "close" };
836
+ if (result.kind !== "completed") return { kind: "stay" };
837
+ if (result.value.kind === "cancelled") return { kind: result.value.reason === "close" ? "close" : "stay" };
838
+ pendingNew = result.value.target;
839
+ return { kind: "to", screen: "new-review" };
840
+ },
841
+ "apply-new": async ({ signal }) => {
842
+ if (!current() || signal.aborted || !pendingNew) return { kind: "close" };
843
+ const target = pendingNew;
844
+ const applied = await runTask(ctx, {
845
+ label: "Applying new-file scope\u2026",
846
+ signal,
847
+ isCurrent: current,
848
+ task: ({ signal: operation }) => options.apply([target], true, operation),
849
+ onError: (_ctx, error) => notify(error)
850
+ });
851
+ return { kind: applied.kind === "completed" || applied.kind === "stale" ? "close" : "stay" };
852
+ },
853
+ hidden: async ({ value, signal }) => {
854
+ if (!current() || signal.aborted) return { kind: "close" };
855
+ await options.saveShowHiddenFiles(value === "true");
856
+ if (!current() || signal.aborted) return { kind: "close" };
857
+ return { kind: "stay" };
858
+ },
859
+ off: async ({ signal }) => {
860
+ if (!current() || signal.aborted) return { kind: "close" };
861
+ await options.off();
862
+ return { kind: "close" };
863
+ }
864
+ }
865
+ });
866
+ await runMenu(ctx, definition, {
867
+ signal: options.signal,
868
+ isCurrent: current,
869
+ getState: () => options.getView(),
870
+ onError: (_ctx, error) => notify(error)
871
+ });
872
+ }
873
+ export {
874
+ showScopeMenu
875
+ };
876
+ //# sourceMappingURL=menu-P6IBMROH.ts.map