pi-hashline-edit-pro 1.0.6 → 1.0.8
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 +13 -3
- package/package.json +1 -1
- package/prompts/replace-guidelines.md +2 -1
- package/src/file-kind.ts +0 -20
- package/src/fs-write.ts +0 -16
- package/src/hash-store.ts +0 -4
- package/src/hashline/hash.ts +12 -1
- package/src/hashline/parse.ts +6 -0
- package/src/hashline/resolve.ts +3 -3
- package/src/replace-render.ts +1 -0
- package/src/replace-undo.ts +22 -4
- package/src/replace.ts +54 -31
- package/src/validation.ts +0 -5
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ Exactly one edit per call, with `hash_range_inclusive` and `content_lines` at th
|
|
|
90
90
|
| Field | Description |
|
|
91
91
|
| --- | --- |
|
|
92
92
|
| `hash_range_inclusive` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
|
|
93
|
-
| `content_lines` | Replacement content, one string per line. Use `[]` to delete the range. |
|
|
93
|
+
| `content_lines` | Replacement content, one string per line; entries must not contain line breaks. Use `[]` to delete the range. |
|
|
94
94
|
|
|
95
95
|
Behavior:
|
|
96
96
|
|
|
@@ -132,6 +132,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
132
132
|
|
|
133
133
|
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
134
134
|
- 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.
|
|
135
|
+
- **Undo is a precondition, not a convenience.** The undo record is persisted *before* the edit is written; if it cannot be persisted, the `replace` is refused with `[E_UNDO_UNAVAILABLE]` and the file is not touched, so every applied edit is undoable. If the file write itself then fails, the previous undo record is restored, so a refused edit never destroys earlier undo history.
|
|
135
136
|
- A successful `write` clears the history for that file.
|
|
136
137
|
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
137
138
|
- **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.
|
|
@@ -154,7 +155,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
154
155
|
|
|
155
156
|
| Code | Meaning |
|
|
156
157
|
| --- | --- |
|
|
157
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields. |
|
|
158
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields, or a `content_lines` entry contains a line break. |
|
|
158
159
|
| `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
|
|
159
160
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
160
161
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
@@ -167,6 +168,7 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
167
168
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
168
169
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
|
169
170
|
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
171
|
+
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
170
172
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
171
173
|
|
|
172
174
|
## Hashing
|
|
@@ -181,10 +183,18 @@ The alphabet is sized for an LLM consumer: the model tokenizes rather than squin
|
|
|
181
183
|
|
|
182
184
|
- **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.
|
|
183
185
|
- **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).
|
|
184
|
-
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo.
|
|
186
|
+
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
|
|
185
187
|
- **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.
|
|
186
188
|
- **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.
|
|
187
189
|
|
|
190
|
+
## Troubleshooting
|
|
191
|
+
|
|
192
|
+
- **Stale anchors.** `[E_STALE_ANCHOR]` / `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read, or an earlier `read` never happened. Call `read` for fresh anchors and retry.
|
|
193
|
+
- **Reset the hash store.** Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
194
|
+
- **Corrupt store.** If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` (plus `-wal`/`-shm` variants) and rebuilt automatically; the quarantined files can be deleted once a healthy store exists.
|
|
195
|
+
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted.
|
|
196
|
+
- **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
|
|
197
|
+
|
|
188
198
|
## Development
|
|
189
199
|
|
|
190
200
|
Requires [Node.js](https://nodejs.org) ≥ 22.13 and npm.
|
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
- `replace`: hash_range_inclusive must use only anchors from the most recent read of the same file.
|
|
2
2
|
- `replace`: content_lines is a native JSON array of strings — never a serialized JSON string. When copying a line from read output, remove its HASH│ prefix and keep the leading whitespace exactly as shown.
|
|
3
|
-
- `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
|
|
3
|
+
- `replace`: minimize the replaced range — anchor only the lines that actually change; for insertions use a single-line range (e.g. the line after the insertion point) instead of a whole block, so fewer unchanged lines must be reproduced byte-exact.
|
|
4
|
+
- `replace`: content_lines entries are single lines — never embed a line break inside an entry; pass each line as its own array entry.
|
package/src/file-kind.ts
CHANGED
|
@@ -39,12 +39,6 @@ function isTextType(mimeType: string): boolean {
|
|
|
39
39
|
return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export type FKind =
|
|
43
|
-
| { kind: "directory" }
|
|
44
|
-
| { kind: "image"; mimeType: string }
|
|
45
|
-
| { kind: "text" }
|
|
46
|
-
| { kind: "binary"; description: string };
|
|
47
|
-
|
|
48
42
|
export type LFile =
|
|
49
43
|
| { kind: "directory" }
|
|
50
44
|
| { kind: "image"; mimeType: string }
|
|
@@ -149,17 +143,3 @@ export async function loadFileKindAndText(
|
|
|
149
143
|
await fileHandle.close();
|
|
150
144
|
}
|
|
151
145
|
}
|
|
152
|
-
|
|
153
|
-
export async function classifyFileKind(filePath: string): Promise<FKind> {
|
|
154
|
-
const loaded = await loadFileKindAndText(filePath);
|
|
155
|
-
switch (loaded.kind) {
|
|
156
|
-
case "directory":
|
|
157
|
-
return loaded;
|
|
158
|
-
case "image":
|
|
159
|
-
return loaded;
|
|
160
|
-
case "binary":
|
|
161
|
-
return loaded;
|
|
162
|
-
case "text":
|
|
163
|
-
return { kind: "text" };
|
|
164
|
-
}
|
|
165
|
-
}
|
package/src/fs-write.ts
CHANGED
|
@@ -9,8 +9,6 @@ import {
|
|
|
9
9
|
rm,
|
|
10
10
|
stat,
|
|
11
11
|
writeFile,
|
|
12
|
-
copyFile,
|
|
13
|
-
chmod,
|
|
14
12
|
} from "fs/promises";
|
|
15
13
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
16
14
|
import { errCode } from "./utils";
|
|
@@ -153,20 +151,6 @@ export async function writeAtomic(
|
|
|
153
151
|
await rename(tempPath, targetPath);
|
|
154
152
|
await syncDir(dir);
|
|
155
153
|
} catch (error: unknown) {
|
|
156
|
-
if (errCode(error) === "EXDEV") {
|
|
157
|
-
try {
|
|
158
|
-
await copyFile(tempPath, targetPath);
|
|
159
|
-
if (existingStats) {
|
|
160
|
-
await chmod(targetPath, existingStats.mode & 0o7777);
|
|
161
|
-
}
|
|
162
|
-
await rm(tempPath, { force: true });
|
|
163
|
-
await syncDir(dir);
|
|
164
|
-
return;
|
|
165
|
-
} catch {
|
|
166
|
-
try { await rm(tempPath, { force: true }); } catch {}
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
154
|
if (process.platform === "win32" && errCode(error) === "EPERM") {
|
|
171
155
|
try {
|
|
172
156
|
await writeFile(targetPath, content, "utf-8");
|
package/src/hash-store.ts
CHANGED
|
@@ -324,10 +324,6 @@ export function upsertSnapshot(
|
|
|
324
324
|
store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
export function deleteSnapshot(store: HashStore, path: string): void {
|
|
328
|
-
store.stmts.deleteOne(path);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
327
|
export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
|
|
332
328
|
store.stmts.undoUpsert(
|
|
333
329
|
path,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -234,6 +234,16 @@ function mapStableHashes(
|
|
|
234
234
|
if (idx !== undefined) removedIndexes.add(idx);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
let spanStart = oldLines.length;
|
|
238
|
+
let spanEnd = -1;
|
|
239
|
+
for (const idx of removedIndexes) {
|
|
240
|
+
if (idx < spanStart) spanStart = idx;
|
|
241
|
+
if (idx > spanEnd) spanEnd = idx;
|
|
242
|
+
}
|
|
243
|
+
const spanLen = spanEnd >= spanStart ? spanEnd - spanStart + 1 : 0;
|
|
244
|
+
const replacementLen = newLines.length - oldLines.length + spanLen;
|
|
245
|
+
const shiftAfterSpan = spanEnd >= spanStart ? replacementLen - spanLen : 0;
|
|
246
|
+
|
|
237
247
|
const survivors: { index: number; hash: string }[] = [];
|
|
238
248
|
const removedEntries: { index: number; hash: string }[] = [];
|
|
239
249
|
for (let i = 0; i < oldLines.length; i++) {
|
|
@@ -261,7 +271,8 @@ function mapStableHashes(
|
|
|
261
271
|
for (const entry of survivors) {
|
|
262
272
|
const candidates = newByContent.get(canon(oldLines[entry.index]!));
|
|
263
273
|
if (!candidates || candidates.length === 0) continue;
|
|
264
|
-
const
|
|
274
|
+
const target = entry.index > spanEnd ? entry.index + shiftAfterSpan : entry.index;
|
|
275
|
+
const pos = nearestNew(candidates, target);
|
|
265
276
|
if (pos < 0) continue;
|
|
266
277
|
const newIdx = candidates.splice(pos, 1)[0]!;
|
|
267
278
|
newHashes[newIdx] = entry.hash;
|
package/src/hashline/parse.ts
CHANGED
|
@@ -46,5 +46,11 @@ export function parseText(edit: string[] | string | null): string[] {
|
|
|
46
46
|
if (typeof edit === "string") {
|
|
47
47
|
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
48
48
|
}
|
|
49
|
+
const lineBreakIndex = edit.findIndex((line) => /[\r\n]/.test(line));
|
|
50
|
+
if (lineBreakIndex >= 0) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[E_BAD_SHAPE] "content_lines" entry at index ${lineBreakIndex} contains a \\r or \\n line break. Pass each line as its own array entry.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
49
55
|
return edit;
|
|
50
56
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -225,7 +225,7 @@ export function stripBarePrefixes(
|
|
|
225
225
|
? "none of the stripped hashes match current file lines"
|
|
226
226
|
: `${matchedCount} of ${stripped.length} stripped hash(es) match current file lines`;
|
|
227
227
|
warnings.push(
|
|
228
|
-
`Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
|
|
228
|
+
`[E_BARE_HASH_PREFIX] Autocorrected: stripped "HASH│" prefix copied from read output in ${locations} (${evidence}).`
|
|
229
229
|
);
|
|
230
230
|
return { ...edit, content_lines: contentLines };
|
|
231
231
|
}
|
|
@@ -251,7 +251,7 @@ export function stripDiffPrefixes(
|
|
|
251
251
|
if (stripped.length === 0) return edit;
|
|
252
252
|
const locations = stripped.map((i) => `content_lines[${i}]`).join(", ");
|
|
253
253
|
warnings.push(
|
|
254
|
-
`Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
254
|
+
`[E_INVALID_PATCH] Autocorrected: stripped diff-preview marker copied from the diff preview in ${locations}.`
|
|
255
255
|
);
|
|
256
256
|
return { ...edit, content_lines: contentLines };
|
|
257
257
|
}
|
|
@@ -276,7 +276,7 @@ export function swapReversedRanges(
|
|
|
276
276
|
return edit;
|
|
277
277
|
}
|
|
278
278
|
warnings.push(
|
|
279
|
-
`Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
279
|
+
`[E_BAD_OP] Autocorrected: hash_range_inclusive was reversed (start ${startRef.hash} is after end ${endRef.hash}); swapped the pair.`
|
|
280
280
|
);
|
|
281
281
|
return { ...edit, hash_range_inclusive: [endRef, startRef] as [Anchor, Anchor] };
|
|
282
282
|
}
|
package/src/replace-render.ts
CHANGED
package/src/replace-undo.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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, upsertUndo, getUndoEntry, deleteUndo } from "./hash-store";
|
|
5
|
+
import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
|
|
6
6
|
import { contentChecksum } from "./hashline/hasher";
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
@@ -19,9 +19,14 @@ export interface UndoEntry {
|
|
|
19
19
|
resultContent: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function saveUndo(
|
|
22
|
+
export async function saveUndo(
|
|
23
|
+
path: string,
|
|
24
|
+
entry: UndoEntry,
|
|
25
|
+
): Promise<{ persisted: boolean; restore: () => Promise<void> }> {
|
|
26
|
+
let previous: UndoRecord | undefined;
|
|
23
27
|
try {
|
|
24
28
|
const store = await loadHashStore();
|
|
29
|
+
previous = getUndoEntry(store, path);
|
|
25
30
|
upsertUndo(store, path, {
|
|
26
31
|
content: entry.content,
|
|
27
32
|
bom: entry.bom,
|
|
@@ -29,11 +34,22 @@ export async function saveUndo(path: string, entry: UndoEntry): Promise<boolean>
|
|
|
29
34
|
hashes: entry.hashes,
|
|
30
35
|
resultContent: entry.resultContent,
|
|
31
36
|
});
|
|
32
|
-
return true;
|
|
33
37
|
} catch (error) {
|
|
34
38
|
console.error("Failed to persist undo entry:", error);
|
|
35
|
-
return false;
|
|
39
|
+
return { persisted: false, restore: async () => undefined };
|
|
36
40
|
}
|
|
41
|
+
return {
|
|
42
|
+
persisted: true,
|
|
43
|
+
restore: async () => {
|
|
44
|
+
try {
|
|
45
|
+
const store = await loadHashStore();
|
|
46
|
+
if (previous) upsertUndo(store, path, previous);
|
|
47
|
+
else deleteUndo(store, path);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error("Failed to restore previous undo entry:", error);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
@@ -109,6 +125,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
if (currentRaw === undefined) {
|
|
128
|
+
await clearUndo(mutationTargetPath);
|
|
112
129
|
return {
|
|
113
130
|
content: [
|
|
114
131
|
{
|
|
@@ -121,6 +138,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
121
138
|
};
|
|
122
139
|
}
|
|
123
140
|
if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
|
|
141
|
+
await clearUndo(mutationTargetPath);
|
|
124
142
|
return {
|
|
125
143
|
content: [
|
|
126
144
|
{
|
package/src/replace.ts
CHANGED
|
@@ -47,7 +47,7 @@ import { loadHashStore, type HashStore } from "./hash-store";
|
|
|
47
47
|
|
|
48
48
|
const contentLinesSchema = Type.Array(Type.String(), {
|
|
49
49
|
description:
|
|
50
|
-
"Replacement content, one string per line. Use [] to delete the range."
|
|
50
|
+
"Replacement content, one string per line; entries must not contain line breaks. Use [] to delete the range."
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
const hashRangeInclSchema = Type.Array(
|
|
@@ -78,7 +78,6 @@ export type ReplaceDetails = {
|
|
|
78
78
|
firstChangedLine?: number;
|
|
79
79
|
snapshotId?: string;
|
|
80
80
|
classification?: "noop";
|
|
81
|
-
structureOutline?: string[];
|
|
82
81
|
metrics?: RMetrics;
|
|
83
82
|
};
|
|
84
83
|
|
|
@@ -99,6 +98,8 @@ interface PipelineResult {
|
|
|
99
98
|
totalRemovedLines: number;
|
|
100
99
|
}
|
|
101
100
|
|
|
101
|
+
const PREVIEW_DEBOUNCE_MS = 150;
|
|
102
|
+
|
|
102
103
|
const ROOT_KS = new Set(["path", "content_lines", "hash_range_inclusive"]);
|
|
103
104
|
|
|
104
105
|
const LEGACY_KS = ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines", "changes"];
|
|
@@ -318,12 +319,20 @@ export function buildToolDef(): ToolDef {
|
|
|
318
319
|
renderShell: "default",
|
|
319
320
|
renderCall(args, theme, context) {
|
|
320
321
|
const previewInput = getPreviewInput(args);
|
|
322
|
+
const cancelPendingPreview = () => {
|
|
323
|
+
if (context.state.previewTimer) {
|
|
324
|
+
clearTimeout(context.state.previewTimer);
|
|
325
|
+
context.state.previewTimer = undefined;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
321
328
|
if (context.executionStarted) {
|
|
329
|
+
cancelPendingPreview();
|
|
322
330
|
context.state.argsKey = undefined;
|
|
323
331
|
context.state.preview = undefined;
|
|
324
332
|
context.state.previewGeneration =
|
|
325
333
|
(context.state.previewGeneration ?? 0) + 1;
|
|
326
334
|
} else if (!context.argsComplete || !previewInput) {
|
|
335
|
+
cancelPendingPreview();
|
|
327
336
|
context.state.argsKey = undefined;
|
|
328
337
|
context.state.preview = undefined;
|
|
329
338
|
context.state.previewGeneration =
|
|
@@ -331,31 +340,35 @@ export function buildToolDef(): ToolDef {
|
|
|
331
340
|
} else {
|
|
332
341
|
const argsKey = JSON.stringify(previewInput);
|
|
333
342
|
if (context.state.argsKey !== argsKey) {
|
|
343
|
+
cancelPendingPreview();
|
|
334
344
|
context.state.argsKey = argsKey;
|
|
335
345
|
context.state.preview = undefined;
|
|
336
346
|
const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
|
|
337
347
|
context.state.previewGeneration = previewGeneration;
|
|
338
|
-
|
|
339
|
-
.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
348
|
+
context.state.previewTimer = setTimeout(() => {
|
|
349
|
+
context.state.previewTimer = undefined;
|
|
350
|
+
compPreview(args, context.cwd)
|
|
351
|
+
.then((preview) => {
|
|
352
|
+
if (
|
|
353
|
+
context.state.argsKey === argsKey &&
|
|
354
|
+
context.state.previewGeneration === previewGeneration
|
|
355
|
+
) {
|
|
356
|
+
context.state.preview = preview;
|
|
357
|
+
context.invalidate();
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
.catch((err: unknown) => {
|
|
361
|
+
if (
|
|
362
|
+
context.state.argsKey === argsKey &&
|
|
363
|
+
context.state.previewGeneration === previewGeneration
|
|
364
|
+
) {
|
|
365
|
+
context.state.preview = {
|
|
366
|
+
error: err instanceof Error ? err.message : String(err),
|
|
367
|
+
};
|
|
368
|
+
context.invalidate();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}, PREVIEW_DEBOUNCE_MS);
|
|
359
372
|
}
|
|
360
373
|
}
|
|
361
374
|
const text =
|
|
@@ -384,6 +397,10 @@ export function buildToolDef(): ToolDef {
|
|
|
384
397
|
|
|
385
398
|
const renderState = context.state as RRState | undefined;
|
|
386
399
|
if (renderState) {
|
|
400
|
+
if (renderState.previewTimer) {
|
|
401
|
+
clearTimeout(renderState.previewTimer);
|
|
402
|
+
renderState.previewTimer = undefined;
|
|
403
|
+
}
|
|
387
404
|
renderState.preview = undefined;
|
|
388
405
|
renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
|
|
389
406
|
}
|
|
@@ -458,21 +475,27 @@ export function buildToolDef(): ToolDef {
|
|
|
458
475
|
}
|
|
459
476
|
|
|
460
477
|
abortIf(signal);
|
|
461
|
-
await
|
|
462
|
-
absolutePath,
|
|
463
|
-
bom + restoreEndings(result, originalEnding),
|
|
464
|
-
);
|
|
465
|
-
const undoPersisted = await saveUndo(mutationTargetPath, {
|
|
478
|
+
const undo = await saveUndo(mutationTargetPath, {
|
|
466
479
|
content: originalNormalized,
|
|
467
480
|
bom,
|
|
468
481
|
originalEnding,
|
|
469
482
|
hashes: originalHashes,
|
|
470
483
|
resultContent: result,
|
|
471
484
|
});
|
|
472
|
-
if (!
|
|
473
|
-
|
|
474
|
-
|
|
485
|
+
if (!undo.persisted) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`[E_UNDO_UNAVAILABLE] Cannot persist undo history to the hash store; the edit was NOT applied and ${path} is unchanged. Retry the replace, or use write if the store cannot be recovered.`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
abortIf(signal);
|
|
492
|
+
await writeAtomic(
|
|
493
|
+
absolutePath,
|
|
494
|
+
bom + restoreEndings(result, originalEnding),
|
|
475
495
|
);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
await undo.restore();
|
|
498
|
+
throw error;
|
|
476
499
|
}
|
|
477
500
|
const updatedSnapshotId = (await fileSnap(absolutePath))
|
|
478
501
|
.snapshotId;
|
package/src/validation.ts
CHANGED