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 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 or deleted since the last replace or insert, the undo is refused rather than overwriting those changes.
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 or deleted after the last edit. |
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
- const target = await resolveTarget(toCwd(writtenPath, ctx.cwd));
71
- await clearUndo(target);
72
- clearBoundaryBypass(target);
71
+ resolvedPath = await resolveTarget(toCwd(writtenPath, ctx.cwd));
72
+ await clearUndo(resolvedPath);
73
+ clearBoundaryBypass(resolvedPath);
73
74
  const store = await loadHashStore();
74
- clearServed(store, target);
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
- const resolvedPath = await resolveTarget(toCwd(writtenPath, ctx.cwd));
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.0",
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.
@@ -1,6 +1,6 @@
1
1
  import { parseText } from "./hashline/parse";
2
2
  import { ANCHOR_ROW_RE } from "./hashline/resolve";
3
- import { HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE } from "./hashline/hash";
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, HASH_SEP } from "./hashline";
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(`${norm.fileHashes[idx]}${HASH_SEP}${lines[idx]}`);
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
- deleteOne: (...params) => { withBusyRetry(() => { delStmt.run(...params); }); },
273
- upsert: (...params) => { withBusyRetry(() => { upsertStmt.run(...params); }); },
274
- undoUpsert: (...params) => { withBusyRetry(() => { undoUpsertStmt.run(...params); }); },
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: (...params) => { withBusyRetry(() => { undoDelStmt.run(...params); }); },
296
+ undoDelete: retriedWrite(undoDelStmt),
277
297
  servedGet: (...params) => servedGetStmt.get(...params) as Record<string, unknown> | undefined,
278
- servedUpsert: (...params) => { withBusyRetry(() => { servedUpsertStmt.run(...params); }); },
279
- servedDelete: (...params) => { withBusyRetry(() => { servedDelStmt.run(...params); }); },
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
- if (!row) return undefined;
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 = parseHashList(row.hashes as string, () => store.stmts.undoDelete(path));
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
- export function findSnapshotPaths(store: HashStore, hashes: string[]): string[] {
581
- const rows = store.stmts.allHashes() as { path: string; hashes: string }[];
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
+ }
@@ -1,5 +1,5 @@
1
1
  import { abortIf, splitLines } from "../utils";
2
- import { _lineHashesPure, HASH_SEP } from "./hash";
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 function fmtRegion(
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,
@@ -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
  }
@@ -43,5 +43,6 @@ export {
43
43
  buildIdx,
44
44
  applyEdit,
45
45
  fmtRegion,
46
+ fmtRow,
46
47
  changedRange,
47
48
  } from "./apply";
@@ -1,12 +1,11 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
- import { HASH_SEP, HASH_RUN, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
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 match = line.match(HL_BARE_PREFIX_RE);
217
- if (!match) return line;
218
- stripped.push({ lineIndex, matched: fileHashSet.has(match[1]!) });
219
- return line.slice(match[0].length);
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 plus = line.match(HL_PREFIX_PLUS_RE);
243
- if (plus) {
244
- stripped.push(lineIndex);
245
- return line.slice(plus[0].length);
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, firstMismatchLine: number, rangeHashes: 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(`${hash}${HASH_SEP}${fileLines[line - 1]}`);
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, first, shownHashes);
563
+ throw new RangeStaleError(message, shownHashes);
562
564
  }
563
565
 
564
566
  export { warnUnicodeEsc };
@@ -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: NEditEntry | undefined;
33
+ noopEdit: NEdit | undefined;
38
34
  snapshotId?: string;
39
35
  editMeta: RMeta;
40
36
  warnings: string[] | undefined;
@@ -126,26 +126,15 @@ export function regUndo(pi: ExtensionAPI): void {
126
126
  if (errCode(error) !== "ENOENT") throw error;
127
127
  }
128
128
 
129
- if (currentRaw === undefined) {
130
- await clearUndo(mutationTargetPath);
131
- return {
132
- content: [
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
- const { text: currentStripped } = stripBOM(currentRaw);
157
- const currentNormalized = toLF(currentStripped);
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, currentNormalized, 0, undefined, undo.hashes);
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, parseHashList, type HashStore } from "./hash-store";
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
- if (!row) return undefined;
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
  }