@danypops/pi-lector 0.10.0 → 0.11.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/extension/src/editor/apply-explorer-diffs.ts +45 -0
- package/extension/src/editor/directory-explorer-operations.ts +61 -0
- package/extension/src/editor/editor-theme.ts +7 -0
- package/extension/src/editor/explorer-component.ts +241 -0
- package/extension/src/editor/explorer-diff.ts +85 -0
- package/extension/src/editor/explorer-flow.ts +27 -0
- package/extension/src/editor/neovim-editor-component.ts +2 -5
- package/extension/src/index.ts +51 -26
- package/package.json +3 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
|
|
2
|
+
import type { ExplorerDiff } from "./explorer-diff.ts";
|
|
3
|
+
|
|
4
|
+
function joinRelative(directory: string, name: string): string {
|
|
5
|
+
return directory === "" ? name : `${directory}/${name}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Applies a diff batch to `directory`, in order. Never partial-rolls-back a failure partway through -- the caller (the explorer's own :w confirmation flow) is responsible for surfacing which operations landed before an error, via each real thrown error's own message. */
|
|
9
|
+
export async function applyExplorerDiffs(session: DirectoryExplorerSession, directory: string, diffs: readonly ExplorerDiff[]): Promise<void> {
|
|
10
|
+
for (const diff of diffs) {
|
|
11
|
+
switch (diff.kind) {
|
|
12
|
+
case "create":
|
|
13
|
+
if (diff.isDirectory) await session.createDirectory(joinRelative(directory, diff.name));
|
|
14
|
+
else await session.createFile(joinRelative(directory, diff.name));
|
|
15
|
+
break;
|
|
16
|
+
case "rename":
|
|
17
|
+
await session.renamePath(joinRelative(directory, diff.fromName), joinRelative(directory, diff.toName));
|
|
18
|
+
break;
|
|
19
|
+
case "delete":
|
|
20
|
+
if (diff.isDirectory) await session.deleteDirectory(joinRelative(directory, diff.name));
|
|
21
|
+
else await session.deleteFile(joinRelative(directory, diff.name));
|
|
22
|
+
break;
|
|
23
|
+
default: {
|
|
24
|
+
const exhaustive: never = diff;
|
|
25
|
+
throw new Error(`unreachable explorer diff kind: ${JSON.stringify(exhaustive)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One human-readable confirmation line for a pending diff, shown before :w actually applies anything. */
|
|
32
|
+
export function summarizeExplorerDiff(diff: ExplorerDiff): string {
|
|
33
|
+
switch (diff.kind) {
|
|
34
|
+
case "create":
|
|
35
|
+
return `+ ${diff.name}${diff.isDirectory ? "/" : ""}`;
|
|
36
|
+
case "rename":
|
|
37
|
+
return `${diff.fromName} -> ${diff.toName}`;
|
|
38
|
+
case "delete":
|
|
39
|
+
return `- ${diff.name}${diff.isDirectory ? "/" : ""}`;
|
|
40
|
+
default: {
|
|
41
|
+
const exhaustive: never = diff;
|
|
42
|
+
throw new Error(`unreachable explorer diff kind: ${JSON.stringify(exhaustive)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DirectoryListing, WorkspaceId } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForPathOrDirectory } from "../lector-client.ts";
|
|
3
|
+
|
|
4
|
+
/** Oil's own default (view_options.show_hidden = false): dotfiles/dotdirs excluded unless explicitly toggled. Toggling is deferred (v2) -- this always applies for now, matching the tool's own out-of-the-box behavior. */
|
|
5
|
+
function isHidden(name: string): boolean {
|
|
6
|
+
return name.startsWith(".");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Backs the /editor no-path Oil-style explorer: one resolved workspace for the whole browsing
|
|
11
|
+
* session (navigating between directories never re-resolves a workspace, just changes which
|
|
12
|
+
* relative path is listed), plus the four mutation primitives its :w diff-and-apply step needs.
|
|
13
|
+
*
|
|
14
|
+
* deleteFile is not a thin pass-through: workspace.deleteEntry is hash-guarded and the explorer
|
|
15
|
+
* only ever has a directory *listing* (no content hash) for the line being deleted, so it reads
|
|
16
|
+
* the file's current hash immediately before deleting it -- an extra round trip, acceptable for
|
|
17
|
+
* an infrequent interactive action, not a hot path.
|
|
18
|
+
*/
|
|
19
|
+
export interface DirectoryExplorerSession {
|
|
20
|
+
readonly root: string;
|
|
21
|
+
readonly workspaceId: WorkspaceId;
|
|
22
|
+
listDirectory(relativePath: string): Promise<DirectoryListing>;
|
|
23
|
+
createFile(relativePath: string): Promise<void>;
|
|
24
|
+
createDirectory(relativePath: string): Promise<void>;
|
|
25
|
+
renamePath(oldRelativePath: string, newRelativePath: string): Promise<void>;
|
|
26
|
+
deleteFile(relativePath: string): Promise<void>;
|
|
27
|
+
deleteDirectory(relativePath: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function openDirectoryExplorer(absoluteDirectory: string): Promise<DirectoryExplorerSession> {
|
|
31
|
+
return withWorkspace(
|
|
32
|
+
() => workspaceForPathOrDirectory(absoluteDirectory),
|
|
33
|
+
async ({ workspaceId, root }) => {
|
|
34
|
+
const client = await lectorClient();
|
|
35
|
+
return {
|
|
36
|
+
root,
|
|
37
|
+
workspaceId,
|
|
38
|
+
listDirectory: async (relativePath: string): Promise<DirectoryListing> => {
|
|
39
|
+
const listing = await client.call("workspace.listDirectory", { workspaceId, path: relativePath });
|
|
40
|
+
return { ...listing, entries: listing.entries.filter((entry) => !isHidden(entry.name)) };
|
|
41
|
+
},
|
|
42
|
+
createFile: async (relativePath: string) => {
|
|
43
|
+
await client.callOnce("workspace.exactEdit", { workspaceId, path: relativePath, expectedHash: null, content: "" });
|
|
44
|
+
},
|
|
45
|
+
createDirectory: async (relativePath: string) => {
|
|
46
|
+
await client.callOnce("workspace.createDirectory", { workspaceId, path: relativePath });
|
|
47
|
+
},
|
|
48
|
+
renamePath: async (oldRelativePath: string, newRelativePath: string) => {
|
|
49
|
+
await client.callOnce("workspace.renamePath", { workspaceId, oldPath: oldRelativePath, newPath: newRelativePath });
|
|
50
|
+
},
|
|
51
|
+
deleteFile: async (relativePath: string) => {
|
|
52
|
+
const { hash } = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
|
|
53
|
+
await client.callOnce("workspace.deleteEntry", { workspaceId, path: relativePath, expectedHash: hash });
|
|
54
|
+
},
|
|
55
|
+
deleteDirectory: async (relativePath: string) => {
|
|
56
|
+
await client.callOnce("workspace.deleteDirectory", { workspaceId, path: relativePath });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Real theme, narrowed to exactly what the editor/explorer components need -- avoids depending on pi-coding-agent's full internal Theme shape. */
|
|
4
|
+
export interface EditorTheme {
|
|
5
|
+
fg(color: ThemeColor, text: string): string;
|
|
6
|
+
bg(color: "selectedBg", text: string): string;
|
|
7
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { join, posix } from "node:path";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { applyExplorerDiffs, summarizeExplorerDiff } from "./apply-explorer-diffs.ts";
|
|
5
|
+
import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
|
|
6
|
+
import type { EditorAction } from "./editor-state.ts";
|
|
7
|
+
import { EditorState } from "./editor-state.ts";
|
|
8
|
+
import type { EditorTheme } from "./editor-theme.ts";
|
|
9
|
+
import type { ExplorerDiff, ExplorerEntry } from "./explorer-diff.ts";
|
|
10
|
+
import { diffExplorerLines, formatExplorerLine, parseExplorerLine } from "./explorer-diff.ts";
|
|
11
|
+
|
|
12
|
+
export type ExplorerResult = { kind: "quit" } | { kind: "open-file"; absolutePath: string };
|
|
13
|
+
|
|
14
|
+
/** Joins a directory-relative name onto `directory` ("" means the resolved root itself). */
|
|
15
|
+
export function joinExplorerPath(directory: string, name: string): string {
|
|
16
|
+
return directory === "" ? name : `${directory}/${name}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The relative parent of a directory-relative path; "" once already at the root. */
|
|
20
|
+
function parentExplorerPath(directory: string): string {
|
|
21
|
+
const parent = posix.dirname(directory);
|
|
22
|
+
return parent === "." ? "" : parent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PendingConfirmation {
|
|
26
|
+
readonly diffs: readonly ExplorerDiff[];
|
|
27
|
+
readonly andQuit: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An oil.nvim-style directory explorer: the listing IS the buffer (EditorState/LiveBuffer,
|
|
32
|
+
* reused as-is from the file editor -- normal/insert/dd/yy/p/undo/:w all already do exactly what
|
|
33
|
+
* renaming, deleting, and creating entries as text edits needs). Enter/`-` are intercepted before
|
|
34
|
+
* EditorState ever sees them (it has no existing mapping for either, confirmed against its own
|
|
35
|
+
* normal-mode key handling) since they're navigation, not buffer edits.
|
|
36
|
+
*
|
|
37
|
+
* :w never applies silently -- it always shows a confirmation summary first (oil.nvim's own
|
|
38
|
+
* default, skip_confirm_for_simple_edits = false), even for a single change.
|
|
39
|
+
*/
|
|
40
|
+
export class ExplorerComponent implements Component {
|
|
41
|
+
private readonly session: DirectoryExplorerSession;
|
|
42
|
+
private readonly tui: TUI;
|
|
43
|
+
private readonly theme: EditorTheme;
|
|
44
|
+
private readonly done: (result: ExplorerResult) => void;
|
|
45
|
+
|
|
46
|
+
private currentPath = "";
|
|
47
|
+
private entries: ExplorerEntry[] = [];
|
|
48
|
+
private nextId = 1;
|
|
49
|
+
private state = new EditorState("");
|
|
50
|
+
private scrollTop = 1;
|
|
51
|
+
private statusMessage = "";
|
|
52
|
+
private confirming: PendingConfirmation | undefined;
|
|
53
|
+
|
|
54
|
+
constructor(tui: TUI, theme: EditorTheme, session: DirectoryExplorerSession, initialRelativePath: string, done: (result: ExplorerResult) => void) {
|
|
55
|
+
this.tui = tui;
|
|
56
|
+
this.theme = theme;
|
|
57
|
+
this.session = session;
|
|
58
|
+
this.done = done;
|
|
59
|
+
void this.loadDirectory(initialRelativePath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private pending: Promise<void> = Promise.resolve();
|
|
63
|
+
|
|
64
|
+
invalidate(): void {}
|
|
65
|
+
|
|
66
|
+
/** Fire-and-forget from the real TUI's own perspective (matching Component's void-returning contract) -- tests await settled() for deterministic assertions after triggering async work. */
|
|
67
|
+
handleInput(data: string): void {
|
|
68
|
+
this.pending = this.handleInputAsync(data);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolves once every async operation triggered by the most recent handleInput() call has finished -- a test-determinism hook, not part of the Component contract. */
|
|
72
|
+
settled(): Promise<void> {
|
|
73
|
+
return this.pending;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async handleInputAsync(data: string): Promise<void> {
|
|
77
|
+
if (this.confirming) {
|
|
78
|
+
await this.handleConfirmationInput(data);
|
|
79
|
+
this.tui.requestRender();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.state.mode === "normal") {
|
|
84
|
+
if (data === "\r" || data === "\n") {
|
|
85
|
+
await this.openCurrentLine();
|
|
86
|
+
this.tui.requestRender();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (data === "-") {
|
|
90
|
+
await this.navigateToParent();
|
|
91
|
+
this.tui.requestRender();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.state.handleKey(data);
|
|
97
|
+
this.scrollToKeepCursorVisible();
|
|
98
|
+
const action = this.state.pendingAction;
|
|
99
|
+
if (action) await this.performAction(action);
|
|
100
|
+
this.tui.requestRender();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async loadDirectory(relativePath: string): Promise<void> {
|
|
104
|
+
const listing = await this.session.listDirectory(relativePath);
|
|
105
|
+
this.currentPath = relativePath;
|
|
106
|
+
this.nextId = 1;
|
|
107
|
+
this.entries = listing.entries.map((entry) => ({ id: this.nextId++, name: entry.name, kind: entry.kind }));
|
|
108
|
+
const text = this.entries.length > 0 ? this.entries.map((entry) => formatExplorerLine(entry)).join("\n") : "";
|
|
109
|
+
this.state = new EditorState(text);
|
|
110
|
+
this.confirming = undefined;
|
|
111
|
+
this.statusMessage = "";
|
|
112
|
+
this.tui.requestRender();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async openCurrentLine(): Promise<void> {
|
|
116
|
+
const parsed = parseExplorerLine(this.state.currentLineText);
|
|
117
|
+
if (!parsed || parsed.id === null) return; // a blank line or an unsaved new entry -- nothing real to open yet
|
|
118
|
+
const entry = this.entries.find((candidate) => candidate.id === parsed.id);
|
|
119
|
+
if (!entry) return;
|
|
120
|
+
|
|
121
|
+
if (entry.kind === "directory") {
|
|
122
|
+
await this.loadDirectory(joinExplorerPath(this.currentPath, entry.name));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.done({ kind: "open-file", absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async navigateToParent(): Promise<void> {
|
|
129
|
+
if (this.currentPath === "") return; // already at the resolved root -- v1 never widens scope above it
|
|
130
|
+
await this.loadDirectory(parentExplorerPath(this.currentPath));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async performAction(action: EditorAction): Promise<void> {
|
|
134
|
+
switch (action.kind) {
|
|
135
|
+
case "save":
|
|
136
|
+
case "save-and-quit": {
|
|
137
|
+
const diffs = diffExplorerLines(this.entries, this.state.buffer.text.split("\n"));
|
|
138
|
+
this.state.dirty = false;
|
|
139
|
+
if (diffs.length === 0) {
|
|
140
|
+
this.statusMessage = "no changes";
|
|
141
|
+
if (action.kind === "save-and-quit") this.done({ kind: "quit" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.confirming = { diffs, andQuit: action.kind === "save-and-quit" };
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case "quit":
|
|
148
|
+
this.done({ kind: "quit" });
|
|
149
|
+
return;
|
|
150
|
+
case "hover":
|
|
151
|
+
this.statusMessage = "hover is not applicable in the file explorer";
|
|
152
|
+
return;
|
|
153
|
+
default: {
|
|
154
|
+
const exhaustive: never = action;
|
|
155
|
+
throw new Error(`Unhandled editor action: ${JSON.stringify(exhaustive)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async handleConfirmationInput(data: string): Promise<void> {
|
|
161
|
+
if (!this.confirming) return;
|
|
162
|
+
if (data === "n" || data === "\x1b") {
|
|
163
|
+
this.confirming = undefined;
|
|
164
|
+
this.statusMessage = "cancelled";
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (data !== "y" && data !== "\r" && data !== "\n") return;
|
|
168
|
+
|
|
169
|
+
const { diffs, andQuit } = this.confirming;
|
|
170
|
+
try {
|
|
171
|
+
await applyExplorerDiffs(this.session, this.currentPath, diffs);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
this.confirming = undefined;
|
|
174
|
+
this.statusMessage = `apply failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (andQuit) {
|
|
178
|
+
this.done({ kind: "quit" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await this.loadDirectory(this.currentPath);
|
|
182
|
+
this.statusMessage = "applied";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private scrollToKeepCursorVisible(): void {
|
|
186
|
+
const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
|
|
187
|
+
if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
|
|
188
|
+
else if (this.state.cursorLine >= this.scrollTop + viewportHeight) this.scrollTop = this.state.cursorLine - viewportHeight + 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
render(width: number): string[] {
|
|
192
|
+
if (this.confirming) return this.renderConfirmation(this.confirming, width);
|
|
193
|
+
|
|
194
|
+
const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
|
|
195
|
+
const lastLine = Math.min(this.state.buffer.lineCount, this.scrollTop + viewportHeight - 1);
|
|
196
|
+
|
|
197
|
+
const lines: string[] = [];
|
|
198
|
+
for (let line = this.scrollTop; line <= lastLine; line++) lines.push(this.renderLine(line, width));
|
|
199
|
+
while (lines.length < viewportHeight) lines.push("");
|
|
200
|
+
|
|
201
|
+
lines.push(this.renderStatusLine(width));
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private renderConfirmation(confirming: PendingConfirmation, width: number): string[] {
|
|
206
|
+
const lines = ["Pending changes:", "", ...confirming.diffs.map((diff) => ` ${summarizeExplorerDiff(diff)}`), "", "Apply? (y/n)"];
|
|
207
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private renderLine(line: number, width: number): string {
|
|
211
|
+
const lineText = this.state.buffer.lineText(line);
|
|
212
|
+
const isCursorLine = line === this.state.cursorLine;
|
|
213
|
+
const styled = isCursorLine ? this.renderLineWithCursor(lineText) : this.renderStyledLine(lineText);
|
|
214
|
+
return truncateToWidth(styled, width, "");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Dims the id prefix (a real character sequence in this terminal, not truly hidden -- see explorer-diff.ts) so it reads as secondary to the entry's own name. */
|
|
218
|
+
private renderStyledLine(lineText: string): string {
|
|
219
|
+
const match = lineText.match(/^(\d+ )(.*)$/);
|
|
220
|
+
if (!match) return lineText;
|
|
221
|
+
const [, idPart, rest] = match;
|
|
222
|
+
return `${this.theme.fg("muted", idPart ?? "")}${rest ?? ""}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private renderLineWithCursor(lineText: string): string {
|
|
226
|
+
const col = this.state.cursorCharacter - 1;
|
|
227
|
+
const before = this.renderStyledLine(lineText.slice(0, col));
|
|
228
|
+
const atCursor = col < lineText.length ? lineText[col] : " ";
|
|
229
|
+
const after = lineText.slice(col + 1);
|
|
230
|
+
return `${before}\x1b[7m${atCursor}\x1b[0m${after}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private renderStatusLine(width: number): string {
|
|
234
|
+
const modeLabel = { normal: " NORMAL ", insert: " INSERT ", command: " COMMAND " }[this.state.mode];
|
|
235
|
+
const location = this.currentPath === "" ? "/" : `/${this.currentPath}/`;
|
|
236
|
+
const left = this.state.mode === "command" ? `:${this.state.commandText}` : `${this.theme.fg("accent", modeLabel)} ${location}`;
|
|
237
|
+
const right = this.statusMessage || `${this.state.cursorLine}:${this.state.cursorCharacter}`;
|
|
238
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
239
|
+
return truncateToWidth(`${left}${" ".repeat(gap)}${right}`, width, "");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { FileTreeEntryKind } from "@danypops/lector";
|
|
2
|
+
|
|
3
|
+
/** One entry as last known from a real directory listing -- id is this explorer's own per-session identity, not anything Lector's daemon tracks. */
|
|
4
|
+
export interface ExplorerEntry {
|
|
5
|
+
readonly id: number;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly kind: FileTreeEntryKind;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** One buffer line, parsed back into its id (if any) and name. */
|
|
11
|
+
export interface ParsedExplorerLine {
|
|
12
|
+
readonly id: number | null;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly isDirectory: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ExplorerDiff =
|
|
18
|
+
| { readonly kind: "create"; readonly name: string; readonly isDirectory: boolean }
|
|
19
|
+
| { readonly kind: "rename"; readonly id: number; readonly fromName: string; readonly toName: string }
|
|
20
|
+
| { readonly kind: "delete"; readonly id: number; readonly name: string; readonly isDirectory: boolean };
|
|
21
|
+
|
|
22
|
+
/** Renders one entry as this explorer's own line format: "<id> name", a trailing "/" for directories. Ported from oil.nvim's own id-prefix convention (lua/oil/mutator/parser.lua) -- this terminal has no Neovim conceallevel equivalent, so the id is real, visible (if dimly styled) text, not truly hidden. */
|
|
23
|
+
export function formatExplorerLine(entry: ExplorerEntry): string {
|
|
24
|
+
return `${entry.id} ${entry.name}${entry.kind === "directory" ? "/" : ""}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EXISTING_LINE_PATTERN = /^(\d+) (.+)$/;
|
|
28
|
+
|
|
29
|
+
function stripTrailingSlash(name: string): { name: string; isDirectory: boolean } {
|
|
30
|
+
return name.endsWith("/") ? { name: name.slice(0, -1), isDirectory: true } : { name, isDirectory: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parses one buffer line back into id/name/isDirectory. Returns null for a blank line -- ignored entirely, never a new entry named "". */
|
|
34
|
+
export function parseExplorerLine(rawLine: string): ParsedExplorerLine | null {
|
|
35
|
+
const trimmed = rawLine.trim();
|
|
36
|
+
if (trimmed === "") return null;
|
|
37
|
+
|
|
38
|
+
const existingMatch = trimmed.match(EXISTING_LINE_PATTERN);
|
|
39
|
+
if (existingMatch) {
|
|
40
|
+
const idText = existingMatch[1];
|
|
41
|
+
const rest = existingMatch[2];
|
|
42
|
+
if (idText === undefined || rest === undefined) return null;
|
|
43
|
+
const { name, isDirectory } = stripTrailingSlash(rest.trim());
|
|
44
|
+
return { id: Number(idText), name, isDirectory };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { name, isDirectory } = stripTrailingSlash(trimmed);
|
|
48
|
+
return { id: null, name, isDirectory };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Diffs the explorer's current buffer lines against the original listing, matched by id -- never
|
|
53
|
+
* by line position, so reordering existing lines produces no diff at all. Every original entry
|
|
54
|
+
* not re-seen by its own id becomes a delete; every id-tagged line whose name changed becomes a
|
|
55
|
+
* rename; every line with no id tag becomes a create.
|
|
56
|
+
*/
|
|
57
|
+
export function diffExplorerLines(original: readonly ExplorerEntry[], currentLines: readonly string[]): ExplorerDiff[] {
|
|
58
|
+
const byId = new Map(original.map((entry) => [entry.id, entry]));
|
|
59
|
+
const unseenIds = new Set(byId.keys());
|
|
60
|
+
const diffs: ExplorerDiff[] = [];
|
|
61
|
+
|
|
62
|
+
for (const rawLine of currentLines) {
|
|
63
|
+
const parsed = parseExplorerLine(rawLine);
|
|
64
|
+
if (!parsed) continue;
|
|
65
|
+
|
|
66
|
+
if (parsed.id === null) {
|
|
67
|
+
diffs.push({ kind: "create", name: parsed.name, isDirectory: parsed.isDirectory });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const originalEntry = byId.get(parsed.id);
|
|
72
|
+
unseenIds.delete(parsed.id);
|
|
73
|
+
if (!originalEntry) continue; // an id that doesn't match anything real -- silently ignored, same as a stray line would be
|
|
74
|
+
if (originalEntry.name !== parsed.name) {
|
|
75
|
+
diffs.push({ kind: "rename", id: parsed.id, fromName: originalEntry.name, toName: parsed.name });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const id of unseenIds) {
|
|
80
|
+
const entry = byId.get(id);
|
|
81
|
+
if (entry) diffs.push({ kind: "delete", id: entry.id, name: entry.name, isDirectory: entry.kind === "directory" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return diffs;
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { dirname, relative } from "node:path";
|
|
2
|
+
import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
|
|
3
|
+
import type { ExplorerResult } from "./explorer-component.ts";
|
|
4
|
+
|
|
5
|
+
export interface ExplorerFlowHost {
|
|
6
|
+
/** Shows the explorer at `relativePath` (root-relative, "" for the resolved root) and resolves once the user quits or opens a file. */
|
|
7
|
+
showExplorer(session: DirectoryExplorerSession, relativePath: string): Promise<ExplorerResult>;
|
|
8
|
+
/** Shows the real file editor for `absolutePath` and resolves once the user quits it. */
|
|
9
|
+
showEditor(absolutePath: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Oil-style /editor-with-no-path loop: browse, open a file into the real editor, then return to
|
|
14
|
+
* the explorer -- at the directory the opened file lives in, not the resolved root -- once that
|
|
15
|
+
* editor quits, rather than closing the whole session after the first file opened.
|
|
16
|
+
*/
|
|
17
|
+
export async function runExplorerFlow(session: DirectoryExplorerSession, host: ExplorerFlowHost): Promise<void> {
|
|
18
|
+
let relativePath = "";
|
|
19
|
+
for (;;) {
|
|
20
|
+
const result = await host.showExplorer(session, relativePath);
|
|
21
|
+
if (result.kind === "quit") return;
|
|
22
|
+
|
|
23
|
+
await host.showEditor(result.absolutePath);
|
|
24
|
+
// path.relative(root, root) is "" directly, matching ExplorerComponent's own root-relative convention -- no "." case to normalize.
|
|
25
|
+
relativePath = relative(session.root, dirname(result.absolutePath));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -6,12 +6,9 @@ import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
|
6
6
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import type { EditorAction } from "./editor-state.ts";
|
|
8
8
|
import { EditorState } from "./editor-state.ts";
|
|
9
|
+
import type { EditorTheme } from "./editor-theme.ts";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
export interface EditorTheme {
|
|
12
|
-
fg(color: ThemeColor, text: string): string;
|
|
13
|
-
bg(color: "selectedBg", text: string): string;
|
|
14
|
-
}
|
|
11
|
+
export type { EditorTheme } from "./editor-theme.ts";
|
|
15
12
|
|
|
16
13
|
export interface NeovimEditorHost {
|
|
17
14
|
filePath: string;
|
package/extension/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
createReadToolDefinition,
|
|
36
36
|
createWriteToolDefinition,
|
|
37
37
|
type ExtensionAPI,
|
|
38
|
+
type ExtensionCommandContext,
|
|
38
39
|
} from "@earendil-works/pi-coding-agent";
|
|
39
40
|
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
40
41
|
import { renderBoundedTable, type TextMeasure } from "malevich-tui-components";
|
|
@@ -70,6 +71,9 @@ import {
|
|
|
70
71
|
import { type CrossWorkspaceOutcome, createLectorCrossWorkspaceSearchOperations } from "./cross-workspace-search/operations.ts";
|
|
71
72
|
import { formatCrossWorkspaceCall, formatFindSymbolsAcrossProjectsResult, formatSearchTextAcrossProjectsResult } from "./cross-workspace-search/rendering.ts";
|
|
72
73
|
import { createLectorEditOperations } from "./edit/operations.ts";
|
|
74
|
+
import { openDirectoryExplorer } from "./editor/directory-explorer-operations.ts";
|
|
75
|
+
import { ExplorerComponent, type ExplorerResult } from "./editor/explorer-component.ts";
|
|
76
|
+
import { runExplorerFlow } from "./editor/explorer-flow.ts";
|
|
73
77
|
import { NeovimEditorComponent, type NeovimEditorHost } from "./editor/neovim-editor-component.ts";
|
|
74
78
|
import { openEditorFile } from "./editor/operations.ts";
|
|
75
79
|
import { createExternalSearchOperations } from "./external-search/operations.ts";
|
|
@@ -347,38 +351,59 @@ export default function (pi: ExtensionAPI) {
|
|
|
347
351
|
|
|
348
352
|
const codeIntelligenceOperations = createLectorCodeIntelligenceOperations();
|
|
349
353
|
|
|
354
|
+
const editorOverlayOptions = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center" } } as const;
|
|
355
|
+
|
|
356
|
+
async function openFileInEditor(commandCtx: ExtensionCommandContext, absolutePath: string): Promise<void> {
|
|
357
|
+
let session: Awaited<ReturnType<typeof openEditorFile>>;
|
|
358
|
+
try {
|
|
359
|
+
session = await openEditorFile(absolutePath);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
commandCtx.ui.notify(`Could not open ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await commandCtx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
366
|
+
const host: NeovimEditorHost = {
|
|
367
|
+
filePath: absolutePath,
|
|
368
|
+
save: (text) => session.save(text),
|
|
369
|
+
hover: async (line, character) => {
|
|
370
|
+
const result = await codeIntelligenceOperations.hover(absolutePath, line, character);
|
|
371
|
+
return result.hover;
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
return new NeovimEditorComponent(tui, theme, host, session.content, () => done(undefined));
|
|
375
|
+
}, editorOverlayOptions);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Oil-style: /editor with no path browses the caller's cwd (nearest git root, matching workspaceForDirectory's own convention). runExplorerFlow owns the browse/open/return-to-explorer loop; this just wires it to the real UI and Lector session. */
|
|
379
|
+
async function openExplorerFlow(commandCtx: ExtensionCommandContext): Promise<void> {
|
|
380
|
+
let session: Awaited<ReturnType<typeof openDirectoryExplorer>>;
|
|
381
|
+
try {
|
|
382
|
+
session = await openDirectoryExplorer(commandCtx.cwd);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
commandCtx.ui.notify(`Could not open explorer at ${commandCtx.cwd}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await runExplorerFlow(session, {
|
|
389
|
+
showExplorer: (explorerSession, relativePath) =>
|
|
390
|
+
commandCtx.ui.custom<ExplorerResult>(
|
|
391
|
+
(tui, theme, _keybindings, done) => new ExplorerComponent(tui, theme, explorerSession, relativePath, done),
|
|
392
|
+
editorOverlayOptions,
|
|
393
|
+
),
|
|
394
|
+
showEditor: (absolutePath) => openFileInEditor(commandCtx, absolutePath),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
350
398
|
pi.registerCommand("editor", {
|
|
351
|
-
description: "Open a file in a neovim-style modal code editor",
|
|
399
|
+
description: "Open a file in a neovim-style modal code editor, or a filesystem explorer with no path",
|
|
352
400
|
handler: async (args, commandCtx) => {
|
|
353
401
|
const target = args.trim();
|
|
354
402
|
if (!target) {
|
|
355
|
-
commandCtx
|
|
403
|
+
await openExplorerFlow(commandCtx);
|
|
356
404
|
return;
|
|
357
405
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
let session: Awaited<ReturnType<typeof openEditorFile>>;
|
|
361
|
-
try {
|
|
362
|
-
session = await openEditorFile(absolutePath);
|
|
363
|
-
} catch (error) {
|
|
364
|
-
commandCtx.ui.notify(`Could not open ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
await commandCtx.ui.custom<void>(
|
|
369
|
-
(tui, theme, _keybindings, done) => {
|
|
370
|
-
const host: NeovimEditorHost = {
|
|
371
|
-
filePath: absolutePath,
|
|
372
|
-
save: (text) => session.save(text),
|
|
373
|
-
hover: async (line, character) => {
|
|
374
|
-
const result = await codeIntelligenceOperations.hover(absolutePath, line, character);
|
|
375
|
-
return result.hover;
|
|
376
|
-
},
|
|
377
|
-
};
|
|
378
|
-
return new NeovimEditorComponent(tui, theme, host, session.content, () => done(undefined));
|
|
379
|
-
},
|
|
380
|
-
{ overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "center" } },
|
|
381
|
-
);
|
|
406
|
+
await openFileInEditor(commandCtx, resolve(commandCtx.cwd, target));
|
|
382
407
|
},
|
|
383
408
|
});
|
|
384
409
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-lector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@danypops/vehicle-client": "^0.2.0",
|
|
22
|
-
"@danypops/lector": "^0.
|
|
22
|
+
"@danypops/lector": "^0.15.0",
|
|
23
23
|
"malevich-tui-components": "^0.19.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
+
"@danypops/pi-tui-harness": "^0.0.1",
|
|
26
27
|
"@earendil-works/pi-ai": "^0.81.1",
|
|
27
28
|
"@earendil-works/pi-coding-agent": "^0.81.1",
|
|
28
29
|
"@earendil-works/pi-tui": "^0.81.1",
|