@danypops/pi-lector 0.9.6 → 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/{apply-patch-operations.ts → apply-patch/operations.ts} +3 -3
- package/extension/src/{apply-patch-rendering.ts → apply-patch/rendering.ts} +1 -1
- package/extension/src/{code-intelligence-operations.ts → code-intelligence/operations.ts} +2 -2
- package/extension/src/{code-intelligence-rendering.ts → code-intelligence/rendering.ts} +1 -1
- package/extension/src/{cross-workspace-search-operations.ts → cross-workspace-search/operations.ts} +1 -1
- package/extension/src/{cross-workspace-search-rendering.ts → cross-workspace-search/rendering.ts} +18 -6
- package/extension/src/{edit-operations.ts → edit/operations.ts} +2 -2
- 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-state.ts +317 -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 +196 -0
- package/extension/src/editor/operations.ts +36 -0
- package/extension/src/{external-search-operations.ts → external-search/operations.ts} +1 -1
- package/extension/src/{external-search-rendering.ts → external-search/rendering.ts} +1 -1
- package/extension/src/{find-files-operations.ts → find-files/operations.ts} +1 -1
- package/extension/src/{find-files-rendering.ts → find-files/rendering.ts} +1 -1
- package/extension/src/{find-symbols-operations.ts → find-symbols/operations.ts} +1 -1
- package/extension/src/{find-symbols-rendering.ts → find-symbols/rendering.ts} +1 -1
- package/extension/src/{git-operations.ts → git/operations.ts} +1 -1
- package/extension/src/{git-rendering.ts → git/rendering.ts} +1 -1
- package/extension/src/index.ts +97 -34
- package/extension/src/{line-edit-operations.ts → line-edit/operations.ts} +4 -4
- package/extension/src/{line-edit-rendering.ts → line-edit/rendering.ts} +1 -1
- package/extension/src/{mutation-history-operations.ts → mutation-history/operations.ts} +2 -2
- package/extension/src/{package-source-operations.ts → package-source/operations.ts} +1 -1
- package/extension/src/{package-source-rendering.ts → package-source/rendering.ts} +35 -28
- package/extension/src/{read-operations.ts → read/operations.ts} +2 -2
- package/extension/src/{reference-based-rename-operations.ts → reference-based-rename/operations.ts} +1 -1
- package/extension/src/{rename-operations.ts → rename/operations.ts} +1 -1
- package/extension/src/{repo-cache-evict-operations.ts → repo-cache/evict-operations.ts} +2 -2
- package/extension/src/{repo-cache-list-operations.ts → repo-cache/list-operations.ts} +2 -2
- package/extension/src/{repo-cache-rendering.ts → repo-cache/rendering.ts} +1 -1
- package/extension/src/{repo-fetch-operations.ts → repo-fetch/operations.ts} +1 -1
- package/extension/src/{search-operations.ts → search/operations.ts} +1 -1
- package/extension/src/{search-rendering.ts → search/rendering.ts} +1 -1
- package/extension/src/{symbol-annotation-operations.ts → symbol-annotation/operations.ts} +1 -1
- package/extension/src/{symbol-annotation-rendering.ts → symbol-annotation/rendering.ts} +1 -1
- package/extension/src/{workspace-cache-operations.ts → workspace-cache/operations.ts} +1 -1
- package/extension/src/{workspace-cache-rendering.ts → workspace-cache/rendering.ts} +1 -1
- package/extension/src/{write-operations.ts → write/operations.ts} +2 -2
- package/package.json +3 -2
- /package/extension/src/{code-intelligence-hints.ts → code-intelligence/hints.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { extname } from "node:path";
|
|
2
|
+
import type { HighlightSpan } from "@danypops/lector";
|
|
3
|
+
import { highlightSpans } from "@danypops/lector";
|
|
4
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import type { EditorAction } from "./editor-state.ts";
|
|
8
|
+
import { EditorState } from "./editor-state.ts";
|
|
9
|
+
import type { EditorTheme } from "./editor-theme.ts";
|
|
10
|
+
|
|
11
|
+
export type { EditorTheme } from "./editor-theme.ts";
|
|
12
|
+
|
|
13
|
+
export interface NeovimEditorHost {
|
|
14
|
+
filePath: string;
|
|
15
|
+
/** Saves the buffer's current text through Lector's hash-guarded write. Throws (surfaced as a status message, not a crash) on a genuinely concurrent external change. */
|
|
16
|
+
save(text: string): Promise<void>;
|
|
17
|
+
/** Real hover info from Lector's existing code-intelligence operation, or undefined when there is none at this position. */
|
|
18
|
+
hover(line: number, character: number): Promise<{ contents: string } | undefined>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CAPTURE_COLOR: Record<string, ThemeColor> = {
|
|
22
|
+
keyword: "syntaxKeyword",
|
|
23
|
+
comment: "syntaxComment",
|
|
24
|
+
string: "syntaxString",
|
|
25
|
+
number: "syntaxNumber",
|
|
26
|
+
function: "syntaxFunction",
|
|
27
|
+
type: "syntaxType",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A real, full-file, neovim-style modal code editor Component -- not a CustomEditor subclass
|
|
32
|
+
* (that API replaces Pi's own chat input, not a full-file view; confirmed against
|
|
33
|
+
* docs/tui.md's Pattern 7 and examples/extensions/modal-editor.ts). Renders as a `ctx.ui.custom`
|
|
34
|
+
* overlay. Owns no authoritative state of its own past the open edit session: `EditorState`'s
|
|
35
|
+
* LiveBuffer is the only in-memory copy, and every save round-trips through the host's
|
|
36
|
+
* hash-guarded write -- never a second source of truth for the file's real disk content.
|
|
37
|
+
*/
|
|
38
|
+
export class NeovimEditorComponent implements Component {
|
|
39
|
+
private readonly state: EditorState;
|
|
40
|
+
private readonly host: NeovimEditorHost;
|
|
41
|
+
private readonly tui: TUI;
|
|
42
|
+
private readonly theme: EditorTheme;
|
|
43
|
+
private readonly done: () => void;
|
|
44
|
+
private readonly extension: string;
|
|
45
|
+
|
|
46
|
+
private scrollTop = 1;
|
|
47
|
+
private statusMessage = "";
|
|
48
|
+
private highlightCache: { text: string; spans: readonly HighlightSpan[] } | undefined;
|
|
49
|
+
|
|
50
|
+
constructor(tui: TUI, theme: EditorTheme, host: NeovimEditorHost, content: string, done: () => void) {
|
|
51
|
+
this.tui = tui;
|
|
52
|
+
this.theme = theme;
|
|
53
|
+
this.host = host;
|
|
54
|
+
this.done = done;
|
|
55
|
+
this.extension = extname(host.filePath);
|
|
56
|
+
this.state = new EditorState(content);
|
|
57
|
+
void this.refreshHighlights();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
invalidate(): void {
|
|
61
|
+
this.highlightCache = undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
handleInput(data: string): void {
|
|
65
|
+
this.state.handleKey(data);
|
|
66
|
+
this.scrollToKeepCursorVisible();
|
|
67
|
+
const action = this.state.pendingAction;
|
|
68
|
+
if (action) void this.performAction(action);
|
|
69
|
+
this.tui.requestRender();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async performAction(action: EditorAction): Promise<void> {
|
|
73
|
+
switch (action.kind) {
|
|
74
|
+
case "save":
|
|
75
|
+
case "save-and-quit": {
|
|
76
|
+
try {
|
|
77
|
+
await this.host.save(this.state.buffer.text);
|
|
78
|
+
this.state.dirty = false;
|
|
79
|
+
this.statusMessage = `"${this.host.filePath}" written`;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.statusMessage = `save failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
82
|
+
this.tui.requestRender();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action.kind === "save-and-quit") {
|
|
86
|
+
this.done();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "quit":
|
|
92
|
+
this.done();
|
|
93
|
+
return;
|
|
94
|
+
case "hover": {
|
|
95
|
+
const hover = await this.host.hover(this.state.cursorLine, this.state.cursorCharacter);
|
|
96
|
+
const firstLine = hover?.contents.split("\n")[0];
|
|
97
|
+
this.statusMessage = firstLine ?? "no hover info at this position";
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
default: {
|
|
101
|
+
const exhaustive: never = action;
|
|
102
|
+
throw new Error(`Unhandled editor action: ${JSON.stringify(exhaustive)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.tui.requestRender();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async refreshHighlights(): Promise<void> {
|
|
109
|
+
const text = this.state.buffer.text;
|
|
110
|
+
const spans = await highlightSpans(text, this.extension);
|
|
111
|
+
this.highlightCache = { text, spans };
|
|
112
|
+
this.tui.requestRender();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private scrollToKeepCursorVisible(): void {
|
|
116
|
+
const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
|
|
117
|
+
if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
|
|
118
|
+
else if (this.state.cursorLine >= this.scrollTop + viewportHeight) this.scrollTop = this.state.cursorLine - viewportHeight + 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
render(width: number): string[] {
|
|
122
|
+
if (this.highlightCache?.text !== this.state.buffer.text) void this.refreshHighlights();
|
|
123
|
+
|
|
124
|
+
const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
|
|
125
|
+
const gutterWidth = Math.max(3, String(this.state.buffer.lineCount).length) + 1;
|
|
126
|
+
const lastLine = Math.min(this.state.buffer.lineCount, this.scrollTop + viewportHeight - 1);
|
|
127
|
+
|
|
128
|
+
const lines: string[] = [];
|
|
129
|
+
for (let line = this.scrollTop; line <= lastLine; line++) {
|
|
130
|
+
lines.push(this.renderLine(line, gutterWidth, width - gutterWidth));
|
|
131
|
+
}
|
|
132
|
+
while (lines.length < viewportHeight) lines.push("");
|
|
133
|
+
|
|
134
|
+
lines.push(this.renderStatusLine(width));
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private renderLine(line: number, gutterWidth: number, contentWidth: number): string {
|
|
139
|
+
const isCursorLine = line === this.state.cursorLine;
|
|
140
|
+
const gutterNumber = isCursorLine ? String(line) : String(Math.abs(line - this.state.cursorLine));
|
|
141
|
+
const gutter = `${this.theme.fg(isCursorLine ? "text" : "muted", gutterNumber.padStart(gutterWidth - 1))} `;
|
|
142
|
+
|
|
143
|
+
const lineText = this.state.buffer.lineText(line);
|
|
144
|
+
const rendered = isCursorLine ? this.renderLineWithCursor(line, lineText) : this.renderHighlightedLine(line, lineText);
|
|
145
|
+
return gutter + truncateToWidth(rendered, contentWidth, "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private spansForLine(line: number): readonly HighlightSpan[] {
|
|
149
|
+
if (!this.highlightCache) return [];
|
|
150
|
+
const from = this.state.buffer.offsetAt({ line, character: 1 });
|
|
151
|
+
const to = from + this.state.buffer.lineText(line).length;
|
|
152
|
+
return this.highlightCache.spans.filter((span) => span.startIndex < to && span.endIndex > from);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private renderHighlightedLine(line: number, lineText: string): string {
|
|
156
|
+
const from = this.state.buffer.offsetAt({ line, character: 1 });
|
|
157
|
+
const spans = this.spansForLine(line);
|
|
158
|
+
let result = "";
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
for (const span of spans) {
|
|
161
|
+
const start = Math.max(span.startIndex - from, 0);
|
|
162
|
+
const end = Math.min(span.endIndex - from, lineText.length);
|
|
163
|
+
if (start < cursor) continue; // overlapping captures -- keep the earliest, simplest for v1
|
|
164
|
+
result += lineText.slice(cursor, start);
|
|
165
|
+
const color = CAPTURE_COLOR[span.capture];
|
|
166
|
+
result += color ? this.theme.fg(color, lineText.slice(start, end)) : lineText.slice(start, end);
|
|
167
|
+
cursor = end;
|
|
168
|
+
}
|
|
169
|
+
result += lineText.slice(cursor);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The cursor's own line: highlighted text plus an inverse-video cursor cell, matching pi-tui's own Editor cursor convention. */
|
|
174
|
+
private renderLineWithCursor(line: number, lineText: string): string {
|
|
175
|
+
const highlighted = this.renderHighlightedLine(line, lineText);
|
|
176
|
+
// Re-slicing a pre-highlighted (ANSI-embedded) string by character index would misplace the
|
|
177
|
+
// cursor inside escape codes -- render the cursor against the plain text, forgoing this
|
|
178
|
+
// line's syntax highlighting. Acceptable for v1: only one line is ever affected at a time.
|
|
179
|
+
void highlighted;
|
|
180
|
+
const col = this.state.cursorCharacter - 1;
|
|
181
|
+
const before = lineText.slice(0, col);
|
|
182
|
+
const atCursor = col < lineText.length ? lineText[col] : " ";
|
|
183
|
+
const after = col < lineText.length ? lineText.slice(col + 1) : "";
|
|
184
|
+
return `${before}\x1b[7m${atCursor}\x1b[0m${after}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private renderStatusLine(width: number): string {
|
|
188
|
+
const modeLabel = { normal: " NORMAL ", insert: " INSERT ", command: " COMMAND " }[this.state.mode];
|
|
189
|
+
const dirtyMarker = this.state.dirty ? " [+]" : "";
|
|
190
|
+
const position = `${this.state.cursorLine}:${this.state.cursorCharacter}`;
|
|
191
|
+
const left = this.state.mode === "command" ? `:${this.state.commandText}` : `${this.theme.fg("accent", modeLabel)} ${this.host.filePath}${dirtyMarker}`;
|
|
192
|
+
const right = this.statusMessage || position;
|
|
193
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
194
|
+
return truncateToWidth(`${left}${" ".repeat(gap)}${right}`, width, "");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ContentHash } from "@danypops/lector";
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForPath } from "../lector-client.ts";
|
|
3
|
+
import { toWorkspaceRelativePath } from "../workspace-relative-path.ts";
|
|
4
|
+
|
|
5
|
+
export interface EditorFileSession {
|
|
6
|
+
readonly content: string;
|
|
7
|
+
/**
|
|
8
|
+
* Saves through Lector's hash-guarded `workspace.exactEdit` -- deliberately NOT the
|
|
9
|
+
* transparent stale-hash-retry behavior `createLectorWriteOperations` uses for pi's
|
|
10
|
+
* unconditional-overwrite write tool. `/editor`'s `:w` is a human editing a file they can see
|
|
11
|
+
* on screen; a genuinely concurrent external change must surface as a real, visible error
|
|
12
|
+
* (StaleExpectedHash), never be silently overwritten just because the model's write-tool
|
|
13
|
+
* contract happens to prefer that elsewhere.
|
|
14
|
+
*/
|
|
15
|
+
save(text: string): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Opens `absolutePath` for `/editor`: one hash-guarded read, then a save() closure that tracks the hash across saves within the same session. */
|
|
19
|
+
export function openEditorFile(absolutePath: string): Promise<EditorFileSession> {
|
|
20
|
+
return withWorkspace(
|
|
21
|
+
() => workspaceForPath(absolutePath),
|
|
22
|
+
async ({ workspaceId, root }) => {
|
|
23
|
+
const client = await lectorClient();
|
|
24
|
+
const relativePath = toWorkspaceRelativePath(root, absolutePath);
|
|
25
|
+
const { content, hash } = await client.call("workspace.rawRead", { workspaceId, path: relativePath });
|
|
26
|
+
let expectedHash: ContentHash | null = hash;
|
|
27
|
+
return {
|
|
28
|
+
content,
|
|
29
|
+
async save(text: string): Promise<void> {
|
|
30
|
+
const outcome = await client.callOnce("workspace.exactEdit", { workspaceId, path: relativePath, expectedHash, content: text });
|
|
31
|
+
expectedHash = outcome.newHash;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
|
|
2
|
-
import { lectorClient } from "
|
|
2
|
+
import { lectorClient } from "../lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/** Thin wrapper over search.githubRepos/search.npmPackages/search.sourcegraphCode -- explicit-query discovery inputs shaped for repo_cache/package_source, never open-ended discovery/trending. */
|
|
5
5
|
export interface ExternalSearchOperations {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GithubRepoSearchResult, NpmPackageCandidate, SourcegraphCodeCandidate } from "@danypops/lector";
|
|
2
|
-
import type { LectorTheme } from "
|
|
2
|
+
import type { LectorTheme } from "../lector-tui-theme.ts";
|
|
3
3
|
|
|
4
4
|
type ExternalSearchAction = "github_repos" | "npm_packages" | "sourcegraph_code";
|
|
5
5
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FindFilesResult } from "@danypops/lector";
|
|
2
|
-
import { lectorClient, withWorkspace, workspaceForDirectory } from "
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Thin wrapper over workspace.findFiles -- the `find`-shaped half of the classic grep+find pair,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FindFilesResult } from "@danypops/lector";
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { renderTruncatedList } from "malevich-tui-components";
|
|
4
|
-
import type { LectorTheme } from "
|
|
4
|
+
import type { LectorTheme } from "../lector-tui-theme.ts";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_VISIBLE_PATHS = 40;
|
|
7
7
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OperationInputs, SymbolSearchResult } from "@danypops/lector";
|
|
2
|
-
import { lectorClient, withWorkspace, workspaceForDirectory } from "
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Thin wrapper over workspace.findSymbols. No seedFile parameter here or
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SymbolSearchResult, WorkspaceSymbol } from "@danypops/lector";
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { renderTruncatedList } from "malevich-tui-components";
|
|
4
|
-
import { colorForKind, formatLocation, type LectorTheme } from "
|
|
4
|
+
import { colorForKind, formatLocation, type LectorTheme } from "../lector-tui-theme.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Custom TUI rendering for find_symbols -- the one Lector-backed tool with
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
|
|
2
|
-
import { lectorClient, withWorkspace, workspaceForDirectory } from "
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
|
|
3
3
|
|
|
4
4
|
type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"];
|
|
5
5
|
|
|
@@ -2,7 +2,7 @@ import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } f
|
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { renderDiffLines, renderTruncatedList, type TextMeasure } from "malevich-tui-components";
|
|
5
|
-
import type { LectorTheme } from "
|
|
5
|
+
import type { LectorTheme } from "../lector-tui-theme.ts";
|
|
6
6
|
|
|
7
7
|
/** Real ANSI-aware measurement, not Malevich's own ASCII-only default -- every diff line renderDiffLines receives is already theme.fg-styled. */
|
|
8
8
|
const measure: TextMeasure = { visibleWidth, truncateToWidth };
|