@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,354 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { ContentSearchMatch } from "./content-search.js";
4
+ import { highlightContentRanges } from "./content-search-ui.js";
5
+ import { safeTerminalText as safeHistoryText } from "./file-browser.js";
6
+ import { bindingHint } from "./file-browser-ui.js";
7
+ import type { LoadedProjectTextFile } from "./file-context.js";
8
+ import type { GitBlameInfo, GitFileContext, GitHistoryEntry, GitProjectInfo, GitRevisionFile } from "./git-context.js";
9
+
10
+ export interface SelectedContextState {
11
+ count: number;
12
+ totalBytes: number;
13
+ maximumCount: number;
14
+ maximumBytes: number;
15
+ maximumSnippetLines?: number;
16
+ maximumSnippetBytes?: number;
17
+ }
18
+
19
+ interface FilePreviewRenderOptions {
20
+ theme: Theme;
21
+ keybindings: KeybindingsManager;
22
+ width: number;
23
+ availableRows: number;
24
+ file: LoadedProjectTextFile;
25
+ fileGit?: GitFileContext;
26
+ project?: GitProjectInfo;
27
+ revision?: GitRevisionFile;
28
+ contentMatch?: ContentSearchMatch;
29
+ blame?: GitBlameInfo;
30
+ cursor: number;
31
+ anchor?: number;
32
+ scrollOffset: number;
33
+ error?: string;
34
+ selectedContext?: SelectedContextState;
35
+ canContinue: boolean;
36
+ canEdit: boolean;
37
+ }
38
+
39
+ export function renderFilePreview(options: FilePreviewRenderOptions): string[] {
40
+ const { file, theme, width } = options;
41
+ const range = selectionRange(options.anchor, options.cursor);
42
+ const selectedText = file.lines.slice(range.start, range.end + 1).join("\n");
43
+ const selectedBytes = Buffer.byteLength(selectedText, "utf8");
44
+ const estimatedTokens = Math.max(1, Math.ceil(selectedBytes / 4));
45
+ const externalEditorKey = bindingHint(options.keybindings, "app.editor.external", []);
46
+ const footer = options.error
47
+ ? { lines: [escapeTerminalControls(options.error)], primaryIndexes: [0] }
48
+ : previewFooterLines(
49
+ width,
50
+ range.start + 1,
51
+ range.end + 1,
52
+ estimatedTokens,
53
+ selectedBytes,
54
+ options.selectedContext,
55
+ options.canContinue,
56
+ options.canEdit ? externalEditorKey : "",
57
+ );
58
+ const previewHeight = Math.max(1, options.availableRows - 1 - (options.blame ? 1 : 0) - footer.lines.length);
59
+ const scrollOffset = visiblePreviewOffset(options.scrollOffset, options.cursor, previewHeight);
60
+ const digits = String(Math.max(1, file.lines.length)).length;
61
+ const changedLines = new Set(options.fileGit?.hunks.flatMap((hunk) => hunk.changedLines) ?? []);
62
+ const deletedAtLines = new Set(
63
+ (options.fileGit?.hunks ?? [])
64
+ .filter((hunk) => hunk.changedLines.length === 0 && hunk.oldCount > 0)
65
+ .map((hunk) => Math.max(1, hunk.newStart)),
66
+ );
67
+ const previewLines = file.lines.slice(scrollOffset, scrollOffset + previewHeight).map((rawLine, visibleIndex) => {
68
+ const index = scrollOffset + visibleIndex;
69
+ const selected = index >= range.start && index <= range.end;
70
+ const cursor = index === options.cursor ? ">" : " ";
71
+ const marker = changedLines.has(index + 1) ? "+" : deletedAtLines.has(index + 1) ? "-" : " ";
72
+ const number = String(index + 1).padStart(digits, " ");
73
+ const contentMatch =
74
+ options.contentMatch?.path === file.path &&
75
+ options.contentMatch.lineNumber === index + 1 &&
76
+ options.contentMatch.line === rawLine
77
+ ? options.contentMatch
78
+ : undefined;
79
+ const content = contentMatch
80
+ ? highlightContentRanges(rawLine, contentMatch.ranges, theme)
81
+ : escapeTerminalControls(rawLine);
82
+ const line = `${cursor}${marker}${number} │ ${content}`;
83
+ const styled = selected
84
+ ? theme.bg("selectedBg", theme.fg("text", line))
85
+ : index === options.cursor
86
+ ? theme.fg("accent", line)
87
+ : line;
88
+ return truncateToWidth(styled, width, "");
89
+ });
90
+ if (previewLines.length === 0) {
91
+ previewLines.push(truncateToWidth(theme.fg("muted", " Empty file"), width, ""));
92
+ }
93
+ const gitLabel = options.revision
94
+ ? `${escapeTerminalControls(options.revision.revision)}@${options.revision.commit.slice(0, 12)} · historical`
95
+ : options.project
96
+ ? `${escapeTerminalControls(options.project.branch)}@${options.project.head.slice(0, 12)}${options.project.dirty ? " · dirty" : ""}${options.fileGit?.status ? ` · ${options.fileGit.status.label}` : " · clean"}`
97
+ : "";
98
+ const blameLabel = options.blame
99
+ ? `L${options.cursor + 1} · ${options.blame.committed ? options.blame.commit.slice(0, 12) : "uncommitted"} · ${escapeTerminalControls(options.blame.author)} · ${escapeTerminalControls(options.blame.summary)}`
100
+ : "";
101
+ const titleLine = truncateToWidth(
102
+ theme.fg("accent", theme.bold(`${escapeTerminalControls(file.path)}${gitLabel ? ` · ${gitLabel}` : ""}`)),
103
+ width,
104
+ "",
105
+ );
106
+ const blameLine = blameLabel ? truncateToWidth(theme.fg("muted", blameLabel), width, "") : undefined;
107
+ const renderedFooter = footer.lines.map((line) =>
108
+ truncateToWidth(theme.fg(options.error ? "error" : "muted", line), width, ""),
109
+ );
110
+ return fitPreviewRows(
111
+ titleLine,
112
+ blameLine,
113
+ previewLines,
114
+ renderedFooter,
115
+ footer.primaryIndexes,
116
+ options.availableRows,
117
+ );
118
+ }
119
+
120
+ export function renderFilePreviewHelp(
121
+ theme: Theme,
122
+ keybindings: KeybindingsManager,
123
+ width: number,
124
+ availableRows: number,
125
+ canEdit: boolean,
126
+ ): string[] {
127
+ const externalEditorKey = bindingHint(keybindings, "app.editor.external", []);
128
+ const editDetailed = canEdit
129
+ ? externalEditorKey
130
+ ? `${externalEditorKey.padEnd(6, " ")} Edit the current worktree file in the external editor`
131
+ : "Edit External editor action is unbound"
132
+ : "Edit Historical revisions are read-only";
133
+ const editCompact = canEdit
134
+ ? externalEditorKey
135
+ ? `${externalEditorKey} edit worktree`
136
+ : "External editor unbound"
137
+ : "Historical revision · read-only";
138
+ const detailed = [
139
+ theme.fg("accent", theme.bold("Preview actions")),
140
+ "Enter Add selected lines and close File Context",
141
+ "A Add selected lines and keep browsing",
142
+ "Space Set or clear the range anchor; arrows extend the range",
143
+ "[ / ] Select the previous or next changed hunk",
144
+ "B Blame: show ownership for the current line when Git is available",
145
+ "H History: browse earlier versions of this file",
146
+ "R Revision: open a commit, branch, or tag",
147
+ "D Git diff: review and add one explicit changed hunk",
148
+ editDetailed,
149
+ "Esc Return to the preview without changing selected context",
150
+ ];
151
+ const compact = [
152
+ theme.fg("accent", theme.bold("Preview actions")),
153
+ "Enter add · A keep browsing · Space range",
154
+ "↑↓ extend · [/] hunk · B blame",
155
+ "H history · R revision · D Git diff",
156
+ editCompact,
157
+ "Esc back without changing context",
158
+ ];
159
+ const lines = availableRows < detailed.length ? compact : detailed;
160
+ return fitRows(
161
+ lines.map((line) => truncateToWidth(line, width, "")),
162
+ availableRows,
163
+ );
164
+ }
165
+
166
+ function previewFooterLines(
167
+ width: number,
168
+ startLine: number,
169
+ endLine: number,
170
+ tokens: number,
171
+ selectedBytes: number,
172
+ state: SelectedContextState | undefined,
173
+ canContinue: boolean,
174
+ externalEditorKey: string,
175
+ ): { lines: string[]; primaryIndexes: number[] } {
176
+ const nextCount = state ? state.count + 1 : undefined;
177
+ const nextBytes = state ? state.totalBytes + selectedBytes : undefined;
178
+ const selectedLines = endLine - startLine + 1;
179
+ const snippetOverLimit =
180
+ state !== undefined &&
181
+ ((state.maximumSnippetLines !== undefined && selectedLines > state.maximumSnippetLines) ||
182
+ (state.maximumSnippetBytes !== undefined && selectedBytes > state.maximumSnippetBytes));
183
+ const aggregateOverLimit =
184
+ state !== undefined &&
185
+ nextCount !== undefined &&
186
+ (nextCount > state.maximumCount || (nextBytes ?? 0) > state.maximumBytes);
187
+ const warning = snippetOverLimit
188
+ ? "Snippet limit exceeded"
189
+ : aggregateOverLimit
190
+ ? "Next prompt limit exceeded"
191
+ : undefined;
192
+ if (width < 74) {
193
+ const selection = `L${startLine}-${endLine} · ~${tokens} tok`;
194
+ const capacity = state ? (warning ?? `Next ${nextCount}/${state.maximumCount}`) : undefined;
195
+ const lines = [
196
+ [selection, capacity].filter(Boolean).join(" · "),
197
+ canContinue ? "Enter add · A keep browsing" : "Enter add & close",
198
+ "↑↓ move · Space range · Esc back",
199
+ [externalEditorKey ? `${externalEditorKey} edit` : "", "? actions"].filter(Boolean).join(" · "),
200
+ "B blame · H history · R revision",
201
+ "D diff · [/] hunk",
202
+ ];
203
+ return { lines, primaryIndexes: [1, 3] };
204
+ }
205
+ const selection = `Lines ${startLine}-${endLine} · ~${tokens} tokens`;
206
+ const capacity = state
207
+ ? snippetOverLimit
208
+ ? "Snippet limit exceeded; shorten the range before adding"
209
+ : aggregateOverLimit
210
+ ? "Next prompt limit exceeded; shorten the range or review selected context"
211
+ : `Next prompt: ${nextCount}/${state.maximumCount} snippets · ~${estimateTokens(nextBytes ?? 0)} tokens`
212
+ : undefined;
213
+ const primaryActions = canContinue
214
+ ? "Enter add & close · A add & continue · ↑↓ move · Space range · Esc back"
215
+ : "Enter add & close · ↑↓ move · Space range · Esc back";
216
+ const editActions = [externalEditorKey ? `${externalEditorKey} edit` : "", "? actions"].filter(Boolean).join(" · ");
217
+ return {
218
+ lines: [
219
+ [selection, capacity].filter(Boolean).join(" · "),
220
+ primaryActions,
221
+ editActions,
222
+ "B blame · H history · R revision · D diff · [/] hunk",
223
+ ],
224
+ primaryIndexes: [1, 2],
225
+ };
226
+ }
227
+
228
+ function selectionRange(anchor: number | undefined, cursor: number) {
229
+ const rangeAnchor = anchor ?? cursor;
230
+ return {
231
+ start: Math.min(rangeAnchor, cursor),
232
+ end: Math.max(rangeAnchor, cursor),
233
+ };
234
+ }
235
+
236
+ function visiblePreviewOffset(current: number, cursor: number, height: number): number {
237
+ if (cursor < current) return cursor;
238
+ if (cursor >= current + height) return cursor - height + 1;
239
+ return current;
240
+ }
241
+
242
+ function estimateTokens(bytes: number): number {
243
+ return bytes === 0 ? 0 : Math.max(1, Math.ceil(bytes / 4));
244
+ }
245
+
246
+ function fitPreviewRows(
247
+ title: string,
248
+ blame: string | undefined,
249
+ previewLines: readonly string[],
250
+ footerLines: readonly string[],
251
+ primaryFooterIndexes: readonly number[],
252
+ height: number,
253
+ ): string[] {
254
+ let visibleTitle: string | undefined = title;
255
+ let visibleBlame = blame;
256
+ const visiblePreview = [...previewLines];
257
+ const visibleFooter = footerLines.map((line, index) => ({
258
+ line,
259
+ primary: primaryFooterIndexes.includes(index),
260
+ }));
261
+ const rowCount = () => (visibleTitle ? 1 : 0) + (visibleBlame ? 1 : 0) + visiblePreview.length + visibleFooter.length;
262
+
263
+ while (rowCount() > height) {
264
+ if (visibleBlame) {
265
+ visibleBlame = undefined;
266
+ continue;
267
+ }
268
+ if (visiblePreview.length > 1) {
269
+ visiblePreview.pop();
270
+ continue;
271
+ }
272
+ let optionalFooter = -1;
273
+ for (let index = visibleFooter.length - 1; index >= 0; index -= 1) {
274
+ if (!visibleFooter[index]?.primary) {
275
+ optionalFooter = index;
276
+ break;
277
+ }
278
+ }
279
+ if (optionalFooter >= 0) {
280
+ visibleFooter.splice(optionalFooter, 1);
281
+ continue;
282
+ }
283
+ if (visibleTitle) {
284
+ visibleTitle = undefined;
285
+ continue;
286
+ }
287
+ if (visiblePreview.length > 0) {
288
+ visiblePreview.pop();
289
+ continue;
290
+ }
291
+ break;
292
+ }
293
+
294
+ return [
295
+ ...(visibleTitle ? [visibleTitle] : []),
296
+ ...(visibleBlame ? [visibleBlame] : []),
297
+ ...visiblePreview,
298
+ ...visibleFooter.map(({ line }) => line),
299
+ ];
300
+ }
301
+
302
+ export function fitRows(lines: string[], height: number): string[] {
303
+ if (lines.length <= height) return lines;
304
+ if (height <= 1) return lines.slice(0, 1);
305
+ return [...lines.slice(0, height - 1), lines.at(-1) ?? ""];
306
+ }
307
+
308
+ function escapeTerminalControls(text: string): string {
309
+ return [...text]
310
+ .map((character) => {
311
+ if (character === "\t") return " ";
312
+ const code = character.charCodeAt(0);
313
+ if (code <= 31 || (code >= 127 && code <= 159)) {
314
+ return `\\x${code.toString(16).padStart(2, "0")}`;
315
+ }
316
+ return character;
317
+ })
318
+ .join("");
319
+ }
320
+
321
+ export function formatHistoryDate(authorTime: number): string {
322
+ const date = new Date(authorTime * 1_000);
323
+ return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : "unknown-date";
324
+ }
325
+
326
+ export function renderFileHistory(options: {
327
+ width: number;
328
+ availableRows: number;
329
+ theme: Theme;
330
+ path: string;
331
+ history: readonly GitHistoryEntry[];
332
+ index: number;
333
+ error?: string;
334
+ }): string[] {
335
+ const { width, availableRows, theme, history, index: selectedIndex, error } = options;
336
+ const listHeight = Math.max(1, availableRows - 3);
337
+ const title = theme.fg("accent", theme.bold(`File history · ${safeHistoryText(options.path)}`));
338
+ const start = Math.max(0, selectedIndex - listHeight + 1);
339
+ const entries = history.slice(start, start + listHeight).map((entry, visibleIndex) => {
340
+ const index = start + visibleIndex;
341
+ const prefix = index === selectedIndex ? "> " : " ";
342
+ const date = formatHistoryDate(entry.authorTime);
343
+ const line = `${prefix}${entry.commit.slice(0, 12)} · ${date} · ${safeHistoryText(entry.author)} · ${safeHistoryText(entry.summary)}`;
344
+ return truncateToWidth(index === selectedIndex ? theme.bg("selectedBg", theme.fg("text", line)) : line, width, "");
345
+ });
346
+ if (entries.length === 0) {
347
+ entries.push(truncateToWidth(theme.fg("muted", " No file history"), width, ""));
348
+ }
349
+ const footer = error ? safeHistoryText(error) : "↑↓ navigate · Enter open revision · Esc preview";
350
+ return fitRows(
351
+ [truncateToWidth(title, width, ""), ...entries, truncateToWidth(theme.fg("muted", footer), width, "")],
352
+ availableRows,
353
+ );
354
+ }
@@ -0,0 +1,301 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import type { KeyId } from "@earendil-works/pi-tui";
7
+
8
+ export const FILE_CONTEXT_SETTINGS_FILE = "pi-file-context.json";
9
+ const MAX_SETTINGS_BYTES = 64 * 1024;
10
+
11
+ export interface FileContextSettings {
12
+ openShortcut: KeyId | null;
13
+ }
14
+
15
+ export interface LoadedFileContextSettings {
16
+ settings: FileContextSettings;
17
+ warning?: string;
18
+ invalidReason?: string;
19
+ }
20
+
21
+ export interface UpdateFileContextSettingsOptions {
22
+ settingsPath?: string;
23
+ signal?: AbortSignal;
24
+ beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
25
+ }
26
+
27
+ export const DEFAULT_FILE_CONTEXT_SETTINGS: FileContextSettings = {
28
+ openShortcut: "ctrl+shift+x",
29
+ };
30
+
31
+ const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
32
+ const BASE_KEYS = new Set([
33
+ ..."abcdefghijklmnopqrstuvwxyz0123456789",
34
+ "`",
35
+ "-",
36
+ "=",
37
+ "[",
38
+ "]",
39
+ "\\",
40
+ ";",
41
+ "'",
42
+ ",",
43
+ ".",
44
+ "/",
45
+ "!",
46
+ "@",
47
+ "#",
48
+ "$",
49
+ "%",
50
+ "^",
51
+ "&",
52
+ "*",
53
+ "(",
54
+ ")",
55
+ "_",
56
+ "+",
57
+ "|",
58
+ "~",
59
+ "{",
60
+ "}",
61
+ ":",
62
+ "<",
63
+ ">",
64
+ "?",
65
+ "escape",
66
+ "esc",
67
+ "enter",
68
+ "return",
69
+ "tab",
70
+ "space",
71
+ "backspace",
72
+ "delete",
73
+ "insert",
74
+ "clear",
75
+ "home",
76
+ "end",
77
+ "pageup",
78
+ "pagedown",
79
+ "up",
80
+ "down",
81
+ "left",
82
+ "right",
83
+ ...Array.from({ length: 12 }, (_unused, index) => `f${index + 1}`),
84
+ ]);
85
+
86
+ type SettingsDocument = Record<string, unknown>;
87
+ type SettingsSnapshot =
88
+ | { kind: "missing" }
89
+ | { kind: "invalid"; reason: string; warning: string }
90
+ | { kind: "loaded"; document: SettingsDocument; settings: FileContextSettings };
91
+
92
+ const mutationQueues = new Map<string, Promise<void>>();
93
+
94
+ export function fileContextSettingsPath(): string {
95
+ return join(getAgentDir(), FILE_CONTEXT_SETTINGS_FILE);
96
+ }
97
+
98
+ export async function loadFileContextSettings(
99
+ settingsPath = fileContextSettingsPath(),
100
+ ): Promise<LoadedFileContextSettings> {
101
+ await awaitFileContextSettingsWrites(settingsPath);
102
+ const snapshot = await readSettingsSnapshot(settingsPath);
103
+ if (snapshot.kind === "loaded") return { settings: snapshot.settings };
104
+ if (snapshot.kind === "missing") {
105
+ return { settings: { ...DEFAULT_FILE_CONTEXT_SETTINGS } };
106
+ }
107
+ return {
108
+ settings: { ...DEFAULT_FILE_CONTEXT_SETTINGS },
109
+ warning: snapshot.warning,
110
+ invalidReason: snapshot.reason,
111
+ };
112
+ }
113
+
114
+ export function updateFileContextSettings(
115
+ openShortcut: KeyId | null,
116
+ options: UpdateFileContextSettingsOptions = {},
117
+ ): Promise<FileContextSettings> {
118
+ const settingsPath = options.settingsPath ?? fileContextSettingsPath();
119
+ return enqueueMutation(settingsPath, async () => {
120
+ options.signal?.throwIfAborted();
121
+ const snapshot = await readSettingsSnapshot(settingsPath);
122
+ if (snapshot.kind === "invalid") {
123
+ throw new Error(`File Context settings are invalid: ${snapshot.reason}`);
124
+ }
125
+ const updated: SettingsDocument = {
126
+ ...(snapshot.kind === "loaded" ? snapshot.document : {}),
127
+ openShortcut,
128
+ };
129
+ const settings = normalizeFileContextSettings(updated);
130
+ if (!settings) throw new Error("File Context settings update is invalid");
131
+ await publishSettings(settingsPath, updated, options);
132
+ return settings;
133
+ });
134
+ }
135
+
136
+ export async function awaitFileContextSettingsWrites(settingsPath = fileContextSettingsPath()): Promise<void> {
137
+ await mutationQueues.get(settingsPath);
138
+ }
139
+
140
+ export function normalizeKeyId(value: unknown): KeyId | undefined {
141
+ if (typeof value !== "string") return undefined;
142
+ const normalized = value.trim().toLowerCase();
143
+ const base = [...BASE_KEYS]
144
+ .sort((left, right) => right.length - left.length)
145
+ .find((candidate) => normalized === candidate || normalized.endsWith(`+${candidate}`));
146
+ if (!base) return undefined;
147
+ const prefix = normalized.slice(0, normalized.length - base.length);
148
+ if (!prefix) return base as KeyId;
149
+ if (/^f(?:[1-9]|1[0-2])$/.test(base) || !prefix.endsWith("+")) return undefined;
150
+ const modifiers = prefix.slice(0, -1).split("+");
151
+ if (
152
+ modifiers.length === 0 ||
153
+ modifiers.some((modifier) => !MODIFIERS.has(modifier)) ||
154
+ new Set(modifiers).size !== modifiers.length
155
+ ) {
156
+ return undefined;
157
+ }
158
+ return normalized as KeyId;
159
+ }
160
+
161
+ function normalizeFileContextSettings(value: unknown): FileContextSettings | undefined {
162
+ if (!isSettingsDocument(value)) return undefined;
163
+ if (!Object.hasOwn(value, "openShortcut")) {
164
+ return { ...DEFAULT_FILE_CONTEXT_SETTINGS };
165
+ }
166
+ const openShortcut = Reflect.get(value, "openShortcut");
167
+ if (openShortcut === null) return { openShortcut: null };
168
+ const normalized = normalizeKeyId(openShortcut);
169
+ return normalized ? { openShortcut: normalized } : undefined;
170
+ }
171
+
172
+ function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): Promise<T> {
173
+ const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
174
+ const result = previous.then(mutation, mutation);
175
+ const settled = result.then(
176
+ () => undefined,
177
+ () => undefined,
178
+ );
179
+ mutationQueues.set(settingsPath, settled);
180
+ void settled.finally(() => {
181
+ if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
182
+ });
183
+ return result;
184
+ }
185
+
186
+ async function readSettingsSnapshot(settingsPath: string): Promise<SettingsSnapshot> {
187
+ let source: string;
188
+ try {
189
+ source = await readSettingsContents(settingsPath);
190
+ } catch (error: unknown) {
191
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
192
+ const reason = safeReadError(error);
193
+ return {
194
+ kind: "invalid",
195
+ reason,
196
+ warning: `Cannot read File Context settings: ${reason}`,
197
+ };
198
+ }
199
+
200
+ let document: unknown;
201
+ try {
202
+ document = JSON.parse(source) as unknown;
203
+ } catch (error: unknown) {
204
+ return {
205
+ kind: "invalid",
206
+ reason: "invalid JSON",
207
+ warning: `Cannot parse File Context settings: ${formatError(error)}`,
208
+ };
209
+ }
210
+ if (!isSettingsDocument(document)) {
211
+ return {
212
+ kind: "invalid",
213
+ reason: "settings must contain a JSON object",
214
+ warning: "File Context settings must contain a JSON object.",
215
+ };
216
+ }
217
+ const settings = normalizeFileContextSettings(document);
218
+ if (!settings) {
219
+ return {
220
+ kind: "invalid",
221
+ reason: 'setting "openShortcut" must be a valid Pi key string or null',
222
+ warning: 'File Context setting "openShortcut" must be a valid Pi key string or null.',
223
+ };
224
+ }
225
+ return { kind: "loaded", document, settings };
226
+ }
227
+
228
+ async function readSettingsContents(settingsPath: string): Promise<string> {
229
+ const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
230
+ const handle = await open(settingsPath, flags);
231
+ try {
232
+ const stats = await handle.stat();
233
+ if (!stats.isFile()) throw new Error("settings path is not a regular file");
234
+ if (stats.size > MAX_SETTINGS_BYTES) {
235
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
236
+ }
237
+ const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
238
+ let offset = 0;
239
+ while (offset < buffer.byteLength) {
240
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
241
+ if (bytesRead === 0) break;
242
+ offset += bytesRead;
243
+ }
244
+ if (offset > MAX_SETTINGS_BYTES) {
245
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
246
+ }
247
+ try {
248
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset));
249
+ } catch {
250
+ throw new Error("settings file is not valid UTF-8");
251
+ }
252
+ } finally {
253
+ await handle.close();
254
+ }
255
+ }
256
+
257
+ async function publishSettings(
258
+ settingsPath: string,
259
+ document: SettingsDocument,
260
+ options: UpdateFileContextSettingsOptions,
261
+ ): Promise<void> {
262
+ options.signal?.throwIfAborted();
263
+ const contents = `${JSON.stringify(document, null, 2)}\n`;
264
+ if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
265
+ throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
266
+ }
267
+ const directory = dirname(settingsPath);
268
+ await mkdir(directory, { recursive: true });
269
+ options.signal?.throwIfAborted();
270
+ const temporaryPath = join(directory, `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`);
271
+ try {
272
+ await writeFile(temporaryPath, contents, {
273
+ encoding: "utf8",
274
+ flag: "wx",
275
+ mode: 0o600,
276
+ signal: options.signal,
277
+ });
278
+ await options.beforeRename?.(temporaryPath, settingsPath);
279
+ options.signal?.throwIfAborted();
280
+ await rename(temporaryPath, settingsPath);
281
+ } finally {
282
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
283
+ }
284
+ }
285
+
286
+ function isSettingsDocument(value: unknown): value is SettingsDocument {
287
+ return typeof value === "object" && value !== null && !Array.isArray(value);
288
+ }
289
+
290
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
291
+ return error instanceof Error && "code" in error;
292
+ }
293
+
294
+ function safeReadError(error: unknown): string {
295
+ if (isNodeError(error) && error.code === "ELOOP") return "settings path is not a regular file";
296
+ return formatError(error);
297
+ }
298
+
299
+ function formatError(error: unknown): string {
300
+ return error instanceof Error ? error.message : String(error);
301
+ }