pi-hashline-edit-pro 1.0.4 → 1.0.5
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 +5 -3
- package/index.ts +8 -1
- package/package.json +1 -1
- package/prompts/read.md +1 -1
- package/src/file-kind.ts +27 -0
- package/src/file-reader.ts +2 -2
- package/src/hash-store.ts +77 -3
- package/src/replace-diff.ts +12 -5
- package/src/replace-undo.ts +48 -14
- package/src/replace.ts +8 -2
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ Edge cases:
|
|
|
66
66
|
|
|
67
67
|
- **Images** (JPEG, PNG, GIF, WebP) are passed through as visual attachments and don't participate in the hashline protocol.
|
|
68
68
|
- **Binary and directory paths** are rejected with a descriptive error.
|
|
69
|
+
- **UTF-16/UTF-32 encoded text** (detected via BOM) is rejected with `[E_NOT_TEXT]` — editing such a file would decode it as `U+FFFD` garbage and rewrite it as corrupted UTF-8.
|
|
69
70
|
- **Empty files** are returned as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
|
|
70
71
|
- **BOMs** are stripped for display; **non-UTF-8 bytes** are shown as `U+FFFD` (editing such a file rewrites it as UTF-8, with a warning).
|
|
71
72
|
- **Files over 238,328 lines** are rejected with `[E_FILE_TOO_LARGE]` (see [Hashing](#hashing)).
|
|
@@ -120,13 +121,14 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
120
121
|
- After `replace` / `undo_last_replace`, the block covers the changed span plus 2 lines of context above and below — the rest of the file keeps its anchors from the persistent store.
|
|
121
122
|
- After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
|
|
122
123
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
124
|
+
- If the auto-read itself fails (e.g. the file was deleted between the operation and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
|
|
123
125
|
|
|
124
126
|
## Undo
|
|
125
127
|
|
|
126
128
|
`undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content — BOM and line endings included — and the previous anchors.
|
|
127
129
|
|
|
128
130
|
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
129
|
-
- History is in-
|
|
131
|
+
- History is persisted in the hash store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) and survives session restarts; a failed `write` does not clear it.
|
|
130
132
|
- A successful `write` clears the history for that file.
|
|
131
133
|
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
132
134
|
- **Safety guard.** If the file was modified or deleted since the last replace, `undo_last_replace` refuses with `[E_UNDO_STALE]` rather than overwriting those changes.
|
|
@@ -160,7 +162,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
160
162
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
161
163
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
162
164
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
163
|
-
| `[E_NOT_TEXT]` | The path is a directory, binary file, or
|
|
165
|
+
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
164
166
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
165
167
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
166
168
|
|
|
@@ -176,7 +178,7 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
176
178
|
|
|
177
179
|
- **Stale anchors fail, per line.** A hash mismatch means that line's content changed since the last `read`. The error says so and, when only one anchor of a pair is stale, shows the current lines around the still-valid anchor so the range can be re-located without a full re-read. Mismatched anchors are never silently relocated to a "close enough" line — correctness over convenience.
|
|
178
180
|
- **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
|
|
179
|
-
- **Byte-exact preservation.** UTF-8 BOMs, CRLF
|
|
181
|
+
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo.
|
|
180
182
|
- **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
|
|
181
183
|
- **One edit per call.** The request shape stays `{path, hash_range_inclusive, content_lines}` from schema through validation to application; there is no batching dialect.
|
|
182
184
|
|
package/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
59
59
|
const writtenPath = (event.input as Record<string, unknown>)?.path;
|
|
60
60
|
if (typeof writtenPath === "string") {
|
|
61
61
|
try {
|
|
62
|
-
clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
62
|
+
await clearUndo(await resolveTarget(toCwd(writtenPath, ctx.cwd)));
|
|
63
63
|
} catch (error) {
|
|
64
64
|
console.error("Failed to clear undo after write:", error);
|
|
65
65
|
}
|
|
@@ -108,6 +108,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
108
|
};
|
|
109
109
|
} catch (error) {
|
|
110
110
|
console.error("Auto-read after write/replace failed:", error);
|
|
111
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
...(event.content ?? []),
|
|
115
|
+
{ type: "text", text: `\n\n--- Auto-read failed: ${message} ---` },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
111
118
|
}
|
|
112
119
|
});
|
|
113
120
|
}
|
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
|
1
|
+
Read a text file; each line returned as HASH│content with a 3-char alphanumeric hash. No line numbers — use the HASH as the anchor in replace calls. Images → visual attachments; Binary/directory → rejected; UTF-16/UTF-32 (BOM) → rejected; empty → HASH│ (replace to insert); pageable with offset/limit; BOM stripped; non-UTF-8 shown as U+FFFD.
|
package/src/file-kind.ts
CHANGED
|
@@ -15,6 +15,26 @@ const TEXT_TYPES = new Set<string>([
|
|
|
15
15
|
"application/x-ms-regedit",
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
|
+
function detectTextBom(sample: Uint8Array): string | undefined {
|
|
19
|
+
if (
|
|
20
|
+
sample.length >= 4 &&
|
|
21
|
+
sample[0] === 0xff &&
|
|
22
|
+
sample[1] === 0xfe &&
|
|
23
|
+
sample[2] === 0x00 &&
|
|
24
|
+
sample[3] === 0x00
|
|
25
|
+
) return "UTF-32LE";
|
|
26
|
+
if (
|
|
27
|
+
sample.length >= 4 &&
|
|
28
|
+
sample[0] === 0x00 &&
|
|
29
|
+
sample[1] === 0x00 &&
|
|
30
|
+
sample[2] === 0xfe &&
|
|
31
|
+
sample[3] === 0xff
|
|
32
|
+
) return "UTF-32BE";
|
|
33
|
+
if (sample.length >= 2 && sample[0] === 0xff && sample[1] === 0xfe) return "UTF-16LE";
|
|
34
|
+
if (sample.length >= 2 && sample[0] === 0xfe && sample[1] === 0xff) return "UTF-16BE";
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
function isTextType(mimeType: string): boolean {
|
|
19
39
|
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
20
40
|
}
|
|
@@ -66,6 +86,13 @@ export async function loadFileKindAndText(
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
const sample = buffer.subarray(0, bytesRead);
|
|
89
|
+
const textBom = detectTextBom(sample);
|
|
90
|
+
if (textBom) {
|
|
91
|
+
return {
|
|
92
|
+
kind: "binary",
|
|
93
|
+
description: `${textBom} encoded text`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
69
96
|
const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
|
|
70
97
|
if (
|
|
71
98
|
detectedMimeType !== undefined &&
|
package/src/file-reader.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { lineHashes } from "./hashline";
|
|
|
4
4
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
5
5
|
import { resolveTarget } from "./fs-write";
|
|
6
6
|
import { toCwd } from "./paths";
|
|
7
|
-
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
7
|
+
import { detectEnding, toLF, stripBOM, type LineEnding } from "./replace-diff";
|
|
8
8
|
import { abortIf } from "./utils";
|
|
9
9
|
import { valKind, valAccess } from "./validation";
|
|
10
10
|
import { visLines } from "./utils";
|
|
@@ -13,7 +13,7 @@ export interface NormFile {
|
|
|
13
13
|
absolutePath: string;
|
|
14
14
|
normalized: string;
|
|
15
15
|
bom: string;
|
|
16
|
-
originalEnding:
|
|
16
|
+
originalEnding: LineEnding;
|
|
17
17
|
fileHashes: string[];
|
|
18
18
|
hadUtf8DecodeErrors: boolean;
|
|
19
19
|
}
|
package/src/hash-store.ts
CHANGED
|
@@ -12,6 +12,9 @@ interface Prepared {
|
|
|
12
12
|
allPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
13
13
|
deleteOne: (...params: SqlParams) => void;
|
|
14
14
|
upsert: (...params: SqlParams) => void;
|
|
15
|
+
undoUpsert: (...params: SqlParams) => void;
|
|
16
|
+
undoGet: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
17
|
+
undoDelete: (...params: SqlParams) => void;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export interface HashStore {
|
|
@@ -19,6 +22,14 @@ export interface HashStore {
|
|
|
19
22
|
readonly engine: "node:sqlite";
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
export interface UndoRecord {
|
|
26
|
+
content: string;
|
|
27
|
+
bom: string;
|
|
28
|
+
ending: string;
|
|
29
|
+
hashes: string[];
|
|
30
|
+
resultContent: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
interface LegacySnapshot {
|
|
23
34
|
content: string;
|
|
24
35
|
hashes: string[];
|
|
@@ -72,27 +83,49 @@ function buildStore(
|
|
|
72
83
|
"value TEXT NOT NULL" +
|
|
73
84
|
")"
|
|
74
85
|
);
|
|
86
|
+
db.exec(
|
|
87
|
+
"CREATE TABLE IF NOT EXISTS undo (" +
|
|
88
|
+
"path TEXT PRIMARY KEY, " +
|
|
89
|
+
"content TEXT NOT NULL, " +
|
|
90
|
+
"bom TEXT NOT NULL, " +
|
|
91
|
+
"ending TEXT NOT NULL, " +
|
|
92
|
+
"hashes TEXT NOT NULL, " +
|
|
93
|
+
"result_content TEXT NOT NULL, " +
|
|
94
|
+
"updated_at INTEGER NOT NULL" +
|
|
95
|
+
")"
|
|
96
|
+
);
|
|
75
97
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
76
98
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
77
99
|
db.exec("DELETE FROM snapshots");
|
|
100
|
+
db.exec("DELETE FROM undo");
|
|
78
101
|
}
|
|
79
102
|
db.prepare(
|
|
80
103
|
"INSERT INTO meta (key, value) VALUES ('version', ?) " +
|
|
81
104
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
82
105
|
).run(String(HASH_STORE_VERSION));
|
|
83
106
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
84
|
-
const allStmt = db.prepare("SELECT path FROM snapshots");
|
|
107
|
+
const allStmt = db.prepare("SELECT path FROM snapshots UNION SELECT path FROM undo");
|
|
85
108
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
86
109
|
const upsertStmt = db.prepare(
|
|
87
110
|
"INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
88
111
|
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at"
|
|
89
112
|
);
|
|
90
|
-
|
|
113
|
+
const undoUpsertStmt = db.prepare(
|
|
114
|
+
"INSERT INTO undo (path, content, bom, ending, hashes, result_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
|
|
115
|
+
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, updated_at = excluded.updated_at"
|
|
116
|
+
);
|
|
117
|
+
const undoGetStmt = db.prepare(
|
|
118
|
+
"SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
|
|
119
|
+
);
|
|
120
|
+
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
91
121
|
const stmts: Prepared = {
|
|
92
122
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
93
123
|
allPaths: (...params) => allStmt.all(...params) as Record<string, unknown>[],
|
|
94
124
|
deleteOne: (...params) => { delStmt.run(...params); },
|
|
95
125
|
upsert: (...params) => { upsertStmt.run(...params); },
|
|
126
|
+
undoUpsert: (...params) => { undoUpsertStmt.run(...params); },
|
|
127
|
+
undoGet: (...params) => undoGetStmt.get(...params) as Record<string, unknown> | undefined,
|
|
128
|
+
undoDelete: (...params) => { undoDelStmt.run(...params); },
|
|
96
129
|
};
|
|
97
130
|
|
|
98
131
|
return { db, stmts };
|
|
@@ -295,6 +328,44 @@ export function deleteSnapshot(store: HashStore, path: string): void {
|
|
|
295
328
|
store.stmts.deleteOne(path);
|
|
296
329
|
}
|
|
297
330
|
|
|
331
|
+
export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
|
|
332
|
+
store.stmts.undoUpsert(
|
|
333
|
+
path,
|
|
334
|
+
entry.content,
|
|
335
|
+
entry.bom,
|
|
336
|
+
entry.ending,
|
|
337
|
+
JSON.stringify(entry.hashes),
|
|
338
|
+
entry.resultContent,
|
|
339
|
+
Date.now(),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function getUndoEntry(store: HashStore, path: string): UndoRecord | undefined {
|
|
344
|
+
const row = store.stmts.undoGet(path);
|
|
345
|
+
if (!row) return undefined;
|
|
346
|
+
try {
|
|
347
|
+
const parsed = JSON.parse(row.hashes as string);
|
|
348
|
+
if (!Array.isArray(parsed) || !parsed.every((h) => typeof h === "string")) {
|
|
349
|
+
store.stmts.undoDelete(path);
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
content: row.content as string,
|
|
354
|
+
bom: row.bom as string,
|
|
355
|
+
ending: row.ending as string,
|
|
356
|
+
hashes: parsed as string[],
|
|
357
|
+
resultContent: row.result_content as string,
|
|
358
|
+
};
|
|
359
|
+
} catch {
|
|
360
|
+
store.stmts.undoDelete(path);
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function deleteUndo(store: HashStore, path: string): void {
|
|
366
|
+
store.stmts.undoDelete(path);
|
|
367
|
+
}
|
|
368
|
+
|
|
298
369
|
export async function pruneMissing(store: HashStore): Promise<void> {
|
|
299
370
|
const rows = store.stmts.allPaths() as { path: string }[];
|
|
300
371
|
const missing: string[] = [];
|
|
@@ -307,6 +378,9 @@ export async function pruneMissing(store: HashStore): Promise<void> {
|
|
|
307
378
|
}
|
|
308
379
|
if (missing.length === 0) return;
|
|
309
380
|
withStore(() => {
|
|
310
|
-
for (const path of missing)
|
|
381
|
+
for (const path of missing) {
|
|
382
|
+
store.stmts.deleteOne(path);
|
|
383
|
+
store.stmts.undoDelete(path);
|
|
384
|
+
}
|
|
311
385
|
});
|
|
312
386
|
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -5,10 +5,15 @@ import {
|
|
|
5
5
|
HASH_SEP,
|
|
6
6
|
} from "./hashline";
|
|
7
7
|
|
|
8
|
-
export
|
|
9
|
-
|
|
8
|
+
export type LineEnding = "\r\n" | "\n" | "\r";
|
|
9
|
+
|
|
10
|
+
export function detectEnding(content: string): LineEnding {
|
|
10
11
|
const lfIdx = content.indexOf("\n");
|
|
11
|
-
if (lfIdx === -1
|
|
12
|
+
if (lfIdx === -1) {
|
|
13
|
+
return content.indexOf("\r") >= 0 ? "\r" : "\n";
|
|
14
|
+
}
|
|
15
|
+
const crlfIdx = content.indexOf("\r\n");
|
|
16
|
+
if (crlfIdx === -1) return "\n";
|
|
12
17
|
return crlfIdx < lfIdx ? "\r\n" : "\n";
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -18,9 +23,11 @@ export function toLF(text: string): string {
|
|
|
18
23
|
|
|
19
24
|
export function restoreEndings(
|
|
20
25
|
text: string,
|
|
21
|
-
ending:
|
|
26
|
+
ending: LineEnding,
|
|
22
27
|
): string {
|
|
23
|
-
|
|
28
|
+
if (ending === "\r\n") return text.replace(/\n/g, "\r\n");
|
|
29
|
+
if (ending === "\r") return text.replace(/\n/g, "\r");
|
|
30
|
+
return text;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export function stripBOM(content: string): { bom: string; text: string } {
|
package/src/replace-undo.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
|
-
import { loadHashStore, upsertSnapshot } from "./hash-store";
|
|
5
|
+
import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo } from "./hash-store";
|
|
6
6
|
import { contentChecksum } from "./hashline/hasher";
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
9
|
-
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
9
|
+
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./replace-diff";
|
|
10
10
|
import { cntDiff, splitLines, errCode } from "./utils";
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { buildMetrics } from "./replace-response";
|
|
@@ -14,26 +14,60 @@ import { changedRange } from "./hashline";
|
|
|
14
14
|
export interface UndoEntry {
|
|
15
15
|
content: string;
|
|
16
16
|
bom: string;
|
|
17
|
-
originalEnding:
|
|
17
|
+
originalEnding: LineEnding;
|
|
18
18
|
hashes: string[];
|
|
19
19
|
resultContent: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean> {
|
|
23
|
+
try {
|
|
24
|
+
const store = await loadHashStore();
|
|
25
|
+
upsertUndo(store, path, {
|
|
26
|
+
content: entry.content,
|
|
27
|
+
bom: entry.bom,
|
|
28
|
+
ending: entry.originalEnding,
|
|
29
|
+
hashes: entry.hashes,
|
|
30
|
+
resultContent: entry.resultContent,
|
|
31
|
+
});
|
|
32
|
+
return true;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Failed to persist undo entry:", error);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
export function getUndo(path: string): UndoEntry | undefined {
|
|
29
|
-
|
|
39
|
+
export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
40
|
+
try {
|
|
41
|
+
const store = await loadHashStore();
|
|
42
|
+
const record = getUndoEntry(store, path);
|
|
43
|
+
if (!record) return undefined;
|
|
44
|
+
const originalEnding = record.ending;
|
|
45
|
+
if (originalEnding !== "\r\n" && originalEnding !== "\n" && originalEnding !== "\r") {
|
|
46
|
+
await deleteUndo(store, path);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
content: record.content,
|
|
51
|
+
bom: record.bom,
|
|
52
|
+
originalEnding,
|
|
53
|
+
hashes: record.hashes,
|
|
54
|
+
resultContent: record.resultContent,
|
|
55
|
+
};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("Failed to load undo entry:", error);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
30
60
|
}
|
|
31
61
|
|
|
32
|
-
export function clearUndo(path: string): void {
|
|
33
|
-
|
|
62
|
+
export async function clearUndo(path: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
const store = await loadHashStore();
|
|
65
|
+
deleteUndo(store, path);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("Failed to clear undo entry:", error);
|
|
68
|
+
}
|
|
34
69
|
}
|
|
35
70
|
|
|
36
|
-
|
|
37
71
|
export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
38
72
|
pi.registerTool({
|
|
39
73
|
name: "undo_last_replace",
|
|
@@ -52,7 +86,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
52
86
|
const absolutePath = toCwd(path, ctx.cwd);
|
|
53
87
|
const mutationTargetPath = await resolveTarget(absolutePath);
|
|
54
88
|
|
|
55
|
-
const undo = getUndo(mutationTargetPath);
|
|
89
|
+
const undo = await getUndo(mutationTargetPath);
|
|
56
90
|
if (!undo) {
|
|
57
91
|
return {
|
|
58
92
|
content: [
|
|
@@ -118,7 +152,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
118
152
|
console.error("Failed to restore hash store snapshot after undo:", error);
|
|
119
153
|
}
|
|
120
154
|
|
|
121
|
-
clearUndo(mutationTargetPath);
|
|
155
|
+
await clearUndo(mutationTargetPath);
|
|
122
156
|
|
|
123
157
|
const parts: string[] = [
|
|
124
158
|
`Undone last replace on ${path}.`,
|
package/src/replace.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { constants } from "fs";
|
|
|
9
9
|
import {
|
|
10
10
|
genDiff,
|
|
11
11
|
restoreEndings,
|
|
12
|
+
type LineEnding,
|
|
12
13
|
} from "./replace-diff";
|
|
13
14
|
import { readNormFile } from "./file-reader";
|
|
14
15
|
import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
|
|
@@ -86,7 +87,7 @@ interface PipelineResult {
|
|
|
86
87
|
originalNormalized: string;
|
|
87
88
|
result: string;
|
|
88
89
|
bom: string;
|
|
89
|
-
originalEnding:
|
|
90
|
+
originalEnding: LineEnding;
|
|
90
91
|
hadUtf8DecodeErrors: boolean;
|
|
91
92
|
warnings: string[];
|
|
92
93
|
noopEdit?: NEdit;
|
|
@@ -465,13 +466,18 @@ export function buildToolDef(): ToolDef {
|
|
|
465
466
|
absolutePath,
|
|
466
467
|
bom + restoreEndings(result, originalEnding),
|
|
467
468
|
);
|
|
468
|
-
saveUndo(mutationTargetPath, {
|
|
469
|
+
const undoPersisted = await saveUndo(mutationTargetPath, {
|
|
469
470
|
content: originalNormalized,
|
|
470
471
|
bom,
|
|
471
472
|
originalEnding,
|
|
472
473
|
hashes: originalHashes,
|
|
473
474
|
resultContent: result,
|
|
474
475
|
});
|
|
476
|
+
if (!undoPersisted) {
|
|
477
|
+
warnings.push(
|
|
478
|
+
"Undo history could not be persisted; undo_last_replace will not be available for this edit.",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
475
481
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
476
482
|
.snapshotId;
|
|
477
483
|
|