pi-hashline-edit-pro 2.6.5 → 2.7.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.
- package/README.md +70 -18
- package/index.ts +15 -9
- package/package.json +2 -2
- package/prompts/grep-guidelines.md +6 -0
- package/prompts/grep-snippet.md +1 -0
- package/prompts/grep.md +1 -0
- package/prompts/insert-guidelines.md +6 -0
- package/prompts/insert-snippet.md +1 -0
- package/prompts/insert.md +1 -0
- package/prompts/read-guidelines.md +1 -1
- package/prompts/undo-last-change-guidelines.md +3 -0
- package/prompts/undo-last-change-snippet.md +1 -0
- package/prompts/undo-last-change.md +1 -0
- package/src/boundary-bypass.ts +2 -10
- package/src/commit.ts +107 -0
- package/src/file-reader.ts +4 -0
- package/src/grep.ts +323 -0
- package/src/hash-store.ts +40 -12
- package/src/hashline/apply.ts +2 -15
- package/src/hashline/hash.ts +24 -0
- package/src/hashline/index.ts +3 -0
- package/src/hashline/resolve.ts +68 -38
- package/src/insert.ts +222 -0
- package/src/replace-diff.ts +47 -6
- package/src/replace-render.ts +117 -2
- package/src/replace-response.ts +7 -11
- package/src/replace-undo.ts +22 -31
- package/src/replace.ts +35 -242
- package/src/served.ts +2 -3
- package/prompts/undo-last-replace-guidelines.md +0 -2
- package/prompts/undo-last-replace-snippet.md +0 -1
- package/prompts/undo-last-replace.md +0 -1
package/src/grep.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { readdir, stat } from "fs/promises";
|
|
4
|
+
import { dirname, join, relative } from "path";
|
|
5
|
+
import { loadFileKindAndText } from "./file-kind";
|
|
6
|
+
import { readNormFile } from "./file-reader";
|
|
7
|
+
import { MAX_HASH_LINES, fmtRow } from "./hashline";
|
|
8
|
+
import { toCwd } from "./paths";
|
|
9
|
+
import { loadP, loadGuide } from "./prompts";
|
|
10
|
+
import { normReq } from "./replace-normalize";
|
|
11
|
+
import { recordServedSafe } from "./served";
|
|
12
|
+
import { abortIf, errCode, isRec, makePrepareArguments, rejectUnknownFields, visLines } from "./utils";
|
|
13
|
+
|
|
14
|
+
const GREP_KS = new Set(["pattern", "path", "glob", "context", "ignoreCase", "literal", "limit"]);
|
|
15
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".tmp", "coverage"]);
|
|
16
|
+
const MAX_SCAN_FILES = 4000;
|
|
17
|
+
const MAX_SHOWN_ROWS = 2000;
|
|
18
|
+
|
|
19
|
+
export interface GrepReq {
|
|
20
|
+
pattern: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
glob?: string;
|
|
23
|
+
context?: number;
|
|
24
|
+
ignoreCase?: boolean;
|
|
25
|
+
literal?: boolean;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertGrepReq(request: unknown): asserts request is GrepReq {
|
|
30
|
+
if (!isRec(request)) {
|
|
31
|
+
throw new Error("[E_BAD_SHAPE] Grep request must be an object.");
|
|
32
|
+
}
|
|
33
|
+
rejectUnknownFields(request, GREP_KS, "Grep request");
|
|
34
|
+
if (typeof request.pattern !== "string" || request.pattern.length === 0) {
|
|
35
|
+
throw new Error('[E_BAD_SHAPE] Grep request requires a non-empty "pattern" string.');
|
|
36
|
+
}
|
|
37
|
+
if (request.context !== undefined && (typeof request.context !== "number" || !Number.isInteger(request.context) || request.context < 0)) {
|
|
38
|
+
throw new Error('[E_BAD_SHAPE] Grep request field "context" must be a non-negative integer.');
|
|
39
|
+
}
|
|
40
|
+
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isInteger(request.limit) || request.limit < 1)) {
|
|
41
|
+
throw new Error('[E_BAD_SHAPE] Grep request field "limit" must be a positive integer.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildRegex(pattern: string, literal: boolean, ignoreCase: boolean): RegExp {
|
|
46
|
+
const source = literal ? pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : pattern;
|
|
47
|
+
try {
|
|
48
|
+
return new RegExp(source, ignoreCase ? "ui" : "u");
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`[E_BAD_SHAPE] Invalid pattern: ${pattern}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function globToRegex(glob: string): RegExp {
|
|
55
|
+
let source = "";
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < glob.length) {
|
|
58
|
+
const ch = glob[i]!;
|
|
59
|
+
if (ch === "*") {
|
|
60
|
+
if (glob[i + 1] === "*") {
|
|
61
|
+
i += 2;
|
|
62
|
+
if (glob[i] === "/") {
|
|
63
|
+
i += 1;
|
|
64
|
+
source += "(?:.*\\/)?";
|
|
65
|
+
} else {
|
|
66
|
+
source += ".*";
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
source += ".*";
|
|
71
|
+
} else if (ch === "?") {
|
|
72
|
+
source += "[^/]";
|
|
73
|
+
} else {
|
|
74
|
+
source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
75
|
+
}
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
return new RegExp(`^${source}$`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isSkipableLoadError(error: unknown): boolean {
|
|
82
|
+
const code = errCode(error);
|
|
83
|
+
if (code === "EACCES" || code === "EPERM" || code === "ENOENT" || code === "ELOOP") return true;
|
|
84
|
+
return error instanceof Error && error.message.startsWith("[E_FILE_TOO_LARGE]");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface FileHit {
|
|
88
|
+
path: string;
|
|
89
|
+
displayPath: string;
|
|
90
|
+
fileHashes: string[];
|
|
91
|
+
rows: string[];
|
|
92
|
+
hashes: string[];
|
|
93
|
+
matchCount: number;
|
|
94
|
+
totalMatchCount: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ScanState {
|
|
98
|
+
scanned: number;
|
|
99
|
+
stopped: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function walkFiles(
|
|
103
|
+
root: string,
|
|
104
|
+
state: ScanState,
|
|
105
|
+
onFile: (absPath: string) => Promise<void>,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const queue: string[] = [root];
|
|
108
|
+
while (queue.length > 0 && !state.stopped) {
|
|
109
|
+
const dir = queue.pop()!;
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (state.stopped) break;
|
|
118
|
+
const full = join(dir, entry.name);
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
121
|
+
queue.push(full);
|
|
122
|
+
} else if (entry.isFile()) {
|
|
123
|
+
state.scanned += 1;
|
|
124
|
+
if (state.scanned > MAX_SCAN_FILES) {
|
|
125
|
+
state.stopped = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
await onFile(full);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function searchFile(
|
|
135
|
+
absPath: string,
|
|
136
|
+
globRoot: string,
|
|
137
|
+
cwd: string,
|
|
138
|
+
regex: RegExp,
|
|
139
|
+
globRegex: RegExp | undefined,
|
|
140
|
+
context: number,
|
|
141
|
+
maxMatches: number,
|
|
142
|
+
): Promise<FileHit | undefined> {
|
|
143
|
+
const displayPath = relative(cwd, absPath).replace(/\\/g, "/");
|
|
144
|
+
if (globRegex) {
|
|
145
|
+
const globPath = relative(globRoot, absPath).replace(/\\/g, "/");
|
|
146
|
+
if (!globRegex.test(globPath)) return undefined;
|
|
147
|
+
}
|
|
148
|
+
let file;
|
|
149
|
+
try {
|
|
150
|
+
file = await loadFileKindAndText(absPath, { maxLines: MAX_HASH_LINES, displayPath });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isSkipableLoadError(error)) return undefined;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (file.kind !== "text") return undefined;
|
|
156
|
+
let norm;
|
|
157
|
+
try {
|
|
158
|
+
norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file, noPersist: true });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (isSkipableLoadError(error)) return undefined;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const lines = visLines(norm.normalized);
|
|
164
|
+
const matchLines: number[] = [];
|
|
165
|
+
for (let i = 0; i < lines.length; i++) {
|
|
166
|
+
if (regex.test(lines[i]!)) matchLines.push(i);
|
|
167
|
+
}
|
|
168
|
+
if (matchLines.length === 0) return undefined;
|
|
169
|
+
const keptMatches = matchLines.length > maxMatches ? matchLines.slice(0, maxMatches) : matchLines;
|
|
170
|
+
const shown = new Set<number>();
|
|
171
|
+
for (const i of keptMatches) {
|
|
172
|
+
for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) shown.add(j);
|
|
173
|
+
}
|
|
174
|
+
const sorted = [...shown].sort((a, b) => a - b);
|
|
175
|
+
const rows: string[] = [];
|
|
176
|
+
const hashes: string[] = [];
|
|
177
|
+
for (const idx of sorted) {
|
|
178
|
+
rows.push(fmtRow(norm.fileHashes[idx]!, lines[idx]!));
|
|
179
|
+
hashes.push(norm.fileHashes[idx]!);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
path: norm.absolutePath,
|
|
183
|
+
displayPath,
|
|
184
|
+
fileHashes: norm.fileHashes,
|
|
185
|
+
rows,
|
|
186
|
+
hashes,
|
|
187
|
+
matchCount: keptMatches.length,
|
|
188
|
+
totalMatchCount: matchLines.length,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const grepToolSchema = Type.Object(
|
|
193
|
+
{
|
|
194
|
+
pattern: Type.String({
|
|
195
|
+
description: "Search pattern (regex or literal string)",
|
|
196
|
+
}),
|
|
197
|
+
path: Type.Optional(
|
|
198
|
+
Type.String({
|
|
199
|
+
description: "Directory or file to search (default: current directory)",
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
glob: Type.Optional(
|
|
203
|
+
Type.String({
|
|
204
|
+
description: "Filter files by glob pattern; * matches across directories, e.g. '*.ts' or '**/*.spec.ts'",
|
|
205
|
+
}),
|
|
206
|
+
),
|
|
207
|
+
ignoreCase: Type.Optional(
|
|
208
|
+
Type.Boolean({
|
|
209
|
+
description: "Case-insensitive search (default: false)",
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
literal: Type.Optional(
|
|
213
|
+
Type.Boolean({
|
|
214
|
+
description: "Treat pattern as literal string instead of regex (default: false)",
|
|
215
|
+
}),
|
|
216
|
+
),
|
|
217
|
+
context: Type.Optional(
|
|
218
|
+
Type.Integer({
|
|
219
|
+
minimum: 0,
|
|
220
|
+
description: "Number of lines to show before and after each match (default: 0)",
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
limit: Type.Optional(
|
|
224
|
+
Type.Integer({
|
|
225
|
+
minimum: 1,
|
|
226
|
+
description: "Maximum number of matches to return (default: 100)",
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
},
|
|
230
|
+
{ additionalProperties: false },
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
export function regGrep(pi: ExtensionAPI): void {
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: "grep",
|
|
236
|
+
label: "Grep",
|
|
237
|
+
description: loadP("../prompts/grep.md"),
|
|
238
|
+
promptSnippet: loadP("../prompts/grep-snippet.md"),
|
|
239
|
+
promptGuidelines: loadGuide("../prompts/grep-guidelines.md"),
|
|
240
|
+
prepareArguments: makePrepareArguments(),
|
|
241
|
+
parameters: grepToolSchema,
|
|
242
|
+
|
|
243
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
244
|
+
const canonical = normReq(params);
|
|
245
|
+
assertGrepReq(canonical);
|
|
246
|
+
const req = canonical;
|
|
247
|
+
const regex = buildRegex(req.pattern, req.literal === true, req.ignoreCase === true);
|
|
248
|
+
const context = req.context ?? 0;
|
|
249
|
+
const limit = req.limit ?? 100;
|
|
250
|
+
const globRegex = req.glob === undefined ? undefined : globToRegex(req.glob);
|
|
251
|
+
const base = req.path ? toCwd(req.path, ctx.cwd) : ctx.cwd;
|
|
252
|
+
abortIf(signal);
|
|
253
|
+
let baseStat;
|
|
254
|
+
try {
|
|
255
|
+
baseStat = await stat(base);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (errCode(error) === "ENOENT") {
|
|
258
|
+
throw new Error(`[E_NOT_FOUND] File not found: ${req.path ?? ctx.cwd}`);
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`[E_ACCESS] Cannot access path: ${req.path ?? ctx.cwd}`);
|
|
261
|
+
}
|
|
262
|
+
const globRoot = baseStat.isFile() ? dirname(base) : base;
|
|
263
|
+
const state: ScanState = { scanned: 0, stopped: false };
|
|
264
|
+
const files: string[] = [];
|
|
265
|
+
if (baseStat.isFile()) {
|
|
266
|
+
files.push(base);
|
|
267
|
+
} else {
|
|
268
|
+
await walkFiles(base, state, async (absPath) => {
|
|
269
|
+
files.push(absPath);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const hits: FileHit[] = [];
|
|
273
|
+
let matches = 0;
|
|
274
|
+
let limitTruncated = false;
|
|
275
|
+
let rowTruncated = false;
|
|
276
|
+
let rowCount = 0;
|
|
277
|
+
for (const absPath of files) {
|
|
278
|
+
abortIf(signal);
|
|
279
|
+
const remaining = limit - matches;
|
|
280
|
+
if (remaining <= 0) {
|
|
281
|
+
limitTruncated = true;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const hit = await searchFile(absPath, globRoot, ctx.cwd, regex, globRegex, context, remaining);
|
|
285
|
+
if (!hit) continue;
|
|
286
|
+
const rowBudget = MAX_SHOWN_ROWS - rowCount;
|
|
287
|
+
if (rowBudget <= 0) {
|
|
288
|
+
rowTruncated = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
const keptRows = hit.rows.slice(0, rowBudget);
|
|
292
|
+
const keptHashes = hit.hashes.slice(0, rowBudget);
|
|
293
|
+
rowCount += keptRows.length;
|
|
294
|
+
if (hit.totalMatchCount > hit.matchCount) limitTruncated = true;
|
|
295
|
+
if (keptRows.length < hit.rows.length) rowTruncated = true;
|
|
296
|
+
matches += hit.matchCount;
|
|
297
|
+
hits.push({ ...hit, rows: keptRows, hashes: keptHashes });
|
|
298
|
+
}
|
|
299
|
+
for (const hit of hits) {
|
|
300
|
+
await recordServedSafe(hit.path, hit.hashes, "grep", new Set(hit.fileHashes));
|
|
301
|
+
}
|
|
302
|
+
const blocks = hits
|
|
303
|
+
.map((hit) => `=== ${hit.displayPath} ===\n${hit.rows.join("\n")}`)
|
|
304
|
+
.join("\n");
|
|
305
|
+
const notes: string[] = [];
|
|
306
|
+
if (rowTruncated) notes.push(`[grep: output truncated at ${MAX_SHOWN_ROWS} rows; refine the pattern to see more.]`);
|
|
307
|
+
if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
|
|
308
|
+
if (state.stopped) notes.push(`[grep: scan cap of ${MAX_SCAN_FILES} files reached; results may be incomplete.]`);
|
|
309
|
+
const truncated = limitTruncated || rowTruncated;
|
|
310
|
+
const text = blocks.length > 0 ? `${blocks}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}` : "No matches found.";
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: "text", text }],
|
|
313
|
+
details: {
|
|
314
|
+
metrics: {
|
|
315
|
+
matches,
|
|
316
|
+
files: hits.length,
|
|
317
|
+
truncated: truncated || state.stopped,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
package/src/hash-store.ts
CHANGED
|
@@ -66,6 +66,7 @@ interface Prepared {
|
|
|
66
66
|
get: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
67
67
|
allPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
68
68
|
allHashes: (...params: SqlParams) => Record<string, unknown>[];
|
|
69
|
+
allServed: (...params: SqlParams) => Record<string, unknown>[];
|
|
69
70
|
deleteOne: (...params: SqlParams) => void;
|
|
70
71
|
upsert: (...params: SqlParams) => void;
|
|
71
72
|
undoUpsert: (...params: SqlParams) => void;
|
|
@@ -118,6 +119,14 @@ export function parseHashList(raw: string, onInvalid: () => void): string[] | un
|
|
|
118
119
|
return parsed;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
export function parseStoredHashes(
|
|
123
|
+
row: Record<string, unknown> | undefined,
|
|
124
|
+
onInvalid: () => void,
|
|
125
|
+
): string[] | undefined {
|
|
126
|
+
if (!row) return undefined;
|
|
127
|
+
return parseHashList(row.hashes as string, onInvalid);
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
122
131
|
if (typeof value !== "object" || value === null) return false;
|
|
123
132
|
const v = value as Record<string, unknown>;
|
|
@@ -174,6 +183,14 @@ function openDbWithBusyRetry(storePath: string): { db: RawDb; stmts: Prepared }
|
|
|
174
183
|
return withBusyRetry(() => openDb(storePath));
|
|
175
184
|
}
|
|
176
185
|
|
|
186
|
+
function retriedWrite(
|
|
187
|
+
stmt: { run(...params: SqlParams): unknown },
|
|
188
|
+
): (...params: SqlParams) => void {
|
|
189
|
+
return (...params) => {
|
|
190
|
+
withBusyRetry(() => { stmt.run(...params); });
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
177
194
|
let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
|
|
178
195
|
let opening: { path: string; promise: Promise<HashStore> } | null = null;
|
|
179
196
|
let exitHandlerRegistered = false;
|
|
@@ -238,6 +255,7 @@ function buildStore(
|
|
|
238
255
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
239
256
|
db.exec("DELETE FROM snapshots");
|
|
240
257
|
db.exec("DELETE FROM undo");
|
|
258
|
+
db.exec("DELETE FROM served");
|
|
241
259
|
}
|
|
242
260
|
db.prepare(
|
|
243
261
|
"INSERT INTO meta (key, value) VALUES ('version', ?) " +
|
|
@@ -246,6 +264,7 @@ function buildStore(
|
|
|
246
264
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
247
265
|
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo UNION SELECT path FROM served");
|
|
248
266
|
const allHashesStmt = db.prepare("SELECT path, hashes FROM snapshots");
|
|
267
|
+
const allServedStmt = db.prepare("SELECT path, hashes FROM served");
|
|
249
268
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
250
269
|
const upsertStmt = db.prepare(
|
|
251
270
|
"INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
@@ -269,14 +288,15 @@ function buildStore(
|
|
|
269
288
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
270
289
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
271
290
|
allHashes: (...params) => allHashesStmt.all(...params) as Record<string, unknown>[],
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
291
|
+
allServed: (...params) => allServedStmt.all(...params) as Record<string, unknown>[],
|
|
292
|
+
deleteOne: retriedWrite(delStmt),
|
|
293
|
+
upsert: retriedWrite(upsertStmt),
|
|
294
|
+
undoUpsert: retriedWrite(undoUpsertStmt),
|
|
275
295
|
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
276
|
-
undoDelete: (
|
|
296
|
+
undoDelete: retriedWrite(undoDelStmt),
|
|
277
297
|
servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
278
|
-
servedUpsert: (
|
|
279
|
-
servedDelete: (
|
|
298
|
+
servedUpsert: retriedWrite(servedUpsertStmt),
|
|
299
|
+
servedDelete: retriedWrite(servedDelStmt),
|
|
280
300
|
};
|
|
281
301
|
return { db, stmts };
|
|
282
302
|
}
|
|
@@ -489,8 +509,7 @@ export function getSnapshot(
|
|
|
489
509
|
return cached.hashes.slice();
|
|
490
510
|
}
|
|
491
511
|
const row = store.stmts.get(path, checksum, lineCount);
|
|
492
|
-
|
|
493
|
-
const parsed = parseHashList(row.hashes as string, () => {
|
|
512
|
+
const parsed = parseStoredHashes(row, () => {
|
|
494
513
|
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
495
514
|
snapshotCache.delete(path);
|
|
496
515
|
});
|
|
@@ -525,7 +544,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
|
|
|
525
544
|
export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
|
|
526
545
|
const row = store.stmts.undoGet(path);
|
|
527
546
|
if (!row) return undefined;
|
|
528
|
-
const parsed =
|
|
547
|
+
const parsed = parseStoredHashes(row, () => store.stmts.undoDelete(path));
|
|
529
548
|
if (!parsed) return undefined;
|
|
530
549
|
return {
|
|
531
550
|
content: row.content as string,
|
|
@@ -571,14 +590,15 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
571
590
|
for (const path of missing) {
|
|
572
591
|
store.stmts.deleteOne(path);
|
|
573
592
|
snapshotCache.delete(path);
|
|
574
|
-
store.stmts.undoDelete(path);
|
|
575
593
|
store.stmts.servedDelete(path);
|
|
576
594
|
}
|
|
577
595
|
});
|
|
578
596
|
}
|
|
579
597
|
|
|
580
|
-
|
|
581
|
-
|
|
598
|
+
function matchPathsByHashes(
|
|
599
|
+
rows: { path: string; hashes: string }[],
|
|
600
|
+
hashes: string[],
|
|
601
|
+
): string[] {
|
|
582
602
|
const matches: string[] = [];
|
|
583
603
|
for (const row of rows) {
|
|
584
604
|
try {
|
|
@@ -591,3 +611,11 @@ export function findSnapshotPaths(store: HashStore, hashes: string[]): string[]
|
|
|
591
611
|
}
|
|
592
612
|
return matches;
|
|
593
613
|
}
|
|
614
|
+
|
|
615
|
+
export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
|
|
616
|
+
return matchPathsByHashes(store.stmts.allHashes() as { path: string; hashes: string }[], hashes);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function findServedPaths(store: HashStore, hashes: string[]): string[] {
|
|
620
|
+
return matchPathsByHashes(store.stmts.allServed() as { path: string; hashes: string }[], hashes);
|
|
621
|
+
}
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf, splitLines } from "../utils";
|
|
2
|
-
import { _lineHashesPure
|
|
2
|
+
import { _lineHashesPure } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdit,
|
|
5
5
|
stripBarePrefixes,
|
|
@@ -268,20 +268,7 @@ export function applyEdit(
|
|
|
268
268
|
};
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
export
|
|
272
|
-
hashes: string[],
|
|
273
|
-
lines: string[],
|
|
274
|
-
): string {
|
|
275
|
-
if (hashes.length !== lines.length) {
|
|
276
|
-
throw new Error(
|
|
277
|
-
`fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
return lines
|
|
281
|
-
.map((line, index) => `${hashes[index]}${HASH_SEP}${line}`)
|
|
282
|
-
.join("\n");
|
|
283
|
-
}
|
|
284
|
-
|
|
271
|
+
export { fmtRegion, fmtRow } from "./resolve";
|
|
285
272
|
export function changedRange(
|
|
286
273
|
original: string,
|
|
287
274
|
result: string,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -47,6 +47,30 @@ export const HL_PREFIX_MINUS_RE = new RegExp(
|
|
|
47
47
|
|
|
48
48
|
export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_RUN})│`);
|
|
49
49
|
|
|
50
|
+
export type RowPrefixKind = "bare" | "plus" | "minus";
|
|
51
|
+
|
|
52
|
+
export type StrippedRow = {
|
|
53
|
+
text: string;
|
|
54
|
+
kind: RowPrefixKind | null;
|
|
55
|
+
hash: string | undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function stripRowPrefix(line: string): StrippedRow {
|
|
59
|
+
const bare = line.match(HL_BARE_PREFIX_RE);
|
|
60
|
+
if (bare) {
|
|
61
|
+
return { text: line.slice(bare[0].length), kind: "bare", hash: bare[1] };
|
|
62
|
+
}
|
|
63
|
+
const plus = line.match(HL_PREFIX_PLUS_RE);
|
|
64
|
+
if (plus) {
|
|
65
|
+
return { text: line.slice(plus[0].length), kind: "plus", hash: plus[1] };
|
|
66
|
+
}
|
|
67
|
+
const minus = line.match(HL_PREFIX_MINUS_RE);
|
|
68
|
+
if (minus) {
|
|
69
|
+
return { text: line.slice(minus[0].length), kind: "minus", hash: minus[1] };
|
|
70
|
+
}
|
|
71
|
+
return { text: line, kind: null, hash: undefined };
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
export function canon(line: string): string {
|
|
51
75
|
return line.replace(/\r/g, "").trimEnd();
|
|
52
76
|
}
|
package/src/hashline/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export {
|
|
|
27
27
|
type BDup,
|
|
28
28
|
type AutoFix,
|
|
29
29
|
resEdit,
|
|
30
|
+
stripAnchorRow,
|
|
31
|
+
resolveAnchorLine,
|
|
30
32
|
valEdit,
|
|
31
33
|
stripBarePrefixes,
|
|
32
34
|
stripDiffPrefixes,
|
|
@@ -41,5 +43,6 @@ export {
|
|
|
41
43
|
buildIdx,
|
|
42
44
|
applyEdit,
|
|
43
45
|
fmtRegion,
|
|
46
|
+
fmtRow,
|
|
44
47
|
changedRange,
|
|
45
48
|
} from "./apply";
|