@danypops/pi-lector 0.13.7 → 0.13.11
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/code-intelligence/operations.ts +109 -3
- package/extension/src/code-intelligence/rendering.ts +119 -0
- package/extension/src/cross-workspace-search/rendering.ts +3 -0
- package/extension/src/editor/editor-state.ts +38 -1
- package/extension/src/editor/explorer-component.ts +41 -9
- package/extension/src/editor/explorer-flow.ts +16 -9
- package/extension/src/editor/index.ts +10 -2
- package/extension/src/editor/modal-editor-component.ts +9 -3
- package/extension/src/git/operations.ts +23 -1
- package/extension/src/git/rendering.ts +42 -4
- package/extension/src/index.ts +397 -14
- package/extension/src/search/rendering.ts +4 -2
- package/extension/src/symbol-annotation/operations.ts +8 -4
- package/extension/src/vehicle-client.ts +4 -2
- package/package.json +4 -4
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { JobSnapshot, OperationInputs, OperationOutputs, PopulateSymbolGraphResult, SymbolEdgeKind, SymbolNode } from "@danypops/lector";
|
|
2
2
|
import { lectorClient, withWorkspace, workspaceForCodeIntelligencePath, workspaceForPathOrDirectory } from "../lector-client.ts";
|
|
3
|
+
import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
|
|
4
|
+
|
|
5
|
+
const CODE_ACTION_PREVIEW_PERMISSIONS = ["workspace:read"];
|
|
6
|
+
const CODE_ACTION_APPLY_PERMISSIONS = ["workspace:write"];
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Thin wrappers over Lector's code-intelligence operations: goToDefinition,
|
|
@@ -34,9 +38,50 @@ export interface CodeIntelligenceOperations {
|
|
|
34
38
|
hover(path: string, line: number, character: number): Promise<OperationOutputs["workspace.hover"]>;
|
|
35
39
|
documentSymbols(path: string): Promise<OperationOutputs["workspace.documentSymbols"]>;
|
|
36
40
|
diagnostics(path: string): Promise<OperationOutputs["workspace.diagnostics"]>;
|
|
41
|
+
previewCodeActions(
|
|
42
|
+
path: string,
|
|
43
|
+
input: Omit<OperationInputs["workspace.previewCodeActions"], "workspaceId" | "path">,
|
|
44
|
+
call: LectorVehicleCall,
|
|
45
|
+
): Promise<OperationOutputs["workspace.previewCodeActions"]>;
|
|
46
|
+
applyCodeAction(
|
|
47
|
+
path: string,
|
|
48
|
+
previewId: OperationInputs["workspace.applyCodeAction"]["previewId"],
|
|
49
|
+
call: LectorVehicleCall,
|
|
50
|
+
): Promise<OperationOutputs["workspace.applyCodeAction"]>;
|
|
51
|
+
diagnosticDelta(
|
|
52
|
+
path: string,
|
|
53
|
+
source: OperationInputs["workspace.diagnosticDelta"]["source"],
|
|
54
|
+
bounds?: Omit<OperationInputs["workspace.diagnosticDelta"], "workspaceId" | "source">,
|
|
55
|
+
): Promise<OperationOutputs["workspace.diagnosticDelta"]>;
|
|
37
56
|
prepareCallHierarchy(path: string, line: number, character: number): Promise<OperationOutputs["workspace.prepareCallHierarchy"]>;
|
|
38
57
|
incomingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.incomingCalls"]>;
|
|
39
58
|
outgoingCalls(path: string, line: number, character: number): Promise<OperationOutputs["workspace.outgoingCalls"]>;
|
|
59
|
+
prepareTypeHierarchy(
|
|
60
|
+
path: string,
|
|
61
|
+
line: number,
|
|
62
|
+
character: number,
|
|
63
|
+
bounds?: Pick<OperationInputs["workspace.prepareTypeHierarchy"], "maxResults" | "maxBytes" | "deadlineMs">,
|
|
64
|
+
): Promise<OperationOutputs["workspace.prepareTypeHierarchy"]>;
|
|
65
|
+
supertypes(
|
|
66
|
+
path: string,
|
|
67
|
+
line: number,
|
|
68
|
+
character: number,
|
|
69
|
+
bounds?: Pick<OperationInputs["workspace.supertypes"], "maxResults" | "maxBytes" | "deadlineMs">,
|
|
70
|
+
): Promise<OperationOutputs["workspace.supertypes"]>;
|
|
71
|
+
subtypes(
|
|
72
|
+
path: string,
|
|
73
|
+
line: number,
|
|
74
|
+
character: number,
|
|
75
|
+
bounds?: Pick<OperationInputs["workspace.subtypes"], "maxResults" | "maxBytes" | "deadlineMs">,
|
|
76
|
+
): Promise<OperationOutputs["workspace.subtypes"]>;
|
|
77
|
+
impactAnalysis(
|
|
78
|
+
path: string,
|
|
79
|
+
source: OperationInputs["workspace.impactAnalysis"]["source"],
|
|
80
|
+
bounds: Pick<
|
|
81
|
+
OperationInputs["workspace.impactAnalysis"],
|
|
82
|
+
"maxDepth" | "maxNodes" | "maxEdges" | "maxBytes" | "deadlineMs" | "coverage" | "autoPopulate" | "maxFiles" | "maxSymbolsPerFile"
|
|
83
|
+
>,
|
|
84
|
+
): Promise<OperationOutputs["workspace.impactAnalysis"]>;
|
|
40
85
|
/**
|
|
41
86
|
* Not exposed as a standalone Pi tool -- every workspace auto-populates on first touch via
|
|
42
87
|
* monitorWorkspaceCache (workspace-cache/operations.ts). Kept here as an internal capability
|
|
@@ -46,7 +91,14 @@ export interface CodeIntelligenceOperations {
|
|
|
46
91
|
populateSymbolGraph(path: string, maxFiles: number, maxSymbolsPerFile: number, waitMs?: number): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
|
|
47
92
|
/** Not exposed as a standalone Pi tool -- see populateSymbolGraph. */
|
|
48
93
|
jobStatus(jobId: string): Promise<JobSnapshot<PopulateSymbolGraphResult>>;
|
|
49
|
-
reachableFrom(
|
|
94
|
+
reachableFrom(
|
|
95
|
+
path: string,
|
|
96
|
+
line: number,
|
|
97
|
+
character: number,
|
|
98
|
+
maxDepth: number,
|
|
99
|
+
kind?: SymbolEdgeKind,
|
|
100
|
+
autoPopulation?: Pick<OperationInputs["workspace.reachableFrom"], "autoPopulate" | "maxFiles" | "maxSymbolsPerFile">,
|
|
101
|
+
): Promise<readonly SymbolNode[]>;
|
|
50
102
|
/** Never spawns a symbol index -- safe to call opportunistically (e.g. before deciding whether to enrich a result). */
|
|
51
103
|
hasWarmIndex(path: string): Promise<boolean>;
|
|
52
104
|
workspaceMap(path: string, maxNodes: number, maxEdges: number, maxEntries: number, maxBytes: number): Promise<OperationOutputs["workspace.map"]>;
|
|
@@ -120,6 +172,36 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
|
|
|
120
172
|
},
|
|
121
173
|
);
|
|
122
174
|
},
|
|
175
|
+
async previewCodeActions(path, input, call) {
|
|
176
|
+
return withWorkspace(
|
|
177
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
178
|
+
({ workspaceId }) =>
|
|
179
|
+
invokeLectorVehicleOperation<OperationOutputs["workspace.previewCodeActions"]>(
|
|
180
|
+
"workspace.previewCodeActions",
|
|
181
|
+
{ workspaceId, path, ...input },
|
|
182
|
+
CODE_ACTION_PREVIEW_PERMISSIONS,
|
|
183
|
+
call,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
async applyCodeAction(path, previewId, call) {
|
|
188
|
+
return withWorkspace(
|
|
189
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
190
|
+
({ workspaceId }) =>
|
|
191
|
+
invokeLectorVehicleOperation<OperationOutputs["workspace.applyCodeAction"]>(
|
|
192
|
+
"workspace.applyCodeAction",
|
|
193
|
+
{ workspaceId, previewId },
|
|
194
|
+
CODE_ACTION_APPLY_PERMISSIONS,
|
|
195
|
+
call,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
async diagnosticDelta(path, source, bounds) {
|
|
200
|
+
return withWorkspace(
|
|
201
|
+
() => workspaceForPathOrDirectory(path),
|
|
202
|
+
async ({ workspaceId }) => (await lectorClient()).call("workspace.diagnosticDelta", { workspaceId, source, ...bounds }),
|
|
203
|
+
);
|
|
204
|
+
},
|
|
123
205
|
async prepareCallHierarchy(path, line, character) {
|
|
124
206
|
return withWorkspace(
|
|
125
207
|
() => workspaceForCodeIntelligencePath(path),
|
|
@@ -147,6 +229,30 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
|
|
|
147
229
|
},
|
|
148
230
|
);
|
|
149
231
|
},
|
|
232
|
+
async prepareTypeHierarchy(path, line, character, bounds) {
|
|
233
|
+
return withWorkspace(
|
|
234
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
235
|
+
async ({ workspaceId }) => (await lectorClient()).call("workspace.prepareTypeHierarchy", { workspaceId, path, line, character, ...bounds }),
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
async supertypes(path, line, character, bounds) {
|
|
239
|
+
return withWorkspace(
|
|
240
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
241
|
+
async ({ workspaceId }) => (await lectorClient()).call("workspace.supertypes", { workspaceId, path, line, character, ...bounds }),
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
async subtypes(path, line, character, bounds) {
|
|
245
|
+
return withWorkspace(
|
|
246
|
+
() => workspaceForCodeIntelligencePath(path),
|
|
247
|
+
async ({ workspaceId }) => (await lectorClient()).call("workspace.subtypes", { workspaceId, path, line, character, ...bounds }),
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
async impactAnalysis(path, source, bounds) {
|
|
251
|
+
return withWorkspace(
|
|
252
|
+
() => workspaceForPathOrDirectory(path),
|
|
253
|
+
async ({ workspaceId }) => (await lectorClient()).call("workspace.impactAnalysis", { workspaceId, source, ...bounds }),
|
|
254
|
+
);
|
|
255
|
+
},
|
|
150
256
|
async populateSymbolGraph(path, maxFiles, maxSymbolsPerFile, waitMs = 500) {
|
|
151
257
|
return withWorkspace(
|
|
152
258
|
() => workspaceForPathOrDirectory(path),
|
|
@@ -167,12 +273,12 @@ export function createLectorCodeIntelligenceOperations(ownerId?: string): CodeIn
|
|
|
167
273
|
const { job } = await client.call("job.status", { jobId });
|
|
168
274
|
return job;
|
|
169
275
|
},
|
|
170
|
-
async reachableFrom(path, line, character, maxDepth, kind) {
|
|
276
|
+
async reachableFrom(path, line, character, maxDepth, kind, autoPopulation) {
|
|
171
277
|
return withWorkspace(
|
|
172
278
|
() => workspaceForCodeIntelligencePath(path),
|
|
173
279
|
async ({ workspaceId }) => {
|
|
174
280
|
const client = await lectorClient();
|
|
175
|
-
const { symbols } = await client.call("workspace.reachableFrom", { workspaceId, path, line, character, maxDepth, kind });
|
|
281
|
+
const { symbols } = await client.call("workspace.reachableFrom", { workspaceId, path, line, character, maxDepth, kind, ...autoPopulation });
|
|
176
282
|
return symbols;
|
|
177
283
|
},
|
|
178
284
|
);
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
Hover,
|
|
6
6
|
IncomingCall,
|
|
7
7
|
IntelligenceProvenance,
|
|
8
|
+
OperationOutputs,
|
|
8
9
|
OutgoingCall,
|
|
9
10
|
SymbolNode,
|
|
10
11
|
WorkspaceLocation,
|
|
@@ -27,6 +28,7 @@ const DEFAULT_VISIBLE_LOCATIONS = 8;
|
|
|
27
28
|
const DEFAULT_VISIBLE_SYMBOLS = 12;
|
|
28
29
|
const DEFAULT_VISIBLE_DIAGNOSTICS = 12;
|
|
29
30
|
const DEFAULT_VISIBLE_CALLS = 12;
|
|
31
|
+
const DEFAULT_VISIBLE_CHANGES = 12;
|
|
30
32
|
|
|
31
33
|
const DIAGNOSTIC_SEVERITY_COLOR: Record<Diagnostic["severity"], ThemeColor> = {
|
|
32
34
|
error: "error",
|
|
@@ -171,6 +173,123 @@ export function formatDiagnosticsResult(diagnostics: readonly Diagnostic[] | und
|
|
|
171
173
|
return lines.join("\n");
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
function formatPathCall(toolName: string, args: { path?: unknown }, theme: LectorTheme, qualifier = ""): string {
|
|
177
|
+
const path = typeof args.path === "string" ? args.path : "";
|
|
178
|
+
return `${theme.fg("toolTitle", theme.bold(toolName))}${qualifier ? ` ${theme.fg("muted", qualifier)}` : ""} ${theme.fg("accent", path)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatCodeActionPreviewCall(
|
|
182
|
+
args: { path?: unknown; startLine?: unknown; startCharacter?: unknown; endLine?: unknown; endCharacter?: unknown },
|
|
183
|
+
theme: LectorTheme,
|
|
184
|
+
): string {
|
|
185
|
+
const range = `${typeof args.startLine === "number" ? args.startLine : "?"}:${typeof args.startCharacter === "number" ? args.startCharacter : "?"}-${typeof args.endLine === "number" ? args.endLine : "?"}:${typeof args.endCharacter === "number" ? args.endCharacter : "?"}`;
|
|
186
|
+
return formatPathCall("code_action_preview", args, theme, range);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function formatCodeActionPreviewResult(
|
|
190
|
+
result: OperationOutputs["workspace.previewCodeActions"] | undefined,
|
|
191
|
+
expanded: boolean,
|
|
192
|
+
theme: LectorTheme,
|
|
193
|
+
): string {
|
|
194
|
+
if (!result || result.actions.length === 0) return theme.fg("dim", "No code actions.");
|
|
195
|
+
return [
|
|
196
|
+
theme.fg("muted", `${result.actions.length} code action${result.actions.length === 1 ? "" : "s"}:`),
|
|
197
|
+
...renderTruncatedList({
|
|
198
|
+
items: result.actions,
|
|
199
|
+
expanded,
|
|
200
|
+
visibleCount: DEFAULT_VISIBLE_CHANGES,
|
|
201
|
+
formatItem: (action) => {
|
|
202
|
+
const state = action.disabledReason
|
|
203
|
+
? theme.fg("warning", `disabled: ${action.disabledReason}`)
|
|
204
|
+
: action.preferred
|
|
205
|
+
? theme.fg("success", "preferred")
|
|
206
|
+
: "";
|
|
207
|
+
const paths = action.affectedPaths.length > 0 ? action.affectedPaths.join(", ") : "command only";
|
|
208
|
+
return ` ${theme.bold(action.title)}${action.kind ? ` (${action.kind})` : ""} -- ${paths}${state ? ` -- ${state}` : ""}`;
|
|
209
|
+
},
|
|
210
|
+
moreLine: moreLine(theme),
|
|
211
|
+
truncationWarning: result.truncated || result.deadlineReached ? theme.fg("warning", "results truncated by the requested bounds") : undefined,
|
|
212
|
+
}),
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function formatCodeActionApplyCall(args: { path?: unknown }, theme: LectorTheme): string {
|
|
217
|
+
return formatPathCall("code_action_apply", args, theme);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function formatCodeActionApplyResult(result: OperationOutputs["workspace.applyCodeAction"] | undefined, theme: LectorTheme): string {
|
|
221
|
+
if (!result) return theme.fg("dim", "No code-action result.");
|
|
222
|
+
const lines = result.touchedPaths.map((path) => ` ${path}`);
|
|
223
|
+
if (result.transactionId) lines.push(theme.fg("muted", `transaction ${result.transactionId}`));
|
|
224
|
+
if (result.pendingCommand) lines.push(theme.fg("warning", `pending command ${result.pendingCommand.command}`));
|
|
225
|
+
return lines.length > 0 ? lines.join("\n") : theme.fg("dim", "Code action made no file changes.");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function formatDiagnosticDeltaCall(args: { path?: unknown; source?: unknown }, theme: LectorTheme): string {
|
|
229
|
+
return formatPathCall("diagnostic_delta", args, theme, typeof args.source === "string" ? args.source : "");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function formatDiagnosticDeltaResult(result: OperationOutputs["workspace.diagnosticDelta"] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
233
|
+
if (!result) return theme.fg("dim", "No diagnostic delta.");
|
|
234
|
+
const changes = [
|
|
235
|
+
...result.introduced.map((diagnostic) => ({ label: "introduced", diagnostic })),
|
|
236
|
+
...result.resolved.map((diagnostic) => ({ label: "resolved", diagnostic })),
|
|
237
|
+
...result.changed.map(({ after }) => ({ label: "changed", diagnostic: after })),
|
|
238
|
+
];
|
|
239
|
+
if (changes.length === 0) return theme.fg("success", "No diagnostic changes.");
|
|
240
|
+
return renderTruncatedList({
|
|
241
|
+
items: changes,
|
|
242
|
+
expanded,
|
|
243
|
+
visibleCount: DEFAULT_VISIBLE_CHANGES,
|
|
244
|
+
formatItem: ({ label, diagnostic }) =>
|
|
245
|
+
`${theme.fg(label === "introduced" ? "error" : label === "resolved" ? "success" : "warning", label)} ${formatLocation(theme, diagnostic.range.path, diagnostic.range.start.line, diagnostic.range.start.character)} -- ${diagnostic.message}`,
|
|
246
|
+
moreLine: moreLine(theme),
|
|
247
|
+
truncationWarning: result.truncated ? theme.fg("warning", "results truncated by maxResults/maxBytes") : undefined,
|
|
248
|
+
}).join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatTypeHierarchyCall(args: { direction?: unknown; path?: unknown; line?: unknown; character?: unknown }, theme: LectorTheme): string {
|
|
252
|
+
return `${formatPositionalCall("type_hierarchy", args, theme)} ${theme.fg("muted", typeof args.direction === "string" ? args.direction : "")}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function formatTypeHierarchyResult(
|
|
256
|
+
result: OperationOutputs["workspace.prepareTypeHierarchy"] | undefined,
|
|
257
|
+
expanded: boolean,
|
|
258
|
+
theme: LectorTheme,
|
|
259
|
+
): string {
|
|
260
|
+
if (!result || result.items.length === 0) return theme.fg("dim", "No type-hierarchy items found.");
|
|
261
|
+
return renderTruncatedList({
|
|
262
|
+
items: result.items,
|
|
263
|
+
expanded,
|
|
264
|
+
visibleCount: DEFAULT_VISIBLE_CALLS,
|
|
265
|
+
formatItem: (item) => ` ${formatCallHierarchyEntry(item, theme)}${item.detail ? theme.fg("dim", ` -- ${item.detail}`) : ""}`,
|
|
266
|
+
moreLine: moreLine(theme),
|
|
267
|
+
truncationWarning: result.truncated ? theme.fg("warning", "results truncated by the requested bounds") : undefined,
|
|
268
|
+
}).join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function formatImpactAnalysisCall(args: { path?: unknown; source?: unknown }, theme: LectorTheme): string {
|
|
272
|
+
return formatPathCall("impact_analysis", args, theme, typeof args.source === "string" ? args.source : "");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function formatImpactAnalysisResult(result: OperationOutputs["workspace.impactAnalysis"] | undefined, expanded: boolean, theme: LectorTheme): string {
|
|
276
|
+
if (!result) return theme.fg("dim", "No impact-analysis result.");
|
|
277
|
+
const impacts = [
|
|
278
|
+
...result.changedSymbols.map(({ symbol, side }) => ({ label: `changed ${side}`, symbol })),
|
|
279
|
+
...result.impactedSymbols.map(({ symbol, depth }) => ({ label: `impact depth=${depth}`, symbol })),
|
|
280
|
+
...result.relatedTests.map(({ symbol, evidence }) => ({ label: `test ${evidence.kind}`, symbol })),
|
|
281
|
+
];
|
|
282
|
+
if (impacts.length === 0) return theme.fg("dim", "No changed symbols resolved.");
|
|
283
|
+
return renderTruncatedList({
|
|
284
|
+
items: impacts,
|
|
285
|
+
expanded,
|
|
286
|
+
visibleCount: DEFAULT_VISIBLE_CHANGES,
|
|
287
|
+
formatItem: ({ label, symbol }) => `${theme.fg("muted", label)} ${formatCallHierarchyEntry(symbol, theme)}`,
|
|
288
|
+
moreLine: moreLine(theme),
|
|
289
|
+
truncationWarning: result.truncated || result.deadlineReached ? theme.fg("warning", "analysis truncated by the requested bounds") : undefined,
|
|
290
|
+
}).join("\n");
|
|
291
|
+
}
|
|
292
|
+
|
|
174
293
|
function formatCallHierarchyEntry(entry: { kind: string; name: string; location: WorkspaceLocation }, theme: LectorTheme): string {
|
|
175
294
|
const kind = theme.fg(colorForKind(entry.kind), entry.kind);
|
|
176
295
|
const name = theme.fg("text", theme.bold(entry.name));
|
|
@@ -83,6 +83,9 @@ export function formatSearchTextAcrossProjectsResult(
|
|
|
83
83
|
lines.push(formatOutcomeHeader(entry, theme));
|
|
84
84
|
const { outcome } = entry;
|
|
85
85
|
if (outcome.status !== "ready") continue;
|
|
86
|
+
if (outcome.result.provenance) {
|
|
87
|
+
lines.push(theme.fg("dim", ` lexical via ${outcome.result.provenance.backend} (${outcome.result.provenance.indexState})`));
|
|
88
|
+
}
|
|
86
89
|
if (outcome.result.matches.length === 0) {
|
|
87
90
|
lines.push(theme.fg("dim", " no matches"));
|
|
88
91
|
continue;
|
|
@@ -4,6 +4,26 @@ export type EditorMode = "normal" | "insert" | "command";
|
|
|
4
4
|
|
|
5
5
|
export type EditorAction = { kind: "save" } | { kind: "save-and-quit" } | { kind: "quit" } | { kind: "hover" };
|
|
6
6
|
|
|
7
|
+
export interface EditorSourcePosition {
|
|
8
|
+
/** One-based line number. */
|
|
9
|
+
readonly line: number;
|
|
10
|
+
/** One-based UTF-16 code-unit position within the line. */
|
|
11
|
+
readonly character: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type EditorPositionErrorCode = "line-out-of-range" | "character-out-of-range";
|
|
15
|
+
|
|
16
|
+
/** Reports why a requested source position cannot identify a cursor location in the current buffer. */
|
|
17
|
+
export class EditorPositionError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
readonly code: EditorPositionErrorCode,
|
|
20
|
+
message: string,
|
|
21
|
+
) {
|
|
22
|
+
super(`${code}: ${message}`);
|
|
23
|
+
this.name = "EditorPositionError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
const BACKSPACE_KEYS = new Set(["\x7f", "\b"]);
|
|
8
28
|
|
|
9
29
|
/**
|
|
@@ -27,8 +47,25 @@ export class EditorState {
|
|
|
27
47
|
private insertPendingJ = false;
|
|
28
48
|
private yankedLine: string | undefined;
|
|
29
49
|
|
|
30
|
-
constructor(content: string) {
|
|
50
|
+
constructor(content: string, initialPosition?: EditorSourcePosition) {
|
|
31
51
|
this.buffer = new LiveBuffer(content);
|
|
52
|
+
if (initialPosition) this.moveToInitialPosition(initialPosition);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private moveToInitialPosition(position: EditorSourcePosition): void {
|
|
56
|
+
if (!Number.isSafeInteger(position.line) || position.line < 1 || position.line > this.buffer.lineCount) {
|
|
57
|
+
throw new EditorPositionError("line-out-of-range", `Line ${position.line} is outside this ${this.buffer.lineCount}-line buffer`);
|
|
58
|
+
}
|
|
59
|
+
const lineLength = this.buffer.lineText(position.line).length;
|
|
60
|
+
const maxCharacter = Math.max(lineLength, 1);
|
|
61
|
+
if (!Number.isSafeInteger(position.character) || position.character < 1 || position.character > maxCharacter) {
|
|
62
|
+
throw new EditorPositionError(
|
|
63
|
+
"character-out-of-range",
|
|
64
|
+
`Character ${position.character} is outside line ${position.line}'s ${lineLength} UTF-16 code units`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
this.cursorLine = position.line;
|
|
68
|
+
this.cursorCharacter = position.character;
|
|
32
69
|
}
|
|
33
70
|
|
|
34
71
|
get currentLineText(): string {
|
|
@@ -9,7 +9,14 @@ import type { EditorTheme } from "./editor-theme.ts";
|
|
|
9
9
|
import type { ExplorerDiff, ExplorerEntry } from "./explorer-diff.ts";
|
|
10
10
|
import { diffExplorerLines, formatExplorerLine, parseExplorerLine } from "./explorer-diff.ts";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** Restores one Workspace's directory and active entry when its Explorer becomes visible again. */
|
|
13
|
+
export interface ExplorerViewState {
|
|
14
|
+
readonly relativePath: string;
|
|
15
|
+
readonly selectedEntryName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reports why the Explorer closed plus its latest restorable location when the host supports view-state persistence. */
|
|
19
|
+
export type ExplorerResult = { kind: "quit"; viewState?: ExplorerViewState } | { kind: "open-file"; absolutePath: string; viewState?: ExplorerViewState };
|
|
13
20
|
|
|
14
21
|
/** Joins a directory-relative name onto `directory` ("" means the resolved root itself). */
|
|
15
22
|
export function joinExplorerPath(directory: string, name: string): string {
|
|
@@ -51,12 +58,19 @@ export class ExplorerComponent implements Component {
|
|
|
51
58
|
private statusMessage = "";
|
|
52
59
|
private confirming: PendingConfirmation | undefined;
|
|
53
60
|
|
|
54
|
-
constructor(
|
|
61
|
+
constructor(
|
|
62
|
+
tui: TUI,
|
|
63
|
+
theme: EditorTheme,
|
|
64
|
+
session: DirectoryExplorerSession,
|
|
65
|
+
initialRelativePath: string,
|
|
66
|
+
done: (result: ExplorerResult) => void,
|
|
67
|
+
initialSelectedEntryName?: string,
|
|
68
|
+
) {
|
|
55
69
|
this.tui = tui;
|
|
56
70
|
this.theme = theme;
|
|
57
71
|
this.session = session;
|
|
58
72
|
this.done = done;
|
|
59
|
-
this.pending = this.loadDirectory(initialRelativePath).catch((error: unknown) => this.reportError(error));
|
|
73
|
+
this.pending = this.loadDirectory(initialRelativePath, initialSelectedEntryName).catch((error: unknown) => this.reportError(error));
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
private pending: Promise<void> = Promise.resolve();
|
|
@@ -111,13 +125,20 @@ export class ExplorerComponent implements Component {
|
|
|
111
125
|
this.tui.requestRender();
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
private async loadDirectory(relativePath: string): Promise<void> {
|
|
128
|
+
private async loadDirectory(relativePath: string, selectedEntryName?: string): Promise<void> {
|
|
115
129
|
const listing = await this.session.listDirectory(relativePath);
|
|
116
130
|
this.currentPath = relativePath;
|
|
117
131
|
this.nextId = 1;
|
|
118
132
|
this.entries = listing.entries.map((entry) => ({ id: this.nextId++, name: entry.name, kind: entry.kind }));
|
|
119
133
|
const text = this.entries.length > 0 ? this.entries.map((entry) => formatExplorerLine(entry)).join("\n") : "";
|
|
120
134
|
this.state = new EditorState(text);
|
|
135
|
+
const selectedIndex = selectedEntryName === undefined ? -1 : this.entries.findIndex((entry) => entry.name === selectedEntryName);
|
|
136
|
+
const selectedEntry = selectedIndex >= 0 ? this.entries[selectedIndex] : undefined;
|
|
137
|
+
if (selectedEntry) {
|
|
138
|
+
this.state.cursorLine = selectedIndex + 1;
|
|
139
|
+
this.state.cursorCharacter = `${selectedEntry.id} `.length + 1;
|
|
140
|
+
this.scrollToKeepCursorVisible();
|
|
141
|
+
}
|
|
121
142
|
this.confirming = undefined;
|
|
122
143
|
this.statusMessage = "";
|
|
123
144
|
this.tui.requestRender();
|
|
@@ -133,12 +154,17 @@ export class ExplorerComponent implements Component {
|
|
|
133
154
|
await this.loadDirectory(joinExplorerPath(this.currentPath, entry.name));
|
|
134
155
|
return;
|
|
135
156
|
}
|
|
136
|
-
this.done({
|
|
157
|
+
this.done({
|
|
158
|
+
kind: "open-file",
|
|
159
|
+
absolutePath: join(this.session.root, joinExplorerPath(this.currentPath, entry.name)),
|
|
160
|
+
viewState: { relativePath: this.currentPath, selectedEntryName: entry.name },
|
|
161
|
+
});
|
|
137
162
|
}
|
|
138
163
|
|
|
139
164
|
private async navigateToParent(): Promise<void> {
|
|
140
165
|
if (this.currentPath === "") return; // already at the resolved root -- v1 never widens scope above it
|
|
141
|
-
|
|
166
|
+
const childName = posix.basename(this.currentPath);
|
|
167
|
+
await this.loadDirectory(parentExplorerPath(this.currentPath), childName);
|
|
142
168
|
}
|
|
143
169
|
|
|
144
170
|
private async performAction(action: EditorAction): Promise<void> {
|
|
@@ -149,14 +175,14 @@ export class ExplorerComponent implements Component {
|
|
|
149
175
|
this.state.dirty = false;
|
|
150
176
|
if (diffs.length === 0) {
|
|
151
177
|
this.statusMessage = "no changes";
|
|
152
|
-
if (action.kind === "save-and-quit") this.done({ kind: "quit" });
|
|
178
|
+
if (action.kind === "save-and-quit") this.done({ kind: "quit", viewState: this.viewState() });
|
|
153
179
|
return;
|
|
154
180
|
}
|
|
155
181
|
this.confirming = { diffs, andQuit: action.kind === "save-and-quit" };
|
|
156
182
|
return;
|
|
157
183
|
}
|
|
158
184
|
case "quit":
|
|
159
|
-
this.done({ kind: "quit" });
|
|
185
|
+
this.done({ kind: "quit", viewState: this.viewState() });
|
|
160
186
|
return;
|
|
161
187
|
case "hover":
|
|
162
188
|
this.statusMessage = "hover is not applicable in the file explorer";
|
|
@@ -186,13 +212,19 @@ export class ExplorerComponent implements Component {
|
|
|
186
212
|
return;
|
|
187
213
|
}
|
|
188
214
|
if (andQuit) {
|
|
189
|
-
this.done({ kind: "quit" });
|
|
215
|
+
this.done({ kind: "quit", viewState: this.viewState() });
|
|
190
216
|
return;
|
|
191
217
|
}
|
|
192
218
|
await this.loadDirectory(this.currentPath);
|
|
193
219
|
this.statusMessage = "applied";
|
|
194
220
|
}
|
|
195
221
|
|
|
222
|
+
private viewState(): ExplorerViewState {
|
|
223
|
+
const parsed = parseExplorerLine(this.state.currentLineText);
|
|
224
|
+
const entry = !parsed || parsed.id === null ? undefined : this.entries.find((candidate) => candidate.id === parsed.id);
|
|
225
|
+
return entry ? { relativePath: this.currentPath, selectedEntryName: entry.name } : { relativePath: this.currentPath };
|
|
226
|
+
}
|
|
227
|
+
|
|
196
228
|
private scrollToKeepCursorVisible(): void {
|
|
197
229
|
const viewportHeight = Math.max(1, this.tui.terminal.rows - 2);
|
|
198
230
|
if (this.state.cursorLine < this.scrollTop) this.scrollTop = this.state.cursorLine;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { dirname, relative } from "node:path";
|
|
1
|
+
import { basename, dirname, relative } from "node:path";
|
|
2
2
|
import type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
|
|
3
|
-
import type { ExplorerResult } from "./explorer-component.ts";
|
|
3
|
+
import type { ExplorerResult, ExplorerViewState } from "./explorer-component.ts";
|
|
4
4
|
|
|
5
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>;
|
|
6
|
+
/** Shows the explorer at `relativePath` (root-relative, "" for the resolved root), optionally revealing one active entry, and resolves once the user quits or opens a file. */
|
|
7
|
+
showExplorer(session: DirectoryExplorerSession, relativePath: string, selectedEntryName?: string): Promise<ExplorerResult>;
|
|
8
8
|
/** Shows the real file editor for `absolutePath` and resolves once the user quits it. */
|
|
9
9
|
showEditor(absolutePath: string): Promise<void>;
|
|
10
10
|
}
|
|
@@ -14,14 +14,21 @@ export interface ExplorerFlowHost {
|
|
|
14
14
|
* the explorer -- at the directory the opened file lives in, not the resolved root -- once that
|
|
15
15
|
* editor quits, rather than closing the whole session after the first file opened.
|
|
16
16
|
*/
|
|
17
|
-
export async function runExplorerFlow(
|
|
18
|
-
|
|
17
|
+
export async function runExplorerFlow(
|
|
18
|
+
session: DirectoryExplorerSession,
|
|
19
|
+
host: ExplorerFlowHost,
|
|
20
|
+
initialViewState: ExplorerViewState = { relativePath: "" },
|
|
21
|
+
): Promise<ExplorerViewState> {
|
|
22
|
+
let viewState = initialViewState;
|
|
19
23
|
for (;;) {
|
|
20
|
-
const result = await host.showExplorer(session, relativePath);
|
|
21
|
-
if (result.kind === "quit") return;
|
|
24
|
+
const result = await host.showExplorer(session, viewState.relativePath, viewState.selectedEntryName);
|
|
25
|
+
if (result.kind === "quit") return result.viewState ?? viewState;
|
|
22
26
|
|
|
23
27
|
await host.showEditor(result.absolutePath);
|
|
24
28
|
// path.relative(root, root) is "" directly, matching ExplorerComponent's own root-relative convention -- no "." case to normalize.
|
|
25
|
-
|
|
29
|
+
viewState = result.viewState ?? {
|
|
30
|
+
relativePath: relative(session.root, dirname(result.absolutePath)),
|
|
31
|
+
selectedEntryName: basename(result.absolutePath),
|
|
32
|
+
};
|
|
26
33
|
}
|
|
27
34
|
}
|
|
@@ -29,9 +29,16 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
export type { DirectoryExplorerSession } from "./directory-explorer-operations.ts";
|
|
32
|
-
export {
|
|
32
|
+
export {
|
|
33
|
+
type EditorAction,
|
|
34
|
+
type EditorMode,
|
|
35
|
+
EditorPositionError,
|
|
36
|
+
type EditorPositionErrorCode,
|
|
37
|
+
type EditorSourcePosition,
|
|
38
|
+
EditorState,
|
|
39
|
+
} from "./editor-state.ts";
|
|
33
40
|
export type { EditorTheme } from "./editor-theme.ts";
|
|
34
|
-
export { ExplorerComponent, type ExplorerResult, joinExplorerPath } from "./explorer-component.ts";
|
|
41
|
+
export { ExplorerComponent, type ExplorerResult, type ExplorerViewState, joinExplorerPath } from "./explorer-component.ts";
|
|
35
42
|
// runExplorerFlow is pure orchestration over the two interfaces above (browse, open a file into
|
|
36
43
|
// the real editor, return to the explorer at that file's own directory once it quits) -- exported
|
|
37
44
|
// for the same reason ExplorerComponent/DirectoryExplorerSession are: any real host can drive this
|
|
@@ -44,4 +51,5 @@ export {
|
|
|
44
51
|
type EditorHoverRequest,
|
|
45
52
|
ModalEditorComponent,
|
|
46
53
|
type ModalEditorHost,
|
|
54
|
+
type ModalEditorOptions,
|
|
47
55
|
} from "./modal-editor-component.ts";
|
|
@@ -4,7 +4,7 @@ import { contentHashOf, highlightSpans } from "@danypops/lector";
|
|
|
4
4
|
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
6
6
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
import type { EditorAction } from "./editor-state.ts";
|
|
7
|
+
import type { EditorAction, EditorSourcePosition } from "./editor-state.ts";
|
|
8
8
|
import { EditorState } from "./editor-state.ts";
|
|
9
9
|
import type { EditorTheme } from "./editor-theme.ts";
|
|
10
10
|
|
|
@@ -26,6 +26,11 @@ export type EditorHoverOutcome =
|
|
|
26
26
|
| { readonly kind: "ready"; readonly hover?: { readonly contents: string } }
|
|
27
27
|
| { readonly kind: "stale-active-buffer"; readonly bufferHash: ContentHash };
|
|
28
28
|
|
|
29
|
+
export interface ModalEditorOptions {
|
|
30
|
+
/** Positions the cursor using one-based UTF-16 coordinates and scrolls it into the first viewport. */
|
|
31
|
+
readonly initialPosition?: EditorSourcePosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
export interface ModalEditorHost {
|
|
30
35
|
filePath: string;
|
|
31
36
|
/** 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. */
|
|
@@ -65,13 +70,14 @@ export class ModalEditorComponent implements Component {
|
|
|
65
70
|
private statusMessage = "";
|
|
66
71
|
private highlightCache: { text: string; spans: readonly HighlightSpan[] } | undefined;
|
|
67
72
|
|
|
68
|
-
constructor(tui: TUI, theme: EditorTheme, host: ModalEditorHost, content: string, done: () => void) {
|
|
73
|
+
constructor(tui: TUI, theme: EditorTheme, host: ModalEditorHost, content: string, done: () => void, options: ModalEditorOptions = {}) {
|
|
69
74
|
this.tui = tui;
|
|
70
75
|
this.theme = theme;
|
|
71
76
|
this.host = host;
|
|
72
77
|
this.done = done;
|
|
73
78
|
this.extension = extname(host.filePath);
|
|
74
|
-
this.state = new EditorState(content);
|
|
79
|
+
this.state = new EditorState(content, options.initialPosition);
|
|
80
|
+
this.scrollToKeepCursorVisible();
|
|
75
81
|
this.refreshHighlightsSafely();
|
|
76
82
|
}
|
|
77
83
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationOutputs } from "@danypops/lector";
|
|
1
|
+
import type { GitDiffResult, GitLogEntry, GitStatusSummary, OperationInputs, OperationOutputs } from "@danypops/lector";
|
|
2
2
|
import { lectorClient, withWorkspace, workspaceForDirectory } from "../lector-client.ts";
|
|
3
3
|
import { invokeLectorVehicleOperation, type LectorVehicleCall } from "../vehicle-client.ts";
|
|
4
4
|
|
|
@@ -6,6 +6,8 @@ type SymbolComparison = OperationOutputs["workspace.compareSymbolAcrossVersions"
|
|
|
6
6
|
type GitWorktreeAddResult = OperationOutputs["workspace.gitWorktreeAdd"];
|
|
7
7
|
type GitWorktreeRemoveResult = OperationOutputs["workspace.gitWorktreeRemove"];
|
|
8
8
|
type GitGrepResult = OperationOutputs["workspace.gitGrep"];
|
|
9
|
+
type GitHistoryGrepResult = OperationOutputs["workspace.gitGrepHistory"];
|
|
10
|
+
type GitHistoryGrepBounds = Omit<OperationInputs["workspace.gitGrepHistory"], "workspaceId" | "pattern" | "pathspecs">;
|
|
9
11
|
type GitListFilesResult = OperationOutputs["workspace.gitListFiles"];
|
|
10
12
|
|
|
11
13
|
/** Matches GIT_READ_PERMISSIONS' own declared value server-side (git/operation-registration.ts). */
|
|
@@ -42,6 +44,14 @@ export interface GitOperations {
|
|
|
42
44
|
maxBytes: number,
|
|
43
45
|
call: LectorVehicleCall,
|
|
44
46
|
): Promise<GitGrepResult>;
|
|
47
|
+
/** Bounded extended-regex search across commit trees reachable from every ref, with deterministic topological paging and exact-match deduplication. */
|
|
48
|
+
grepHistory(
|
|
49
|
+
directory: string,
|
|
50
|
+
pattern: string,
|
|
51
|
+
pathspecs: readonly string[] | undefined,
|
|
52
|
+
bounds: GitHistoryGrepBounds,
|
|
53
|
+
call: LectorVehicleCall,
|
|
54
|
+
): Promise<GitHistoryGrepResult>;
|
|
45
55
|
/** Every file path in `ref`'s own tree, no checkout -- pathspecs narrows the listing (prefix-based, not glob-based like grep's). */
|
|
46
56
|
listFiles(directory: string, ref: string, pathspecs: readonly string[] | undefined, maxResults: number, call: LectorVehicleCall): Promise<GitListFilesResult>;
|
|
47
57
|
/** True iff ancestorRef is a real ancestor of (or the exact same commit as) ref -- the backport/reachability check "was this fix ported to this branch" actually needs. */
|
|
@@ -139,6 +149,18 @@ export function createLectorGitOperations(): GitOperations {
|
|
|
139
149
|
),
|
|
140
150
|
);
|
|
141
151
|
},
|
|
152
|
+
async grepHistory(directory, pattern, pathspecs, bounds, call) {
|
|
153
|
+
return withWorkspace(
|
|
154
|
+
() => workspaceForDirectory(directory),
|
|
155
|
+
({ workspaceId }) =>
|
|
156
|
+
invokeLectorVehicleOperation<GitHistoryGrepResult>(
|
|
157
|
+
"workspace.gitGrepHistory",
|
|
158
|
+
{ workspaceId, pattern, pathspecs, ...bounds },
|
|
159
|
+
GIT_READ_PERMISSIONS,
|
|
160
|
+
call,
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
},
|
|
142
164
|
async listFiles(directory, ref, pathspecs, maxResults, call) {
|
|
143
165
|
return withWorkspace(
|
|
144
166
|
() => workspaceForDirectory(directory),
|