pi-hashline-edit-pro 2.7.0 → 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 +4 -2
- package/index.ts +6 -5
- package/package.json +1 -1
- package/prompts/undo-last-change-guidelines.md +1 -0
- package/prompts/undo-last-change.md +1 -1
- package/src/boundary-bypass.ts +2 -10
- package/src/grep.ts +3 -3
- 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 +1 -0
- package/src/hashline/resolve.ts +25 -23
- package/src/replace-response.ts +2 -6
- package/src/replace-undo.ts +15 -24
- package/src/replace.ts +2 -3
- package/src/served.ts +2 -3
package/README.md
CHANGED
|
@@ -161,7 +161,9 @@ Notes:
|
|
|
161
161
|
- History is persisted and survives session restarts. A failed `write` does not clear it.
|
|
162
162
|
- Every applied replace or insert is undoable: the undo record is saved before the edit is written.
|
|
163
163
|
- A successful `write` clears the history for that file.
|
|
164
|
-
- If the file was modified
|
|
164
|
+
- If the file was modified since the last replace or insert, the undo is refused rather than overwriting those changes. The undo record is kept: once the file matches the edited state again (for example you revert the external change), `undo_last_change` succeeds.
|
|
165
|
+
- If the file was deleted since the last replace or insert, `undo_last_change` restores it from the recorded pre-edit content. Nothing is overwritten, since the file no longer exists.
|
|
166
|
+
- Missing-file cleanup never touches the undo record: the per-session prune of the hash store removes the snapshots and served records of files that no longer exist (both are recomputed on the next read), but the undo history survives — even when the file is temporarily absent, for example during a branch switch.
|
|
165
167
|
|
|
166
168
|
## Auto-read
|
|
167
169
|
|
|
@@ -228,7 +230,7 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
228
230
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
229
231
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
230
232
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
231
|
-
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified
|
|
233
|
+
| `[E_UNDO_STALE]` | `undo_last_change` refused: the file was modified after the last edit. The undo record is kept until the file matches the edited state again or a new edit replaces it. |
|
|
232
234
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the edit was refused and the file was left unchanged. |
|
|
233
235
|
| `[E_RANGE_STALE]` | A line in the replaced range no longer matches what was last shown (the file changed on disk, or the line was never shown). The edit was refused; the current range is returned with fresh anchors. |
|
|
234
236
|
| `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
|
package/index.ts
CHANGED
|
@@ -65,13 +65,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
65
65
|
|
|
66
66
|
if (event.toolName === "write") {
|
|
67
67
|
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
68
|
+
let resolvedPath: string | undefined;
|
|
68
69
|
if (typeof writtenPath === "string") {
|
|
69
70
|
try {
|
|
70
|
-
|
|
71
|
-
await clearUndo(
|
|
72
|
-
clearBoundaryBypass(
|
|
71
|
+
resolvedPath = await resolveTarget(toCwd(writtenPath, ctx.cwd));
|
|
72
|
+
await clearUndo(resolvedPath);
|
|
73
|
+
clearBoundaryBypass(resolvedPath);
|
|
73
74
|
const store = await loadHashStore();
|
|
74
|
-
clearServed(store,
|
|
75
|
+
clearServed(store, resolvedPath);
|
|
75
76
|
} catch (error) {
|
|
76
77
|
console.error("Failed to clear undo after write:", error);
|
|
77
78
|
}
|
|
@@ -79,7 +80,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
79
80
|
if (!autoRead) return;
|
|
80
81
|
if (typeof writtenPath !== "string") return;
|
|
81
82
|
try {
|
|
82
|
-
|
|
83
|
+
resolvedPath ??= await resolveTarget(toCwd(writtenPath, ctx.cwd));
|
|
83
84
|
await valAccess(resolvedPath, writtenPath);
|
|
84
85
|
const file = await loadFileKindAndText(resolvedPath, { maxLines: MAX_HASH_LINES, displayPath: writtenPath });
|
|
85
86
|
if (file.kind !== "text") return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
- `undo_last_change`: reverts only the most recent replace or insert on the file: any write to the file clears the undo history, so call it immediately after a bad edit. An edit is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep (a closing brace, import, or declaration).
|
|
2
2
|
- `undo_last_change`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
|
|
3
|
+
- `undo_last_change`: if the file was deleted since the edit, the undo restores it from the recorded pre-edit content; if the file was modified since the edit, the undo is refused with `[E_UNDO_STALE]` and the record is kept, so reverting the external change makes the undo succeed.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Undo the last change (replace or insert) on a file, reverting it to its previous state. Use when an edit produced incorrect results (e.g., wrong content, duplicated lines, broken syntax).
|
|
1
|
+
Undo the last change (replace or insert) on a file, reverting it to its previous state. Use when an edit produced incorrect results (e.g., wrong content, duplicated lines, broken syntax). If the file was deleted since the edit, the undo restores it from the recorded content. If the file was modified since the edit, the undo is refused and the record is kept until the file matches the edited state again.
|
package/src/boundary-bypass.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseText } from "./hashline/parse";
|
|
2
2
|
import { ANCHOR_ROW_RE } from "./hashline/resolve";
|
|
3
|
-
import {
|
|
3
|
+
import { stripRowPrefix } from "./hashline/hash";
|
|
4
4
|
|
|
5
5
|
function canonRef(ref: string): string {
|
|
6
6
|
const trimmed = ref.trim();
|
|
@@ -9,15 +9,7 @@ function canonRef(ref: string): string {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
function canonLines(lines: string[]): string[] {
|
|
12
|
-
return parseText(lines).map((line) =>
|
|
13
|
-
const bare = line.match(HL_BARE_PREFIX_RE);
|
|
14
|
-
if (bare) return line.slice(bare[0].length);
|
|
15
|
-
const plus = line.match(HL_PREFIX_PLUS_RE);
|
|
16
|
-
if (plus) return line.slice(plus[0].length);
|
|
17
|
-
const minus = line.match(HL_PREFIX_MINUS_RE);
|
|
18
|
-
if (minus) return line.slice(minus[0].length);
|
|
19
|
-
return line;
|
|
20
|
-
});
|
|
12
|
+
return parseText(lines).map((line) => stripRowPrefix(line).text);
|
|
21
13
|
}
|
|
22
14
|
|
|
23
15
|
const boundaryBypassTracker = new Map<string, string>();
|
package/src/grep.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readdir, stat } from "fs/promises";
|
|
|
4
4
|
import { dirname, join, relative } from "path";
|
|
5
5
|
import { loadFileKindAndText } from "./file-kind";
|
|
6
6
|
import { readNormFile } from "./file-reader";
|
|
7
|
-
import { MAX_HASH_LINES,
|
|
7
|
+
import { MAX_HASH_LINES, fmtRow } from "./hashline";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
9
9
|
import { loadP, loadGuide } from "./prompts";
|
|
10
10
|
import { normReq } from "./replace-normalize";
|
|
@@ -155,7 +155,7 @@ async function searchFile(
|
|
|
155
155
|
if (file.kind !== "text") return undefined;
|
|
156
156
|
let norm;
|
|
157
157
|
try {
|
|
158
|
-
norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file });
|
|
158
|
+
norm = await readNormFile(absPath, cwd, { maxLines: MAX_HASH_LINES, preloadedFile: file, noPersist: true });
|
|
159
159
|
} catch (error) {
|
|
160
160
|
if (isSkipableLoadError(error)) return undefined;
|
|
161
161
|
throw error;
|
|
@@ -175,7 +175,7 @@ async function searchFile(
|
|
|
175
175
|
const rows: string[] = [];
|
|
176
176
|
const hashes: string[] = [];
|
|
177
177
|
for (const idx of sorted) {
|
|
178
|
-
rows.push(
|
|
178
|
+
rows.push(fmtRow(norm.fileHashes[idx]!, lines[idx]!));
|
|
179
179
|
hashes.push(norm.fileHashes[idx]!);
|
|
180
180
|
}
|
|
181
181
|
return {
|
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
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
|
|
2
|
-
import { HASH_SEP, HASH_RUN,
|
|
2
|
+
import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
|
|
3
3
|
import { parseHashRef, parseText, type Anchor } from "./parse";
|
|
4
4
|
import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
|
|
5
5
|
|
|
6
6
|
export type RAnchor = {
|
|
7
7
|
line: number;
|
|
8
8
|
hash: string;
|
|
9
|
-
hashMatched: boolean;
|
|
10
9
|
};
|
|
11
10
|
|
|
12
11
|
export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
|
|
@@ -56,7 +55,6 @@ function resAnchorFromMap(
|
|
|
56
55
|
return {
|
|
57
56
|
line: hashMatches[0]!,
|
|
58
57
|
hash: ref.hash,
|
|
59
|
-
hashMatched: true,
|
|
60
58
|
};
|
|
61
59
|
}
|
|
62
60
|
return { ref, kind: "ambiguous", candidates: hashMatches };
|
|
@@ -74,6 +72,19 @@ function assertAligned(
|
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
74
|
|
|
75
|
+
export function fmtRow(hash: string, line: string): string {
|
|
76
|
+
return `${hash}${HASH_SEP}${line}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function fmtRegion(hashes: string[], lines: string[]): string {
|
|
80
|
+
if (hashes.length !== lines.length) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return lines.map((line, index) => fmtRow(hashes[index]!, line)).join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
export function fmtMismatchWithHashes(
|
|
78
89
|
mismatches: HMismatch[],
|
|
79
90
|
fileLines: string[],
|
|
@@ -213,10 +224,10 @@ export function stripBarePrefixes(
|
|
|
213
224
|
const fileHashSet = new Set(fileHashes);
|
|
214
225
|
const stripped: { lineIndex: number; matched: boolean }[] = [];
|
|
215
226
|
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
218
|
-
stripped.push({ lineIndex, matched: fileHashSet.has(
|
|
219
|
-
return
|
|
227
|
+
const result = stripRowPrefix(line);
|
|
228
|
+
if (result.kind !== "bare") return line;
|
|
229
|
+
stripped.push({ lineIndex, matched: fileHashSet.has(result.hash ?? "") });
|
|
230
|
+
return result.text;
|
|
220
231
|
});
|
|
221
232
|
if (stripped.length === 0) return edit;
|
|
222
233
|
const locations = stripped
|
|
@@ -239,17 +250,10 @@ export function stripDiffPrefixes(
|
|
|
239
250
|
): HEdit {
|
|
240
251
|
const stripped: number[] = [];
|
|
241
252
|
const contentLines = edit.content_lines.map((line, lineIndex) => {
|
|
242
|
-
const
|
|
243
|
-
if (plus)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
const minus = line.match(HL_PREFIX_MINUS_RE);
|
|
248
|
-
if (minus) {
|
|
249
|
-
stripped.push(lineIndex);
|
|
250
|
-
return line.slice(minus[0].length);
|
|
251
|
-
}
|
|
252
|
-
return line;
|
|
253
|
+
const result = stripRowPrefix(line);
|
|
254
|
+
if (result.kind !== "plus" && result.kind !== "minus") return line;
|
|
255
|
+
stripped.push(lineIndex);
|
|
256
|
+
return result.text;
|
|
253
257
|
});
|
|
254
258
|
if (stripped.length === 0) return edit;
|
|
255
259
|
const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
|
|
@@ -502,12 +506,10 @@ export function resolveAnchorLine(
|
|
|
502
506
|
}
|
|
503
507
|
|
|
504
508
|
export class RangeStaleError extends Error {
|
|
505
|
-
readonly firstMismatchLine: number;
|
|
506
509
|
readonly rangeHashes: string[];
|
|
507
|
-
constructor(message: string,
|
|
510
|
+
constructor(message: string, rangeHashes: string[]) {
|
|
508
511
|
super(message);
|
|
509
512
|
this.name = "RangeStaleError";
|
|
510
|
-
this.firstMismatchLine = firstMismatchLine;
|
|
511
513
|
this.rangeHashes = rangeHashes;
|
|
512
514
|
}
|
|
513
515
|
}
|
|
@@ -544,7 +546,7 @@ export function assertRangeServed(
|
|
|
544
546
|
for (let line = startLine; line < startLine + shownLength; line++) {
|
|
545
547
|
const hash = fileHashes[line - 1]!;
|
|
546
548
|
shownHashes.push(hash);
|
|
547
|
-
rows.push(
|
|
549
|
+
rows.push(fmtRow(hash, fileLines[line - 1]));
|
|
548
550
|
}
|
|
549
551
|
const location = filePath ? ` in ${filePath}` : "";
|
|
550
552
|
const first = mismatchLines[0]!;
|
|
@@ -558,7 +560,7 @@ export function assertRangeServed(
|
|
|
558
560
|
: "";
|
|
559
561
|
const message =
|
|
560
562
|
`[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
|
|
561
|
-
throw new RangeStaleError(message,
|
|
563
|
+
throw new RangeStaleError(message, shownHashes);
|
|
562
564
|
}
|
|
563
565
|
|
|
564
566
|
export { warnUnicodeEsc };
|
package/src/replace-response.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NEdit } from "./hashline";
|
|
1
2
|
import type { ReplaceDetails } from "./replace";
|
|
2
3
|
import { genDiff, genPatch } from "./replace-diff";
|
|
3
4
|
import { visLines, clipLine } from "./utils";
|
|
@@ -27,14 +28,9 @@ export type RMeta = {
|
|
|
27
28
|
removedLines: number;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
type NEditEntry = {
|
|
31
|
-
loc: string;
|
|
32
|
-
currentContent: string;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
31
|
export interface NoopInput {
|
|
36
32
|
path: string;
|
|
37
|
-
noopEdit:
|
|
33
|
+
noopEdit: NEdit | undefined;
|
|
38
34
|
snapshotId?: string;
|
|
39
35
|
editMeta: RMeta;
|
|
40
36
|
warnings: string[] | undefined;
|
package/src/replace-undo.ts
CHANGED
|
@@ -126,26 +126,15 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
126
126
|
if (errCode(error) !== "ENOENT") throw error;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
{
|
|
134
|
-
type: "text",
|
|
135
|
-
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file no longer exists.`
|
|
136
|
-
},
|
|
137
|
-
],
|
|
138
|
-
isError: true,
|
|
139
|
-
details: {},
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
|
|
143
|
-
await clearUndo(mutationTargetPath);
|
|
129
|
+
if (
|
|
130
|
+
currentRaw !== undefined &&
|
|
131
|
+
currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)
|
|
132
|
+
) {
|
|
144
133
|
return {
|
|
145
134
|
content: [
|
|
146
135
|
{
|
|
147
136
|
type: "text",
|
|
148
|
-
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. Call read() to inspect the current state.`
|
|
137
|
+
text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. The undo record is kept; once the file matches the edited state again, undo_last_change will succeed. Call read() to inspect the current state.`
|
|
149
138
|
},
|
|
150
139
|
],
|
|
151
140
|
isError: true,
|
|
@@ -153,20 +142,19 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
153
142
|
};
|
|
154
143
|
}
|
|
155
144
|
|
|
156
|
-
|
|
157
|
-
|
|
145
|
+
await writeAtomic(
|
|
146
|
+
mutationTargetPath,
|
|
147
|
+
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|
|
158
151
|
const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
|
|
159
|
-
const diffResult = genDiff(undo.content,
|
|
152
|
+
const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes);
|
|
160
153
|
const linesAddedByReplace = cntDiff(diffResult.diff, "+");
|
|
161
154
|
const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
|
|
162
155
|
const restoredRange = changedRange(currentNormalized, undo.content);
|
|
163
156
|
const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes).diff;
|
|
164
157
|
|
|
165
|
-
await writeAtomic(
|
|
166
|
-
mutationTargetPath,
|
|
167
|
-
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
168
|
-
);
|
|
169
|
-
|
|
170
158
|
try {
|
|
171
159
|
const store = await loadHashStore();
|
|
172
160
|
upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
|
|
@@ -180,6 +168,9 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
180
168
|
const parts: string[] = [
|
|
181
169
|
`Undone last change on ${path}.`,
|
|
182
170
|
];
|
|
171
|
+
if (currentRaw === undefined) {
|
|
172
|
+
parts.push("The file was deleted; restored it from undo history.");
|
|
173
|
+
}
|
|
183
174
|
if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
|
|
184
175
|
parts.push(
|
|
185
176
|
`Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
|
package/src/replace.ts
CHANGED
|
@@ -31,9 +31,8 @@ import {
|
|
|
31
31
|
type RPreview,
|
|
32
32
|
type RRState,
|
|
33
33
|
} from "./replace-render";
|
|
34
|
-
export { reuseText, reuseMarkdown } from "./replace-render";
|
|
35
34
|
import { loadP, loadGuide } from "./prompts";
|
|
36
|
-
import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
|
|
35
|
+
import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
|
|
37
36
|
import { getServed, recordServedSafe } from "./served";
|
|
38
37
|
import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
|
|
39
38
|
import { commitEdit } from "./commit";
|
|
@@ -149,7 +148,7 @@ async function resolveMissingPath(
|
|
|
149
148
|
} catch {
|
|
150
149
|
return undefined;
|
|
151
150
|
}
|
|
152
|
-
const matches = findSnapshotPaths(store, hashes);
|
|
151
|
+
const matches = [...new Set([...findSnapshotPaths(store, hashes), ...findServedPaths(store, hashes)])];
|
|
153
152
|
if (matches.length === 1) {
|
|
154
153
|
return {
|
|
155
154
|
path: matches[0]!,
|
package/src/served.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadHashStore,
|
|
1
|
+
import { loadHashStore, parseStoredHashes, type HashStore } from "./hash-store";
|
|
2
2
|
import { HASH_CLASS } from "./hashline/alphabet";
|
|
3
3
|
|
|
4
4
|
const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
|
|
@@ -14,8 +14,7 @@ export function servedHashesFromDiff(diff: string): string[] {
|
|
|
14
14
|
|
|
15
15
|
export function getServed(store: HashStore, path: string): Set<string> | undefined {
|
|
16
16
|
const row = store.stmts.servedGet(path);
|
|
17
|
-
|
|
18
|
-
const parsed = parseHashList(row.hashes as string, () => store.stmts.servedDelete(path));
|
|
17
|
+
const parsed = parseStoredHashes(row, () => store.stmts.servedDelete(path));
|
|
19
18
|
if (!parsed) return undefined;
|
|
20
19
|
return new Set(parsed);
|
|
21
20
|
}
|