@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,143 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
export interface ExternalEditorProcess {
|
|
8
|
+
readonly killed: boolean;
|
|
9
|
+
kill(): boolean;
|
|
10
|
+
once(event: "close", listener: (code: number | null) => void): this;
|
|
11
|
+
once(event: "error", listener: (error: Error) => void): this;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SpawnExternalEditorProcess = (command: string, args: readonly string[]) => ExternalEditorProcess;
|
|
15
|
+
|
|
16
|
+
export interface EditProjectFileInExternalEditorOptions {
|
|
17
|
+
root: string;
|
|
18
|
+
projectPath: string;
|
|
19
|
+
tui: TUI;
|
|
20
|
+
projectTrusted: boolean;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
isCurrent?: () => boolean;
|
|
23
|
+
agentDir?: string;
|
|
24
|
+
command?: string;
|
|
25
|
+
spawnProcess?: SpawnExternalEditorProcess;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function editProjectFileInExternalEditor(options: EditProjectFileInExternalEditorOptions): Promise<void> {
|
|
29
|
+
options.signal?.throwIfAborted();
|
|
30
|
+
const target = await resolveEditableProjectFile(options.root, options.projectPath, options.signal);
|
|
31
|
+
options.signal?.throwIfAborted();
|
|
32
|
+
const command =
|
|
33
|
+
options.command ??
|
|
34
|
+
SettingsManager.create(options.root, options.agentDir ?? getAgentDir(), {
|
|
35
|
+
projectTrusted: options.projectTrusted,
|
|
36
|
+
}).getExternalEditorCommand();
|
|
37
|
+
const [editor, ...editorArgs] = command.trim().split(/\s+/u);
|
|
38
|
+
if (!editor) throw new Error("External editor command is empty");
|
|
39
|
+
assertEditorTargetSafeForPlatform(target);
|
|
40
|
+
|
|
41
|
+
await withFileMutationQueue(target, async () => {
|
|
42
|
+
options.signal?.throwIfAborted();
|
|
43
|
+
let stopped = false;
|
|
44
|
+
try {
|
|
45
|
+
options.tui.stop();
|
|
46
|
+
stopped = true;
|
|
47
|
+
process.stdout.write("Launching external editor. Pi will resume when the editor exits.\n");
|
|
48
|
+
await runExternalEditorProcess(editor, [...editorArgs, target], options.signal, options.spawnProcess);
|
|
49
|
+
options.signal?.throwIfAborted();
|
|
50
|
+
} finally {
|
|
51
|
+
if (stopped && !options.signal?.aborted && (options.isCurrent === undefined || options.isCurrent())) {
|
|
52
|
+
options.tui.start();
|
|
53
|
+
options.tui.requestRender(true);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resolveEditableProjectFile(
|
|
60
|
+
root: string,
|
|
61
|
+
projectPath: string,
|
|
62
|
+
signal?: AbortSignal,
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
if (!projectPath || isAbsolute(projectPath) || projectPath.includes("\0")) {
|
|
66
|
+
throw new Error("File path is outside the project");
|
|
67
|
+
}
|
|
68
|
+
const canonicalRoot = await realpath(root);
|
|
69
|
+
signal?.throwIfAborted();
|
|
70
|
+
const candidate = resolve(canonicalRoot, projectPath);
|
|
71
|
+
if (!isInside(canonicalRoot, candidate)) throw new Error("File path is outside the project");
|
|
72
|
+
const candidateInfo = await lstat(candidate);
|
|
73
|
+
signal?.throwIfAborted();
|
|
74
|
+
if (candidateInfo.isSymbolicLink()) throw new Error(`${projectPath} is a symbolic link`);
|
|
75
|
+
const canonicalFile = await realpath(candidate);
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
if (!isInside(canonicalRoot, canonicalFile)) throw new Error("File path is outside the project");
|
|
78
|
+
const info = await lstat(canonicalFile);
|
|
79
|
+
signal?.throwIfAborted();
|
|
80
|
+
if (!info.isFile()) throw new Error(`${projectPath} is not a regular file`);
|
|
81
|
+
return canonicalFile;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function runExternalEditorProcess(
|
|
85
|
+
command: string,
|
|
86
|
+
args: readonly string[],
|
|
87
|
+
signal: AbortSignal | undefined,
|
|
88
|
+
spawnProcess: SpawnExternalEditorProcess = defaultSpawnExternalEditorProcess,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
const child = spawnProcess(command, args);
|
|
92
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
93
|
+
let settled = false;
|
|
94
|
+
let aborted = signal?.aborted ?? false;
|
|
95
|
+
const finish = (error?: Error) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
signal?.removeEventListener("abort", abort);
|
|
99
|
+
if (error) reject(error);
|
|
100
|
+
else resolvePromise();
|
|
101
|
+
};
|
|
102
|
+
const abort = () => {
|
|
103
|
+
aborted = true;
|
|
104
|
+
if (!child.killed) child.kill();
|
|
105
|
+
};
|
|
106
|
+
child.once("error", (error) => finish(aborted ? abortError() : error));
|
|
107
|
+
child.once("close", (code) => {
|
|
108
|
+
if (aborted) {
|
|
109
|
+
finish(abortError());
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (code === 0) {
|
|
113
|
+
finish();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
finish(new Error(`External editor exited with code ${code ?? "unknown"}`));
|
|
117
|
+
});
|
|
118
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
119
|
+
if (signal?.aborted) abort();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function assertEditorTargetSafeForPlatform(target: string, platform: NodeJS.Platform = process.platform): void {
|
|
124
|
+
if (platform === "win32" && /[\r\n"&|<>^()%!]/u.test(target)) {
|
|
125
|
+
throw new Error("File path contains characters unsafe for the Windows external-editor shell");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function defaultSpawnExternalEditorProcess(command: string, args: readonly string[]): ChildProcess {
|
|
130
|
+
return spawn(command, [...args], {
|
|
131
|
+
stdio: "inherit",
|
|
132
|
+
shell: process.platform === "win32",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isInside(root: string, candidate: string): boolean {
|
|
137
|
+
const result = relative(root, candidate);
|
|
138
|
+
return result === "" || (!result.startsWith(`..${sep}`) && result !== ".." && !isAbsolute(result));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function abortError(): DOMException {
|
|
142
|
+
return new DOMException("External editor cancelled", "AbortError");
|
|
143
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ProjectBrowserItem } from "./file-browser.js";
|
|
4
|
+
import { safeTerminalText } from "./file-browser.js";
|
|
5
|
+
|
|
6
|
+
interface FileBrowserRenderOptions {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
keybindings: KeybindingsManager;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
items: readonly ProjectBrowserItem[];
|
|
12
|
+
selectedIndex: number;
|
|
13
|
+
scrollOffset: number;
|
|
14
|
+
currentDirectory: string;
|
|
15
|
+
searchLine: string;
|
|
16
|
+
searchActive: boolean;
|
|
17
|
+
repositoryLabel: string;
|
|
18
|
+
statuses?: ReadonlyMap<string, { code: string }>;
|
|
19
|
+
loading: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderFileBrowser(options: FileBrowserRenderOptions): string[] {
|
|
24
|
+
const location = options.currentDirectory ? `/${options.currentDirectory}` : "/";
|
|
25
|
+
const title = options.theme.fg(
|
|
26
|
+
"accent",
|
|
27
|
+
options.theme.bold(`File Context · files · ${location}${options.repositoryLabel}`),
|
|
28
|
+
);
|
|
29
|
+
const visibleItems = options.items.slice(options.scrollOffset, options.scrollOffset + options.height);
|
|
30
|
+
const itemLines = visibleItems.map((item, visibleIndex) => {
|
|
31
|
+
const index = options.scrollOffset + visibleIndex;
|
|
32
|
+
const prefix = index === options.selectedIndex ? "> " : " ";
|
|
33
|
+
const status = item.kind === "file" ? (options.statuses?.get(item.path)?.code ?? " ") : " ";
|
|
34
|
+
const suffix = item.kind === "directory" ? "/" : "";
|
|
35
|
+
const line = `${prefix}${status} ${item.label}${suffix}`;
|
|
36
|
+
return truncateToWidth(
|
|
37
|
+
index === options.selectedIndex ? options.theme.bg("selectedBg", options.theme.fg("text", line)) : line,
|
|
38
|
+
options.width,
|
|
39
|
+
"",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
if (itemLines.length === 0) {
|
|
43
|
+
itemLines.push(truncateToWidth(options.theme.fg("muted", " No matching files"), options.width, ""));
|
|
44
|
+
}
|
|
45
|
+
const state = options.loading
|
|
46
|
+
? options.theme.fg("warning", "Loading…")
|
|
47
|
+
: options.error
|
|
48
|
+
? options.theme.fg("error", safeTerminalText(options.error))
|
|
49
|
+
: options.theme.fg("muted", formatFileBrowserHint(options));
|
|
50
|
+
return [
|
|
51
|
+
truncateToWidth(title, options.width, ""),
|
|
52
|
+
truncateToWidth(options.searchLine, options.width, ""),
|
|
53
|
+
...itemLines,
|
|
54
|
+
truncateToWidth(state, options.width, ""),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatFileBrowserHint(options: FileBrowserRenderOptions): string {
|
|
59
|
+
const selected = options.items[options.selectedIndex];
|
|
60
|
+
const countLabel = options.searchActive
|
|
61
|
+
? `${options.items.length} matching ${options.items.length === 1 ? "file" : "files"}`
|
|
62
|
+
: `${options.items.length} ${options.items.length === 1 ? "item" : "items"}`;
|
|
63
|
+
const navigation = navigationHint(options.keybindings);
|
|
64
|
+
const confirm = bindingHint(options.keybindings, "tui.select.confirm", ["enter"]);
|
|
65
|
+
const tab = bindingHint(options.keybindings, "tui.input.tab", ["tab"]);
|
|
66
|
+
const cancel = uniqueKeys([
|
|
67
|
+
...bindingKeys(options.keybindings, "tui.select.cancel", ["escape"]),
|
|
68
|
+
formatHintKey("ctrl+c"),
|
|
69
|
+
]).join("/");
|
|
70
|
+
return [
|
|
71
|
+
countLabel,
|
|
72
|
+
`${navigation} navigate`,
|
|
73
|
+
`${confirm} ${selected?.kind === "directory" ? "open folder" : "preview"}`,
|
|
74
|
+
...(selected?.kind === "file" ? [`${tab} reference`] : []),
|
|
75
|
+
"Ctrl+F contents",
|
|
76
|
+
`${cancel} ${options.currentDirectory && !options.searchActive ? "back" : "cancel"}`,
|
|
77
|
+
].join(" · ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function navigationHint(keybindings: KeybindingsManager): string {
|
|
81
|
+
const up = bindingKeys(keybindings, "tui.select.up", ["up"]);
|
|
82
|
+
const down = bindingKeys(keybindings, "tui.select.down", ["down"]);
|
|
83
|
+
if (up.includes("↑") && down.includes("↓")) {
|
|
84
|
+
return uniqueKeys(["↑↓", ...up.filter((key) => key !== "↑"), ...down.filter((key) => key !== "↓")]).join("/");
|
|
85
|
+
}
|
|
86
|
+
return uniqueKeys([...up, ...down]).join("/");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function bindingHint(
|
|
90
|
+
keybindings: KeybindingsManager,
|
|
91
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
92
|
+
fallback: readonly string[],
|
|
93
|
+
): string {
|
|
94
|
+
return bindingKeys(keybindings, binding, fallback).join("/");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function bindingKeys(
|
|
98
|
+
keybindings: KeybindingsManager,
|
|
99
|
+
binding: Parameters<KeybindingsManager["getKeys"]>[0],
|
|
100
|
+
fallback: readonly string[],
|
|
101
|
+
): string[] {
|
|
102
|
+
const getKeys = keybindings.getKeys?.bind(keybindings);
|
|
103
|
+
const keys = getKeys?.(binding) ?? fallback;
|
|
104
|
+
return uniqueKeys(keys.map(formatHintKey));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function uniqueKeys(keys: readonly string[]): string[] {
|
|
108
|
+
return [...new Set(keys.filter(Boolean))];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatHintKey(key: string): string {
|
|
112
|
+
const normalized = safeTerminalText(key).toLowerCase();
|
|
113
|
+
if (normalized === "up") return "↑";
|
|
114
|
+
if (normalized === "down") return "↓";
|
|
115
|
+
if (normalized === "left") return "←";
|
|
116
|
+
if (normalized === "right") return "→";
|
|
117
|
+
if (normalized === "return" || normalized === "enter") return "Enter";
|
|
118
|
+
if (normalized === "escape" || normalized === "esc") return "Esc";
|
|
119
|
+
if (normalized === "tab") return "Tab";
|
|
120
|
+
return normalized
|
|
121
|
+
.split("+")
|
|
122
|
+
.map((part, index) =>
|
|
123
|
+
part === "ctrl" || part === "alt" || part === "shift" || (index > 0 && part.length === 1)
|
|
124
|
+
? `${part[0]?.toUpperCase()}${part.slice(1)}`
|
|
125
|
+
: part,
|
|
126
|
+
)
|
|
127
|
+
.join("+");
|
|
128
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
interface IndexedProjectFile {
|
|
2
|
+
path: string;
|
|
3
|
+
displayPath: string;
|
|
4
|
+
segments: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ProjectBrowserItem =
|
|
8
|
+
| { kind: "directory"; path: string; label: string }
|
|
9
|
+
| { kind: "file"; path: string; label: string };
|
|
10
|
+
|
|
11
|
+
export class ProjectFileBrowser {
|
|
12
|
+
private readonly files: readonly IndexedProjectFile[];
|
|
13
|
+
private readonly filesByPath: ReadonlyMap<string, IndexedProjectFile>;
|
|
14
|
+
|
|
15
|
+
constructor(paths: readonly string[]) {
|
|
16
|
+
this.files = paths.map((path) => {
|
|
17
|
+
const displayPath = safeTerminalText(path);
|
|
18
|
+
return { path, displayPath, segments: displayPath.split("/") };
|
|
19
|
+
});
|
|
20
|
+
this.filesByPath = new Map(this.files.map((file) => [file.path, file]));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
list(directory: string): ProjectBrowserItem[] {
|
|
24
|
+
const directorySegments = directory ? directory.split("/") : [];
|
|
25
|
+
const directories = new Map<string, ProjectBrowserItem>();
|
|
26
|
+
const files: ProjectBrowserItem[] = [];
|
|
27
|
+
|
|
28
|
+
for (const file of this.files) {
|
|
29
|
+
if (!startsWithSegments(file.segments, directorySegments)) continue;
|
|
30
|
+
const childIndex = directorySegments.length;
|
|
31
|
+
const label = file.segments[childIndex];
|
|
32
|
+
if (!label) continue;
|
|
33
|
+
if (file.segments.length > childIndex + 1) {
|
|
34
|
+
const path = [...directorySegments, label].join("/");
|
|
35
|
+
directories.set(path, { kind: "directory", path, label });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
files.push({ kind: "file", path: file.path, label });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [...[...directories.values()].sort(compareBrowserItems), ...files.sort(compareBrowserItems)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
searchResults(paths: readonly string[]): ProjectBrowserItem[] {
|
|
45
|
+
return paths.flatMap((path) => {
|
|
46
|
+
const file = this.filesByPath.get(path);
|
|
47
|
+
return file ? [{ kind: "file" as const, path: file.path, label: file.displayPath }] : [];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parentProjectDirectory(directory: string): string {
|
|
53
|
+
const separator = directory.lastIndexOf("/");
|
|
54
|
+
return separator < 0 ? "" : directory.slice(0, separator);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function safeTerminalText(text: string): string {
|
|
58
|
+
return [...text]
|
|
59
|
+
.map((character) => {
|
|
60
|
+
if (character === "\t") return " ";
|
|
61
|
+
const code = character.charCodeAt(0);
|
|
62
|
+
if (code <= 31 || (code >= 127 && code <= 159)) {
|
|
63
|
+
return `\\x${code.toString(16).padStart(2, "0")}`;
|
|
64
|
+
}
|
|
65
|
+
return character;
|
|
66
|
+
})
|
|
67
|
+
.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startsWithSegments(path: readonly string[], prefix: readonly string[]): boolean {
|
|
71
|
+
return prefix.every((segment, index) => path[index] === segment);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function compareBrowserItems(left: ProjectBrowserItem, right: ProjectBrowserItem): number {
|
|
75
|
+
return left.label < right.label ? -1 : left.label > right.label ? 1 : 0;
|
|
76
|
+
}
|