@danypops/pi-lector 0.10.0 → 0.11.1

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.
@@ -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,252 @@
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
+ this.pending = this.loadDirectory(initialRelativePath).catch((error: unknown) => this.reportError(error));
60
+ }
61
+
62
+ private pending: Promise<void> = Promise.resolve();
63
+
64
+ invalidate(): void {}
65
+
66
+ /**
67
+ * Fire-and-forget from the real TUI's own perspective (matching Component's void-returning
68
+ * contract) -- tests await settled() for deterministic assertions after triggering async work.
69
+ * Any rejection (a daemon call failing, a stale/incompatible daemon, a genuine bug) is caught
70
+ * here rather than left to become an unhandled rejection: that crashed the whole Pi process in
71
+ * production once already, when a stale running daemon didn't yet support workspace.listDirectory.
72
+ */
73
+ handleInput(data: string): void {
74
+ this.pending = this.handleInputAsync(data).catch((error: unknown) => this.reportError(error));
75
+ }
76
+
77
+ private reportError(error: unknown): void {
78
+ this.statusMessage = `error: ${error instanceof Error ? error.message : String(error)}`;
79
+ this.tui.requestRender();
80
+ }
81
+
82
+ /** Resolves once every async operation triggered by the most recent handleInput() call has finished -- a test-determinism hook, not part of the Component contract. */
83
+ settled(): Promise<void> {
84
+ return this.pending;
85
+ }
86
+
87
+ private async handleInputAsync(data: string): Promise<void> {
88
+ if (this.confirming) {
89
+ await this.handleConfirmationInput(data);
90
+ this.tui.requestRender();
91
+ return;
92
+ }
93
+
94
+ if (this.state.mode === "normal") {
95
+ if (data === "\r" || data === "\n") {
96
+ await this.openCurrentLine();
97
+ this.tui.requestRender();
98
+ return;
99
+ }
100
+ if (data === "-") {
101
+ await this.navigateToParent();
102
+ this.tui.requestRender();
103
+ return;
104
+ }
105
+ }
106
+
107
+ this.state.handleKey(data);
108
+ this.scrollToKeepCursorVisible();
109
+ const action = this.state.pendingAction;
110
+ if (action) await this.performAction(action);
111
+ this.tui.requestRender();
112
+ }
113
+
114
+ private async loadDirectory(relativePath: string): Promise<void> {
115
+ const listing = await this.session.listDirectory(relativePath);
116
+ this.currentPath = relativePath;
117
+ this.nextId = 1;
118
+ this.entries = listing.entries.map((entry) => ({ id: this.nextId++, name: entry.name, kind: entry.kind }));
119
+ const text = this.entries.length > 0 ? this.entries.map((entry) => formatExplorerLine(entry)).join("\n") : "";
120
+ this.state = new EditorState(text);
121
+ this.confirming = undefined;
122
+ this.statusMessage = "";
123
+ this.tui.requestRender();
124
+ }
125
+
126
+ private async openCurrentLine(): Promise<void> {
127
+ const parsed = parseExplorerLine(this.state.currentLineText);
128
+ if (!parsed || parsed.id === null) return; // a blank line or an unsaved new entry -- nothing real to open yet
129
+ const entry = this.entries.find((candidate) => candidate.id === parsed.id);
130
+ if (!entry) return;
131
+
132
+ if (entry.kind === "directory") {
133
+ await this.loadDirectory(joinExplorerPath(this.currentPath, entry.name));
134
+ return;
135
+ }
136
+ this.done({ kind: "open-file", absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)) });
137
+ }
138
+
139
+ private async navigateToParent(): Promise<void> {
140
+ if (this.currentPath === "") return; // already at the resolved root -- v1 never widens scope above it
141
+ await this.loadDirectory(parentExplorerPath(this.currentPath));
142
+ }
143
+
144
+ private async performAction(action: EditorAction): Promise<void> {
145
+ switch (action.kind) {
146
+ case "save":
147
+ case "save-and-quit": {
148
+ const diffs = diffExplorerLines(this.entries, this.state.buffer.text.split("\n"));
149
+ this.state.dirty = false;
150
+ if (diffs.length === 0) {
151
+ this.statusMessage = "no changes";
152
+ if (action.kind === "save-and-quit") this.done({ kind: "quit" });
153
+ return;
154
+ }
155
+ this.confirming = { diffs, andQuit: action.kind === "save-and-quit" };
156
+ return;
157
+ }
158
+ case "quit":
159
+ this.done({ kind: "quit" });
160
+ return;
161
+ case "hover":
162
+ this.statusMessage = "hover is not applicable in the file explorer";
163
+ return;
164
+ default: {
165
+ const exhaustive: never = action;
166
+ throw new Error(`Unhandled editor action: ${JSON.stringify(exhaustive)}`);
167
+ }
168
+ }
169
+ }
170
+
171
+ private async handleConfirmationInput(data: string): Promise<void> {
172
+ if (!this.confirming) return;
173
+ if (data === "n" || data === "\x1b") {
174
+ this.confirming = undefined;
175
+ this.statusMessage = "cancelled";
176
+ return;
177
+ }
178
+ if (data !== "y" && data !== "\r" && data !== "\n") return;
179
+
180
+ const { diffs, andQuit } = this.confirming;
181
+ try {
182
+ await applyExplorerDiffs(this.session, this.currentPath, diffs);
183
+ } catch (error) {
184
+ this.confirming = undefined;
185
+ this.statusMessage = `apply failed: ${error instanceof Error ? error.message : String(error)}`;
186
+ return;
187
+ }
188
+ if (andQuit) {
189
+ this.done({ kind: "quit" });
190
+ return;
191
+ }
192
+ await this.loadDirectory(this.currentPath);
193
+ this.statusMessage = "applied";
194
+ }
195
+
196
+ private scrollToKeepCursorVisible(): void {
197
+ const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
198
+ if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
199
+ else if (this.state.cursorLine >= this.scrollTop + viewportHeight) this.scrollTop = this.state.cursorLine - viewportHeight + 1;
200
+ }
201
+
202
+ render(width: number): string[] {
203
+ if (this.confirming) return this.renderConfirmation(this.confirming, width);
204
+
205
+ const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
206
+ const lastLine = Math.min(this.state.buffer.lineCount, this.scrollTop + viewportHeight - 1);
207
+
208
+ const lines: string[] = [];
209
+ for (let line = this.scrollTop; line <= lastLine; line++) lines.push(this.renderLine(line, width));
210
+ while (lines.length < viewportHeight) lines.push("");
211
+
212
+ lines.push(this.renderStatusLine(width));
213
+ return lines;
214
+ }
215
+
216
+ private renderConfirmation(confirming: PendingConfirmation, width: number): string[] {
217
+ const lines = ["Pending changes:", "", ...confirming.diffs.map((diff) => ` ${summarizeExplorerDiff(diff)}`), "", "Apply? (y/n)"];
218
+ return lines.map((line) => truncateToWidth(line, width, ""));
219
+ }
220
+
221
+ private renderLine(line: number, width: number): string {
222
+ const lineText = this.state.buffer.lineText(line);
223
+ const isCursorLine = line === this.state.cursorLine;
224
+ const styled = isCursorLine ? this.renderLineWithCursor(lineText) : this.renderStyledLine(lineText);
225
+ return truncateToWidth(styled, width, "");
226
+ }
227
+
228
+ /** 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. */
229
+ private renderStyledLine(lineText: string): string {
230
+ const match = lineText.match(/^(\d+ )(.*)$/);
231
+ if (!match) return lineText;
232
+ const [, idPart, rest] = match;
233
+ return `${this.theme.fg("muted", idPart ?? "")}${rest ?? ""}`;
234
+ }
235
+
236
+ private renderLineWithCursor(lineText: string): string {
237
+ const col = this.state.cursorCharacter - 1;
238
+ const before = this.renderStyledLine(lineText.slice(0, col));
239
+ const atCursor = col < lineText.length ? lineText[col] : " ";
240
+ const after = lineText.slice(col + 1);
241
+ return `${before}\x1b[7m${atCursor}\x1b[0m${after}`;
242
+ }
243
+
244
+ private renderStatusLine(width: number): string {
245
+ const modeLabel = { normal: " NORMAL ", insert: " INSERT ", command: " COMMAND " }[this.state.mode];
246
+ const location = this.currentPath === "" ? "/" : `/${this.currentPath}/`;
247
+ const left = this.state.mode === "command" ? `:${this.state.commandText}` : `${this.theme.fg("accent", modeLabel)} ${location}`;
248
+ const right = this.statusMessage || `${this.state.cursorLine}:${this.state.cursorCharacter}`;
249
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
250
+ return truncateToWidth(`${left}${" ".repeat(gap)}${right}`, width, "");
251
+ }
252
+ }
@@ -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
- /** Real theme, narrowed to exactly what this component needs -- avoids depending on pi-coding-agent's full internal Theme shape. */
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;
@@ -57,21 +54,40 @@ export class NeovimEditorComponent implements Component {
57
54
  this.done = done;
58
55
  this.extension = extname(host.filePath);
59
56
  this.state = new EditorState(content);
60
- void this.refreshHighlights();
57
+ this.refreshHighlightsSafely();
61
58
  }
62
59
 
63
60
  invalidate(): void {
64
61
  this.highlightCache = undefined;
65
62
  }
66
63
 
64
+ /**
65
+ * Fire-and-forget from the real TUI's own perspective (matching Component's void-returning
66
+ * contract). Any rejection from performAction (host.save/host.hover talking to a daemon that
67
+ * fails or restarts mid-session) is caught here rather than left to become an unhandled
68
+ * rejection that crashes the whole Pi process -- the same defect class found and fixed in
69
+ * ExplorerComponent's own constructor.
70
+ */
67
71
  handleInput(data: string): void {
68
72
  this.state.handleKey(data);
69
73
  this.scrollToKeepCursorVisible();
70
74
  const action = this.state.pendingAction;
71
- if (action) void this.performAction(action);
75
+ if (action) this.performActionSafely(action);
72
76
  this.tui.requestRender();
73
77
  }
74
78
 
79
+ private performActionSafely(action: EditorAction): void {
80
+ void this.performAction(action).catch((error: unknown) => {
81
+ this.statusMessage = `error: ${error instanceof Error ? error.message : String(error)}`;
82
+ this.tui.requestRender();
83
+ });
84
+ }
85
+
86
+ /** Highlighting is cosmetic: a failure here must never surface as a status message that stomps a real save/hover result, and must never crash the editor. */
87
+ private refreshHighlightsSafely(): void {
88
+ void this.refreshHighlights().catch(() => undefined);
89
+ }
90
+
75
91
  private async performAction(action: EditorAction): Promise<void> {
76
92
  switch (action.kind) {
77
93
  case "save":
@@ -115,6 +131,10 @@ export class NeovimEditorComponent implements Component {
115
131
  this.tui.requestRender();
116
132
  }
117
133
 
134
+ private refreshHighlightsIfStale(): void {
135
+ if (this.highlightCache?.text !== this.state.buffer.text) this.refreshHighlightsSafely();
136
+ }
137
+
118
138
  private scrollToKeepCursorVisible(): void {
119
139
  const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
120
140
  if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
@@ -122,7 +142,7 @@ export class NeovimEditorComponent implements Component {
122
142
  }
123
143
 
124
144
  render(width: number): string[] {
125
- if (this.highlightCache?.text !== this.state.buffer.text) void this.refreshHighlights();
145
+ this.refreshHighlightsIfStale();
126
146
 
127
147
  const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
128
148
  const gutterWidth = Math.max(3, String(this.state.buffer.lineCount).length) + 1;
@@ -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.ui.notify("Usage: /editor <path>", "error");
403
+ await openExplorerFlow(commandCtx);
356
404
  return;
357
405
  }
358
- const absolutePath = resolve(commandCtx.cwd, target);
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.10.0",
3
+ "version": "0.11.1",
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,12 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@danypops/vehicle-client": "^0.2.0",
22
- "@danypops/lector": "^0.14.0",
22
+ "@danypops/lector": "^0.15.0",
23
23
  "malevich-tui-components": "^0.19.0"
24
24
  },
25
25
  "devDependencies": {
26
+ "@danypops/pi-extension-harness": "^0.2.0",
27
+ "@danypops/pi-tui-harness": "^0.0.1",
26
28
  "@earendil-works/pi-ai": "^0.81.1",
27
29
  "@earendil-works/pi-coding-agent": "^0.81.1",
28
30
  "@earendil-works/pi-tui": "^0.81.1",