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 +2 -3
- package/package.json +1 -1
- package/src/file-kind.ts +14 -2
- package/src/hash-store.ts +46 -8
- package/src/replace-diff.ts +6 -1
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.
|
|
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 (
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/replace-diff.ts
CHANGED
|
@@ -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;
|