@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,207 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Input, matchesKey, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { runCustomInteraction } from "@narumitw/pi-tui-kit";
|
|
4
|
+
import { displayError, displayPath } from "./display.js";
|
|
5
|
+
import { resolveScopeTarget, type ScopeTarget } from "./paths.js";
|
|
6
|
+
import { exactRows, scopeFrame } from "./ui.js";
|
|
7
|
+
|
|
8
|
+
const PASTE_START = "\u001b[200~";
|
|
9
|
+
const PASTE_END = "\u001b[201~";
|
|
10
|
+
|
|
11
|
+
/** Public Input editing with paste-aware screen dispatch and display-only control masking. */
|
|
12
|
+
export class ScopeInput {
|
|
13
|
+
readonly input: Input;
|
|
14
|
+
private pasting = false;
|
|
15
|
+
private pasteTail = "";
|
|
16
|
+
private discardPaste = false;
|
|
17
|
+
|
|
18
|
+
constructor(placeholder = "", placeholderStyle?: (text: string) => string) {
|
|
19
|
+
this.input = new Input({ placeholder, placeholderStyle });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get isPasting(): boolean {
|
|
23
|
+
return this.pasting;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
handleInput(
|
|
27
|
+
data: string,
|
|
28
|
+
action: (key: string) => boolean,
|
|
29
|
+
editable = true,
|
|
30
|
+
afterPaste?: (remaining: string) => void,
|
|
31
|
+
): void {
|
|
32
|
+
if (this.pasting || data.includes(PASTE_START)) {
|
|
33
|
+
if (!this.pasting) this.discardPaste = !editable;
|
|
34
|
+
const combined = this.pasteTail + data;
|
|
35
|
+
const start = this.pasting ? 0 : combined.indexOf(PASTE_START) + PASTE_START.length;
|
|
36
|
+
const end = combined.indexOf(PASTE_END, start);
|
|
37
|
+
if (end < 0) {
|
|
38
|
+
this.pasting = true;
|
|
39
|
+
this.pasteTail = combined.slice(-5);
|
|
40
|
+
if (!this.discardPaste) this.input.handleInput(data);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const consumed = end + PASTE_END.length - this.pasteTail.length;
|
|
44
|
+
if (!this.discardPaste) this.input.handleInput(data.slice(0, consumed));
|
|
45
|
+
this.pasting = false;
|
|
46
|
+
this.discardPaste = false;
|
|
47
|
+
this.pasteTail = "";
|
|
48
|
+
const remaining = data.slice(consumed);
|
|
49
|
+
if (remaining) {
|
|
50
|
+
if (afterPaste) afterPaste(remaining);
|
|
51
|
+
else this.handleInput(remaining, action, editable);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!action(data) && editable) this.input.handleInput(data);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
render(width: number): string[] {
|
|
59
|
+
return this.withDisplayValue(() => this.input.render(width));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleMouse(event: TuiMouseEvent) {
|
|
63
|
+
return this.withDisplayValue(() => this.input.handleMouse(event));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private withDisplayValue<T>(operation: () => T): T {
|
|
67
|
+
const raw = this.input.getValue();
|
|
68
|
+
// Native rendering and hit-testing must measure the same masked graphemes.
|
|
69
|
+
// Equal UTF-16 length preserves cursor offsets; only the display value changes.
|
|
70
|
+
const safe = raw.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, (character) => "�".repeat(character.length));
|
|
71
|
+
this.input.setValue(safe);
|
|
72
|
+
try {
|
|
73
|
+
return operation();
|
|
74
|
+
} finally {
|
|
75
|
+
this.input.setValue(raw);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type PathInputValue = { kind: "selected"; target: ScopeTarget } | { kind: "cancelled"; reason: "back" | "close" };
|
|
81
|
+
|
|
82
|
+
interface PathInputOptions {
|
|
83
|
+
cwd: string;
|
|
84
|
+
signal: AbortSignal;
|
|
85
|
+
isCurrent(): boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function showScopePathInput(ctx: ExtensionCommandContext, options: PathInputOptions) {
|
|
89
|
+
return runCustomInteraction<PathInputValue>(ctx, {
|
|
90
|
+
signal: options.signal,
|
|
91
|
+
isCurrent: options.isCurrent,
|
|
92
|
+
create: ({ tui, theme, keybindings, signal: ownerSignal, complete }) => {
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
const signal = AbortSignal.any([controller.signal, ownerSignal]);
|
|
95
|
+
const editor = new ScopeInput();
|
|
96
|
+
let error = "";
|
|
97
|
+
let pending = Promise.resolve();
|
|
98
|
+
let submitting = false;
|
|
99
|
+
let disposed = false;
|
|
100
|
+
let closing = false;
|
|
101
|
+
let focused = false;
|
|
102
|
+
let inputRow: number | undefined;
|
|
103
|
+
let frameWidth = 0;
|
|
104
|
+
const current = () => {
|
|
105
|
+
if (disposed || closing || signal.aborted) return false;
|
|
106
|
+
if (options.isCurrent()) return true;
|
|
107
|
+
closing = true;
|
|
108
|
+
// Calling the public completion boundary with stale ownership makes
|
|
109
|
+
// runCustomInteraction settle as stale and drain this component.
|
|
110
|
+
complete({ kind: "cancelled", reason: "close" });
|
|
111
|
+
controller.abort();
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
114
|
+
const close = (value: PathInputValue) => {
|
|
115
|
+
if (!current()) return;
|
|
116
|
+
closing = true;
|
|
117
|
+
complete(value);
|
|
118
|
+
controller.abort();
|
|
119
|
+
};
|
|
120
|
+
const submit = () => {
|
|
121
|
+
if (!current() || submitting) return;
|
|
122
|
+
submitting = true;
|
|
123
|
+
error = "";
|
|
124
|
+
const draft = editor.input.getValue();
|
|
125
|
+
pending = (async () => {
|
|
126
|
+
try {
|
|
127
|
+
const resolved = await resolveScopeTarget(draft, options.cwd, "new", signal);
|
|
128
|
+
if (!current()) return;
|
|
129
|
+
close({ kind: "selected", target: resolved });
|
|
130
|
+
} catch (failure) {
|
|
131
|
+
if (current()) error = displayError(failure);
|
|
132
|
+
} finally {
|
|
133
|
+
submitting = false;
|
|
134
|
+
if (current()) tui.requestRender();
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
};
|
|
138
|
+
editor.input.onSubmit = submit;
|
|
139
|
+
editor.input.onEscape = () => close({ kind: "cancelled", reason: "back" });
|
|
140
|
+
return {
|
|
141
|
+
get focused() {
|
|
142
|
+
return focused;
|
|
143
|
+
},
|
|
144
|
+
set focused(value: boolean) {
|
|
145
|
+
focused = value;
|
|
146
|
+
editor.input.focused = value;
|
|
147
|
+
},
|
|
148
|
+
render(width: number) {
|
|
149
|
+
inputRow = undefined;
|
|
150
|
+
frameWidth = width;
|
|
151
|
+
const safeWidth = Math.max(1, width);
|
|
152
|
+
const content = [
|
|
153
|
+
...editor.render(safeWidth),
|
|
154
|
+
...exactRows(`Draft: ${displayPath(editor.input.getValue())}`, safeWidth).map((line) =>
|
|
155
|
+
theme.fg("muted", line),
|
|
156
|
+
),
|
|
157
|
+
...(error ? exactRows(error, safeWidth).map((line) => theme.fg("error", line)) : []),
|
|
158
|
+
...(submitting ? [theme.fg("dim", "Checking target…")] : []),
|
|
159
|
+
];
|
|
160
|
+
const priorities = [0, ...(error ? [content.length - 1] : [])];
|
|
161
|
+
// No key hints: public host listeners may consume configured keys before
|
|
162
|
+
// component dispatch. No replacement shortcuts or fallback keys are added.
|
|
163
|
+
const frame = scopeFrame(
|
|
164
|
+
tui,
|
|
165
|
+
theme,
|
|
166
|
+
width,
|
|
167
|
+
"New file",
|
|
168
|
+
[
|
|
169
|
+
`Cwd: ${displayPath(options.cwd)}`,
|
|
170
|
+
"Use a cwd-relative or full path. Submit to review; no file content is created here.",
|
|
171
|
+
],
|
|
172
|
+
content,
|
|
173
|
+
priorities,
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
inputRow = frame.contentRows.find((row) => row.contentIndex === 0)?.frameIndex;
|
|
177
|
+
return frame.lines;
|
|
178
|
+
},
|
|
179
|
+
handleInput(data: string) {
|
|
180
|
+
if (!current()) return;
|
|
181
|
+
const action = (key: string) => {
|
|
182
|
+
if (matchesKey(key, "ctrl+c")) close({ kind: "cancelled", reason: "close" });
|
|
183
|
+
else if (keybindings.matches(key, "tui.select.cancel")) close({ kind: "cancelled", reason: "back" });
|
|
184
|
+
else return false;
|
|
185
|
+
return true;
|
|
186
|
+
};
|
|
187
|
+
editor.handleInput(data, action, !submitting);
|
|
188
|
+
tui.requestRender();
|
|
189
|
+
},
|
|
190
|
+
handleMouse(event: TuiMouseEvent) {
|
|
191
|
+
if (!current() || submitting || event.width !== frameWidth || event.y !== inputRow) return;
|
|
192
|
+
return editor.handleMouse({ ...event, y: 0, height: 1 });
|
|
193
|
+
},
|
|
194
|
+
invalidate() {
|
|
195
|
+
inputRow = undefined;
|
|
196
|
+
editor.input.invalidate();
|
|
197
|
+
},
|
|
198
|
+
waitForPending: () => pending,
|
|
199
|
+
dispose() {
|
|
200
|
+
if (disposed) return;
|
|
201
|
+
disposed = true;
|
|
202
|
+
controller.abort();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { defineMenu, runMenu, runTask } from "@narumitw/pi-tui-kit";
|
|
5
|
+
import { displayError, displayPath } from "./display.js";
|
|
6
|
+
import { discoverScopeFiles } from "./files.js";
|
|
7
|
+
import { showScopePathInput } from "./input.js";
|
|
8
|
+
import { assertScopeTarget, resolveScopeTarget, type ScopeTarget } from "./paths.js";
|
|
9
|
+
import { showScopePicker } from "./picker.js";
|
|
10
|
+
import type { ScopeSettings } from "./settings.js";
|
|
11
|
+
import { createSnapshot, MAX_TARGETS } from "./state.js";
|
|
12
|
+
import { scopeOverlayContext } from "./ui.js";
|
|
13
|
+
|
|
14
|
+
export interface ScopeView {
|
|
15
|
+
state: "inactive" | "active" | "blocked";
|
|
16
|
+
cwd: string;
|
|
17
|
+
targets: readonly ScopeTarget[];
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScopeMenuOptions {
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
isCurrent(): boolean;
|
|
24
|
+
getView(): ScopeView;
|
|
25
|
+
getSettings(): ScopeSettings;
|
|
26
|
+
saveShowHiddenFiles(value: boolean): Promise<void>;
|
|
27
|
+
apply(targets: readonly ScopeTarget[], newFile?: boolean, signal?: AbortSignal): Promise<void>;
|
|
28
|
+
off(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Screen = "main" | "settings" | "status" | "help" | "new-review";
|
|
32
|
+
type Action = "select" | "new" | "apply-new" | "hidden" | "off";
|
|
33
|
+
|
|
34
|
+
function stateLines(view: ScopeView): string[] {
|
|
35
|
+
return [
|
|
36
|
+
`Cwd: ${displayPath(view.cwd)}`,
|
|
37
|
+
view.state === "inactive"
|
|
38
|
+
? "Not restricted"
|
|
39
|
+
: `Editing limited to ${view.targets.length} file${view.targets.length === 1 ? "" : "s"}`,
|
|
40
|
+
...(view.state === "blocked"
|
|
41
|
+
? ["Restriction blocked: repair the allowed files or explicitly end the restriction."]
|
|
42
|
+
: []),
|
|
43
|
+
...(view.error ? [displayError(view.error)] : []),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function showScopeMenu(context: ExtensionCommandContext, options: ScopeMenuOptions): Promise<void> {
|
|
48
|
+
if (context.mode !== "tui" || !context.hasUI) throw new Error("/edit-scope requires interactive TUI mode.");
|
|
49
|
+
const ctx = scopeOverlayContext(context);
|
|
50
|
+
const current = () => !options.signal.aborted && options.isCurrent();
|
|
51
|
+
const notify = (error: unknown) => {
|
|
52
|
+
if (current()) ctx.ui.notify(displayError(error), "error");
|
|
53
|
+
};
|
|
54
|
+
let pendingNew: ScopeTarget | undefined;
|
|
55
|
+
const definition = defineMenu<ScopeView, Screen, Action>({
|
|
56
|
+
start: "main",
|
|
57
|
+
screens: {
|
|
58
|
+
main: ({ state }) => ({
|
|
59
|
+
kind: "actions",
|
|
60
|
+
title: "Edit Scope",
|
|
61
|
+
lines: stateLines(state),
|
|
62
|
+
hint: "close",
|
|
63
|
+
items: [
|
|
64
|
+
{
|
|
65
|
+
id: "select",
|
|
66
|
+
label: state.state === "inactive" ? "Select files…" : "Change allowed files…",
|
|
67
|
+
action: "select",
|
|
68
|
+
},
|
|
69
|
+
{ id: "new", label: state.state === "inactive" ? "New file…" : "Use only a new file…", action: "new" },
|
|
70
|
+
{ id: "settings", label: "Settings", to: "settings" },
|
|
71
|
+
{ id: "status", label: "Status", to: "status" },
|
|
72
|
+
{ id: "help", label: "Help", to: "help" },
|
|
73
|
+
...(state.state === "inactive" ? [] : [{ id: "off", label: "End restriction", action: "off" as const }]),
|
|
74
|
+
{ id: "close", label: "Close", close: true },
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
"new-review": ({ state }) => {
|
|
78
|
+
if (!pendingNew) throw new Error("No new-file target to review.");
|
|
79
|
+
const outside = [pendingNew.path, pendingNew.canonical].some((path) => {
|
|
80
|
+
const fromCwd = relative(state.cwd, path);
|
|
81
|
+
return isAbsolute(fromCwd) || fromCwd === ".." || fromCwd.startsWith(`..${sep}`);
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
kind: "review",
|
|
85
|
+
title: "Review new file",
|
|
86
|
+
content: [
|
|
87
|
+
...(state.state === "inactive" ? [] : ["This replaces the complete allowed-file set with one file."]),
|
|
88
|
+
...(outside ? ["Outside the current directory."] : []),
|
|
89
|
+
...(pendingNew.canonical !== pendingNew.path
|
|
90
|
+
? ["The canonical target differs from the entered path."]
|
|
91
|
+
: []),
|
|
92
|
+
`Path: ${displayPath(pendingNew.path)}`,
|
|
93
|
+
...(pendingNew.canonical !== pendingNew.path
|
|
94
|
+
? [`Canonical target: ${displayPath(pendingNew.canonical)}`]
|
|
95
|
+
: []),
|
|
96
|
+
"Reserved only. The first normal write creates the file and parent directories.",
|
|
97
|
+
].join("\n"),
|
|
98
|
+
format: { kind: "text" },
|
|
99
|
+
viewportSize: "adaptive",
|
|
100
|
+
confirm: {
|
|
101
|
+
id: "apply-new",
|
|
102
|
+
label: state.state === "inactive" ? "Start restriction" : "Replace allowed files with this new file",
|
|
103
|
+
action: "apply-new",
|
|
104
|
+
},
|
|
105
|
+
hint: "back",
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
settings: () => ({
|
|
109
|
+
kind: "settings",
|
|
110
|
+
title: "Edit Scope Settings",
|
|
111
|
+
lines: ["User setting. Changes are saved immediately."],
|
|
112
|
+
items: [
|
|
113
|
+
{
|
|
114
|
+
id: "hidden",
|
|
115
|
+
label: "Show hidden files",
|
|
116
|
+
description:
|
|
117
|
+
"Include hidden files and directories in the picker. This does not change the active restriction.",
|
|
118
|
+
currentValue: options.getSettings().showHiddenFiles ? "true" : "false",
|
|
119
|
+
values: ["false", "true"],
|
|
120
|
+
action: "hidden",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
}),
|
|
124
|
+
status: ({ state }) => ({
|
|
125
|
+
kind: "review",
|
|
126
|
+
title: "Edit Scope Status",
|
|
127
|
+
lines: stateLines(state),
|
|
128
|
+
content: [
|
|
129
|
+
...state.targets.flatMap((target, index) => [
|
|
130
|
+
`${index + 1}. ${displayPath(target.path)}`,
|
|
131
|
+
...(target.path === target.canonical ? [] : [` Canonical: ${displayPath(target.canonical)}`]),
|
|
132
|
+
]),
|
|
133
|
+
"",
|
|
134
|
+
"Missing selected paths are reserved. The first normal write creates the file and parent directories.",
|
|
135
|
+
"While restricted, write/edit are limited to exact allowed targets. Supported readers remain available.",
|
|
136
|
+
"Shell commands, tests, formatters, and opaque writing/delegation tools are blocked; use /edit-scope off first.",
|
|
137
|
+
"This guard is not an OS sandbox. User shell commands, other programs, filesystem races, and trusted extensions are outside its boundary.",
|
|
138
|
+
].join("\n"),
|
|
139
|
+
format: { kind: "text" },
|
|
140
|
+
viewportSize: "adaptive",
|
|
141
|
+
hint: "back",
|
|
142
|
+
}),
|
|
143
|
+
help: () => ({
|
|
144
|
+
kind: "detail",
|
|
145
|
+
title: "Edit Scope Help",
|
|
146
|
+
lines: [
|
|
147
|
+
"Select files to start a restriction or replace the complete allowed-file set.",
|
|
148
|
+
"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.",
|
|
149
|
+
"Project paths are relative to cwd by default. Use Full paths to switch views without changing the search or selected files.",
|
|
150
|
+
"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.",
|
|
151
|
+
"New file reserves one target; it replaces all allowed files and creates no content here.",
|
|
152
|
+
"Cancelling a draft, closing this menu, and run completion do not release the restriction.",
|
|
153
|
+
"Use End restriction or /edit-scope off to release it explicitly.",
|
|
154
|
+
"Discovery starts at cwd, skips symlinks and fixed generated/dependency/version-control directories, and does not use .gitignore.",
|
|
155
|
+
"Settings controls only hidden-file discovery. Selected targets remain visible even when discovery would exclude them.",
|
|
156
|
+
"Status lists every selected path and the tool limitations.",
|
|
157
|
+
],
|
|
158
|
+
hint: "back",
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
actions: {
|
|
162
|
+
select: async ({ signal }) => {
|
|
163
|
+
const view = options.getView();
|
|
164
|
+
const live = new Map(view.targets.map((target) => [target.path, target]));
|
|
165
|
+
const result = await showScopePicker(ctx, {
|
|
166
|
+
cwd: view.cwd,
|
|
167
|
+
targets: view.targets,
|
|
168
|
+
completionLabel: view.state === "inactive" ? "Start restriction" : "Apply changes",
|
|
169
|
+
signal,
|
|
170
|
+
isCurrent: current,
|
|
171
|
+
discover: (scanSignal) => discoverScopeFiles(view.cwd, options.getSettings().showHiddenFiles, scanSignal),
|
|
172
|
+
resolvePath: async (raw, validationSignal) => {
|
|
173
|
+
const target = await resolveScopeTarget(raw, view.cwd, "existing", validationSignal);
|
|
174
|
+
if (!current() || validationSignal.aborted)
|
|
175
|
+
throw validationSignal.reason ?? new Error("Scope flow is stale.");
|
|
176
|
+
return target;
|
|
177
|
+
},
|
|
178
|
+
validate: async (paths, validationSignal, added) => {
|
|
179
|
+
if (paths.length > MAX_TARGETS) throw new Error(`Select no more than ${MAX_TARGETS} files.`);
|
|
180
|
+
const manual = new Map(added.map((target) => [target.path, target] as const));
|
|
181
|
+
const known = new Map([...live, ...manual]);
|
|
182
|
+
const targets: ScopeTarget[] = [];
|
|
183
|
+
for (const path of paths) {
|
|
184
|
+
// Discovery/snapshots already contain lexical native paths. A file
|
|
185
|
+
// URL preserves literal Unicode spaces through Pi's raw-input resolver.
|
|
186
|
+
const nativePath = pathToFileURL(path).href;
|
|
187
|
+
const existing = known.get(path);
|
|
188
|
+
if (existing) {
|
|
189
|
+
let checked = existing;
|
|
190
|
+
if (manual.has(path)) {
|
|
191
|
+
checked = await resolveScopeTarget(nativePath, view.cwd, "existing", validationSignal);
|
|
192
|
+
if (!current() || validationSignal.aborted)
|
|
193
|
+
throw validationSignal.reason ?? new Error("Scope flow is stale.");
|
|
194
|
+
if (checked.canonical !== existing.canonical)
|
|
195
|
+
throw new Error(`The added target changed: ${displayPath(path)}. Add it again.`);
|
|
196
|
+
}
|
|
197
|
+
await assertScopeTarget(nativePath, view.cwd, [existing], validationSignal);
|
|
198
|
+
if (!current() || validationSignal.aborted)
|
|
199
|
+
throw validationSignal.reason ?? new Error("Scope flow is stale.");
|
|
200
|
+
targets.push(checked);
|
|
201
|
+
} else {
|
|
202
|
+
const target = await resolveScopeTarget(nativePath, view.cwd, "existing", validationSignal);
|
|
203
|
+
if (!current() || validationSignal.aborted)
|
|
204
|
+
throw validationSignal.reason ?? new Error("Scope flow is stale.");
|
|
205
|
+
targets.push(target);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
createSnapshot(view.cwd, targets);
|
|
209
|
+
return targets;
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (!current() || signal.aborted || result.kind === "stale") return { kind: "close" };
|
|
213
|
+
if (result.kind !== "completed") return { kind: "stay" };
|
|
214
|
+
if (result.value.kind === "cancelled") return { kind: result.value.reason === "close" ? "close" : "stay" };
|
|
215
|
+
if (result.value.kind === "error") {
|
|
216
|
+
notify(result.value.error);
|
|
217
|
+
return { kind: "stay" };
|
|
218
|
+
}
|
|
219
|
+
if (result.value.targets.length === 0) {
|
|
220
|
+
ctx.ui.notify("No files selected. The restriction is unchanged.", "info");
|
|
221
|
+
return { kind: "stay" };
|
|
222
|
+
}
|
|
223
|
+
const selected = result.value.targets;
|
|
224
|
+
const applied = await runTask(ctx, {
|
|
225
|
+
label: "Applying editing scope…",
|
|
226
|
+
signal,
|
|
227
|
+
isCurrent: current,
|
|
228
|
+
task: ({ signal: operation }) => options.apply(selected, false, operation),
|
|
229
|
+
onError: (_ctx, error) => notify(error),
|
|
230
|
+
});
|
|
231
|
+
return { kind: applied.kind === "completed" || applied.kind === "stale" ? "close" : "stay" };
|
|
232
|
+
},
|
|
233
|
+
new: async ({ signal }) => {
|
|
234
|
+
const view = options.getView();
|
|
235
|
+
const result = await showScopePathInput(ctx, {
|
|
236
|
+
cwd: view.cwd,
|
|
237
|
+
signal,
|
|
238
|
+
isCurrent: current,
|
|
239
|
+
});
|
|
240
|
+
if (!current() || signal.aborted || result.kind === "stale") return { kind: "close" };
|
|
241
|
+
if (result.kind !== "completed") return { kind: "stay" };
|
|
242
|
+
if (result.value.kind === "cancelled") return { kind: result.value.reason === "close" ? "close" : "stay" };
|
|
243
|
+
pendingNew = result.value.target;
|
|
244
|
+
return { kind: "to", screen: "new-review" };
|
|
245
|
+
},
|
|
246
|
+
"apply-new": async ({ signal }) => {
|
|
247
|
+
if (!current() || signal.aborted || !pendingNew) return { kind: "close" };
|
|
248
|
+
const target = pendingNew;
|
|
249
|
+
const applied = await runTask(ctx, {
|
|
250
|
+
label: "Applying new-file scope…",
|
|
251
|
+
signal,
|
|
252
|
+
isCurrent: current,
|
|
253
|
+
task: ({ signal: operation }) => options.apply([target], true, operation),
|
|
254
|
+
onError: (_ctx, error) => notify(error),
|
|
255
|
+
});
|
|
256
|
+
return { kind: applied.kind === "completed" || applied.kind === "stale" ? "close" : "stay" };
|
|
257
|
+
},
|
|
258
|
+
hidden: async ({ value, signal }) => {
|
|
259
|
+
if (!current() || signal.aborted) return { kind: "close" };
|
|
260
|
+
await options.saveShowHiddenFiles(value === "true");
|
|
261
|
+
if (!current() || signal.aborted) return { kind: "close" };
|
|
262
|
+
return { kind: "stay" };
|
|
263
|
+
},
|
|
264
|
+
off: async ({ signal }) => {
|
|
265
|
+
if (!current() || signal.aborted) return { kind: "close" };
|
|
266
|
+
await options.off();
|
|
267
|
+
return { kind: "close" };
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
await runMenu(ctx, definition, {
|
|
272
|
+
signal: options.signal,
|
|
273
|
+
isCurrent: current,
|
|
274
|
+
getState: () => options.getView(),
|
|
275
|
+
onError: (_ctx, error) => notify(error),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export interface ScopeTarget {
|
|
7
|
+
/** Normalized absolute lexical path; do not apply tool input normalization again. */
|
|
8
|
+
path: string;
|
|
9
|
+
canonical: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResolvedScopeTarget extends ScopeTarget {
|
|
13
|
+
exists: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Mirrors Pi's installed utils/paths.js and tools/path-utils.js write/edit path.
|
|
17
|
+
// Do not trim, normalize Unicode composition, or use the read-only macOS fallbacks.
|
|
18
|
+
function normalizePath(input: string, toolPath: boolean): string {
|
|
19
|
+
let normalized = toolPath ? input.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ") : input;
|
|
20
|
+
if (toolPath && normalized.startsWith("@")) normalized = normalized.slice(1);
|
|
21
|
+
if (
|
|
22
|
+
process.platform === "win32" &&
|
|
23
|
+
normalized.startsWith("/") &&
|
|
24
|
+
!normalized.startsWith("//") &&
|
|
25
|
+
!normalized.includes("\\")
|
|
26
|
+
) {
|
|
27
|
+
const match = normalized.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
28
|
+
if (match) normalized = `${match[1].toUpperCase()}:\\${match[2]?.replaceAll("/", "\\") ?? ""}`;
|
|
29
|
+
}
|
|
30
|
+
const home = homedir();
|
|
31
|
+
if (normalized === "~") return home;
|
|
32
|
+
if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) {
|
|
33
|
+
return join(home, normalized.slice(2));
|
|
34
|
+
}
|
|
35
|
+
return /^file:\/\//.test(normalized) ? fileURLToPath(normalized) : normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The lexical target used by built-in write/edit; this does not access the filesystem. */
|
|
39
|
+
export function resolveWritePath(raw: string, cwd: string): string {
|
|
40
|
+
const normalized = normalizePath(raw, true);
|
|
41
|
+
const base = normalizePath(cwd, false);
|
|
42
|
+
return isAbsolute(normalized) ? resolve(normalized) : resolve(base, normalized);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Await OS work to completion before reporting cancellation; do not leave detached I/O.
|
|
46
|
+
async function checked<T>(signal: AbortSignal | undefined, operation: () => Promise<T>): Promise<T> {
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
try {
|
|
49
|
+
return await operation();
|
|
50
|
+
} finally {
|
|
51
|
+
signal?.throwIfAborted();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function inspect(path: string, signal?: AbortSignal) {
|
|
56
|
+
try {
|
|
57
|
+
return await checked(signal, () => fs.lstat(path));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
signal?.throwIfAborted();
|
|
60
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve existing files, or a missing suffix below the nearest existing directory.
|
|
67
|
+
* Symlinks may resolve to regular files/directories, but dangling links and hard-linked
|
|
68
|
+
* files are rejected. Only ENOENT means missing; permission and other errors propagate.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveScopeTarget(
|
|
71
|
+
raw: string,
|
|
72
|
+
cwd: string,
|
|
73
|
+
requirement: "existing" | "new" | "either",
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
): Promise<ResolvedScopeTarget> {
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
return resolveLexicalTarget(resolveWritePath(raw, cwd), requirement, signal);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resolveLexicalTarget(
|
|
81
|
+
path: string,
|
|
82
|
+
requirement: "existing" | "new" | "either",
|
|
83
|
+
signal?: AbortSignal,
|
|
84
|
+
): Promise<ResolvedScopeTarget> {
|
|
85
|
+
signal?.throwIfAborted();
|
|
86
|
+
let ancestor = path;
|
|
87
|
+
const suffix: string[] = [];
|
|
88
|
+
let entry = await inspect(ancestor, signal);
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
while (!entry) {
|
|
91
|
+
const parent = dirname(ancestor);
|
|
92
|
+
if (parent === ancestor) throw new Error(`No existing ancestor for scope target ${JSON.stringify(path)}.`);
|
|
93
|
+
suffix.unshift(basename(ancestor));
|
|
94
|
+
ancestor = parent;
|
|
95
|
+
entry = await inspect(ancestor, signal);
|
|
96
|
+
signal?.throwIfAborted();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// realpath must succeed even for symlinks: an ENOENT here is a dangling link,
|
|
100
|
+
// not a missing target which write is permitted to create.
|
|
101
|
+
const realAncestor = await checked(signal, () => fs.realpath(ancestor));
|
|
102
|
+
const stats = await checked(signal, () => fs.stat(realAncestor));
|
|
103
|
+
const exists = suffix.length === 0;
|
|
104
|
+
if (exists) {
|
|
105
|
+
if (!stats.isFile()) throw new Error(`Scope target must be a regular file: ${JSON.stringify(path)}.`);
|
|
106
|
+
if (stats.nlink > 1) throw new Error(`Scope does not support hard-linked files: ${JSON.stringify(path)}.`);
|
|
107
|
+
} else if (!stats.isDirectory()) {
|
|
108
|
+
throw new Error(`Scope target parent must be a directory: ${JSON.stringify(ancestor)}.`);
|
|
109
|
+
}
|
|
110
|
+
if (requirement === "existing" && !exists) {
|
|
111
|
+
throw new Error(`Scope target does not exist: ${JSON.stringify(path)}.`);
|
|
112
|
+
}
|
|
113
|
+
if (requirement === "new" && exists) {
|
|
114
|
+
throw new Error(`Scope target already exists: ${JSON.stringify(path)}.`);
|
|
115
|
+
}
|
|
116
|
+
return { path, canonical: resolve(realAncestor, ...suffix), exists };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Exact canonical matching, with fresh checks of both the reservation and mutation. */
|
|
120
|
+
export async function assertScopeTarget(
|
|
121
|
+
raw: string,
|
|
122
|
+
cwd: string,
|
|
123
|
+
targets: readonly ScopeTarget[],
|
|
124
|
+
signal?: AbortSignal,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const candidate = await resolveScopeTarget(raw, cwd, "either", signal);
|
|
127
|
+
signal?.throwIfAborted();
|
|
128
|
+
const selected = targets.find((target) => target.canonical === candidate.canonical);
|
|
129
|
+
if (!selected) throw new Error("Scope blocks this target: it is not an allowed file.");
|
|
130
|
+
const refreshed = await resolveLexicalTarget(selected.path, "either", signal);
|
|
131
|
+
signal?.throwIfAborted();
|
|
132
|
+
if (refreshed.canonical !== selected.canonical) {
|
|
133
|
+
throw new Error("Scope target changed since selection. Select the allowed files again.");
|
|
134
|
+
}
|
|
135
|
+
const rechecked = await resolveScopeTarget(raw, cwd, "either", signal);
|
|
136
|
+
signal?.throwIfAborted();
|
|
137
|
+
if (rechecked.canonical !== selected.canonical) {
|
|
138
|
+
throw new Error("Scope mutation target changed during validation. Try again.");
|
|
139
|
+
}
|
|
140
|
+
// This is a trusted-extension guard, not race-free OS containment. Pi performs
|
|
141
|
+
// the mutation later; another process can still change filesystem entries.
|
|
142
|
+
}
|