pi-hashline-edit-pro 2.5.0 → 2.5.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
@@ -63,8 +63,8 @@ Lines up to 200KB are shown in full. Larger lines are replaced by a marker with
63
63
 
64
64
  Edge cases:
65
65
 
66
- - Images (JPEG, PNG, GIF, WebP) come back as visual attachments.
67
- - Binary files and directories are rejected with a descriptive error.
66
+ - Images (JPEG, PNG, GIF, WebP, BMP) come back as visual attachments. Other image formats (for example AVIF, HEIC/HEIF, TIFF, ICO, JPEG 2000, JPEG XL, PSD, APNG) are rejected as binary, since the built-in renderer cannot attach them.
67
+ - Binary files and directories are rejected with a descriptive error. A magic-signature match is ignored when the sampled bytes contain no NUL bytes and decode as UTF-8, so a text file whose first bytes happen to match a binary or image signature (for example starting with `BM` or `8BPS`) is still read as text.
68
68
  - UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
69
69
  - Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
70
70
  - 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.
@@ -114,7 +114,6 @@ Notes:
114
114
 
115
115
  Enabled by default. After a successful `write` that changes the file, the extension reads the file and appends an `--- Auto-read (hashline anchors) ---` block to the result, so you get fresh `HASH│content` anchors without a separate `read` call.
116
116
 
117
- - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. Call `read` when you want the full file's anchors.
118
117
  - After `replace` and `undo_last_replace`, the result shows the post-edit diff. The `+HASH│` and ` HASH│` rows carry the current hashes, so follow-up edits can anchor on the diff directly. The `-HASH│` rows show removed lines with their old hashes, so you can see exactly which anchors were deleted (those hashes are stale after the edit). Call `read` when you want the full file's anchors.
119
118
  - Auto-read keeps a 50KB display budget. Lines over 50KB are skipped with a marker instead of their content (use `read` for lines up to 200KB).
120
119
  - Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/undo 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",
package/src/file-kind.ts CHANGED
@@ -3,9 +3,10 @@ import { fileTypeFromBuffer } from "file-type";
3
3
  import { SNIFF_BYTES, MAX_BYTES } from "./constants";
4
4
 
5
5
  const IMG_TYPES = new Set<string>([
6
+ "image/bmp",
7
+ "image/gif",
6
8
  "image/jpeg",
7
9
  "image/png",
8
- "image/gif",
9
10
  "image/webp",
10
11
  ]);
11
12
 
@@ -39,6 +40,16 @@ function isTextType(mimeType: string): boolean {
39
40
  return mimeType.startsWith("text/") || TEXT_TYPES.has(mimeType);
40
41
  }
41
42
 
43
+ function looksLikeText(sample: Uint8Array): boolean {
44
+ if (sample.includes(0)) return false;
45
+ try {
46
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
42
53
  export type LFile =
43
54
  | { kind: "directory" }
44
55
  | { kind: "image"; mimeType: string }
@@ -96,7 +107,8 @@ export async function loadFileKindAndText(
96
107
  const detectedMimeType = (await fileTypeFromBuffer(sample))?.mime;
97
108
  if (
98
109
  detectedMimeType !== undefined &&
99
- !isTextType(detectedMimeType)
110
+ !isTextType(detectedMimeType) &&
111
+ !looksLikeText(sample)
100
112
  ) {
101
113
  if (IMG_TYPES.has(detectedMimeType)) {
102
114
  return { kind: "image", mimeType: detectedMimeType };
package/src/hash-store.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from "fs";
2
2
  import { readFile, rename, mkdir, stat } from "fs/promises";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
5
- import { errCode, splitLines } from "./utils";
5
+ import { errCode, isRec, splitLines } from "./utils";
6
6
  import { initHasher, contentChecksum } from "./hashline/hasher";
7
7
  import { HASH_RE } from "./hashline/alphabet";
8
8
  import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
@@ -45,6 +45,7 @@ export function isValidHashList(value: unknown): value is string[] {
45
45
  for (const hash of value) {
46
46
  if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
47
47
  }
48
+ if (new Set(value).size !== value.length) return false;
48
49
  return true;
49
50
  }
50
51
 
@@ -107,6 +108,13 @@ function openDbWithBusyRetry(storePath: string): { db: DatabaseSync; stmts: Prep
107
108
  let cachedDb: { path: string; db: DatabaseSync; stmts: Prepared } | null = null;
108
109
  let opening: { path: string; promise: Promise<HashStore> } | null = null;
109
110
  let exitHandlerRegistered = false;
111
+ interface SnapshotCacheEntry {
112
+ checksum: string;
113
+ lineCount: number;
114
+ hashes: string[];
115
+ }
116
+ const snapshotCache = new Map<string, SnapshotCacheEntry>();
117
+ export const SNAPSHOT_CACHE_LIMIT = 256;
110
118
  function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
111
119
  const db = new DatabaseSync(storePath, {
112
120
  timeout: HASH_STORE_BUSY_TIMEOUT,
@@ -301,6 +309,7 @@ export function shutdownHashStore(): void {
301
309
  shutdownDb(cachedDb.db);
302
310
  cachedDb = null;
303
311
  }
312
+ snapshotCache.clear();
304
313
  }
305
314
 
306
315
  function withStore(fn: () => void): void {
@@ -344,11 +353,17 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
344
353
 
345
354
  const rows: [string, string, number, string, number][] = [];
346
355
  for (const [key, value] of Object.entries(raw)) {
347
- if (!isValidSnapshot(value)) continue;
348
- if (new Set(value.hashes).size !== value.hashes.length) {
349
- console.warn(`Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`);
356
+ if (
357
+ isRec(value) &&
358
+ Array.isArray(value.hashes) &&
359
+ new Set(value.hashes).size !== value.hashes.length
360
+ ) {
361
+ console.warn(
362
+ `Skipped legacy snapshot with duplicate hashes for ${key}; it will be re-hashed on next read.`,
363
+ );
350
364
  continue;
351
365
  }
366
+ if (!isValidSnapshot(value)) continue;
352
367
  rows.push([
353
368
  key,
354
369
  contentChecksum(value.content),
@@ -378,6 +393,15 @@ async function migrateLegacy(db: DatabaseSync): Promise<void> {
378
393
  }
379
394
  }
380
395
 
396
+ function cacheSnapshot(path: string, checksum: string, lineCount: number, hashes: string[]): void {
397
+ snapshotCache.delete(path);
398
+ snapshotCache.set(path, { checksum, lineCount, hashes: hashes.slice() });
399
+ if (snapshotCache.size > SNAPSHOT_CACHE_LIMIT) {
400
+ const oldest = snapshotCache.keys().next().value;
401
+ if (oldest !== undefined) snapshotCache.delete(oldest);
402
+ }
403
+ }
404
+
381
405
  export function getSnapshot(
382
406
  store: HashStore,
383
407
  path: string,
@@ -386,17 +410,29 @@ export function getSnapshot(
386
410
  ): string[] | undefined {
387
411
  const checksum = contentChecksum(content);
388
412
  const lineCount = splitLines(content).length;
413
+ const cached = snapshotCache.get(path);
414
+ if (cached && cached.checksum === checksum && cached.lineCount === lineCount) {
415
+ snapshotCache.delete(path);
416
+ snapshotCache.set(path, cached);
417
+ return cached.hashes.slice();
418
+ }
389
419
  const row = store.stmts.get(path, checksum, lineCount);
390
420
  if (!row) return undefined;
421
+ let parsed: unknown;
391
422
  try {
392
- const parsed = JSON.parse(row.hashes as string);
393
- if (isValidHashList(parsed)) return parsed;
394
- if (deleteCorrupt) store.stmts.deleteOne(path);
395
- return undefined;
423
+ parsed = JSON.parse(row.hashes as string);
396
424
  } catch {
397
425
  if (deleteCorrupt) store.stmts.deleteOne(path);
426
+ snapshotCache.delete(path);
398
427
  return undefined;
399
428
  }
429
+ if (isValidHashList(parsed)) {
430
+ cacheSnapshot(path, checksum, lineCount, parsed);
431
+ return parsed;
432
+ }
433
+ if (deleteCorrupt) store.stmts.deleteOne(path);
434
+ snapshotCache.delete(path);
435
+ return undefined;
400
436
  }
401
437
 
402
438
  export function upsertSnapshot(
@@ -407,6 +443,7 @@ export function upsertSnapshot(
407
443
  hashes: string[],
408
444
  ): void {
409
445
  store.stmts.upsert(path, checksum, lineCount, JSON.stringify(hashes), Date.now());
446
+ cacheSnapshot(path, checksum, lineCount, hashes);
410
447
  }
411
448
 
412
449
  export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): void {
@@ -477,6 +514,7 @@ export async function pruneMissing(store: HashStore): Promise<void> {
477
514
  withStore(() => {
478
515
  for (const path of missing) {
479
516
  store.stmts.deleteOne(path);
517
+ snapshotCache.delete(path);
480
518
  store.stmts.undoDelete(path);
481
519
  store.stmts.servedDelete(path);
482
520
  }
@@ -96,6 +96,7 @@ export function genDiff(
96
96
  let linesToShow: (string | symbol)[] = displayLines;
97
97
  let skipStart = 0;
98
98
  let skipMiddle = 0;
99
+ let skipTail = 0;
99
100
 
100
101
  if (!lastWasChange) {
101
102
  skipStart = Math.max(0, displayLines.length - contextLines);
@@ -104,8 +105,9 @@ export function genDiff(
104
105
  const tail = displayLines.slice(-contextLines);
105
106
  linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
106
107
  skipMiddle = displayLines.length - contextLines * 2;
107
- } else if (linesToShow.length > contextLines) {
108
+ } else if (!nextPartIsChange && linesToShow.length > contextLines) {
108
109
  linesToShow = linesToShow.slice(0, contextLines);
110
+ skipTail = displayLines.length - contextLines;
109
111
  }
110
112
 
111
113
  if (skipStart > 0) {
@@ -125,6 +127,9 @@ export function genDiff(
125
127
  newLineNum++;
126
128
  oldLineNum++;
127
129
  }
130
+ if (skipTail > 0) {
131
+ output.push(" ...");
132
+ }
128
133
  } else {
129
134
  newLineNum += displayLines.length;
130
135
  oldLineNum += displayLines.length;