pi-hashline-edit-pro 0.16.10 → 0.16.12
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/index.ts +20 -32
- package/package.json +1 -1
- package/prompts/read.md +2 -0
- package/prompts/replace-guidelines.md +6 -0
- package/prompts/replace-snippet.md +1 -0
- package/prompts/replace.md +33 -0
- package/src/config.ts +9 -50
- package/src/constants.ts +5 -0
- package/src/file-reader.ts +28 -5
- package/src/fs-write.ts +21 -9
- package/src/hash-store.ts +21 -6
- package/src/hashline/apply.ts +6 -6
- package/src/hashline/hash.ts +23 -21
- package/src/hashline/parse.ts +2 -4
- package/src/hashline/resolve.ts +71 -72
- package/src/paths.ts +12 -1
- package/src/read.ts +3 -3
- package/src/replace-normalize.ts +6 -9
- package/src/replace-undo.ts +21 -2
- package/src/replace.ts +64 -15
- package/src/utils.ts +11 -3
- package/src/validation.ts +1 -9
- package/prompts/replace-bulk-guidelines.md +0 -6
- package/prompts/replace-bulk-snippet.md +0 -1
- package/prompts/replace-bulk.md +0 -105
- package/prompts/replace-flat-guidelines.md +0 -6
- package/prompts/replace-flat-snippet.md +0 -1
- package/prompts/replace-flat.md +0 -95
- package/src/path-utils.ts +0 -13
- package/src/replace-flat.ts +0 -17
- package/src/runtime.ts +0 -3
- package/src/snapshot.ts +0 -22
- package/src/undo-store.ts +0 -21
package/index.ts
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { readFile } from "fs/promises";
|
|
3
|
-
import { join, isAbsolute } from "path";
|
|
4
2
|
import { initHasher } from "./src/hashline";
|
|
5
|
-
import { regReplace } from "./src/replace";
|
|
6
|
-
import { regReplaceFlat } from "./src/replace-flat";
|
|
3
|
+
import { regReplace, regReplaceFlat } from "./src/replace";
|
|
7
4
|
import { regReplaceUndo } from "./src/replace-undo";
|
|
8
5
|
import { regRead, fmtReadPreview } from "./src/read";
|
|
9
|
-
import { toLF, stripBOM } from "./src/replace-diff";
|
|
10
|
-
import { resolveTarget } from "./src/fs-write";
|
|
11
6
|
import { visLines } from "./src/utils";
|
|
12
7
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
13
8
|
import {
|
|
14
|
-
|
|
9
|
+
readConfig,
|
|
15
10
|
toggleReplaceMode,
|
|
16
|
-
readAutoRead,
|
|
17
11
|
toggleAutoRead,
|
|
18
12
|
} from "./src/config";
|
|
19
13
|
import { loadHashStore, pruneHashStore } from "./src/hash-store";
|
|
14
|
+
import { readNormFile } from "./src/file-reader";
|
|
20
15
|
|
|
21
16
|
export default function (pi: ExtensionAPI): void {
|
|
22
17
|
regRead(pi);
|
|
23
18
|
|
|
24
19
|
regReplace(pi);
|
|
25
20
|
regReplaceUndo(pi);
|
|
21
|
+
|
|
22
|
+
function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean): void {
|
|
23
|
+
if (mode === "flat") {
|
|
24
|
+
regReplaceFlat(pi, autoRead);
|
|
25
|
+
} else {
|
|
26
|
+
regReplace(pi, autoRead);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
26
29
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
27
30
|
const autoReadValue = process.env.PI_HASHLINE_AUTO_READ;
|
|
28
31
|
let autoRead = autoReadValue === "1" || autoReadValue === "true";
|
|
@@ -37,14 +40,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
37
40
|
} catch (err) {
|
|
38
41
|
console.error("Failed to load or prune hash store:", err);
|
|
39
42
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
regReplace(pi);
|
|
45
|
-
}
|
|
43
|
+
const config = await readConfig();
|
|
44
|
+
const mode = config.replaceMode;
|
|
45
|
+
autoRead = config.autoRead;
|
|
46
|
+
registerReplaceTool(pi, mode, autoRead);
|
|
46
47
|
|
|
47
|
-
autoRead = await readAutoRead();
|
|
48
48
|
|
|
49
49
|
if (debugValue === "1" || debugValue === "true") {
|
|
50
50
|
ctx.ui.notify(`Hashline Edit mode active (${mode} replace)`, "info");
|
|
@@ -55,11 +55,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
55
55
|
description: "Toggle replace tool between bulk (changes array) and flat (single edit at top level) mode",
|
|
56
56
|
handler: async (_args, ctx) => {
|
|
57
57
|
const mode = await toggleReplaceMode();
|
|
58
|
-
|
|
59
|
-
regReplaceFlat(pi);
|
|
60
|
-
} else {
|
|
61
|
-
regReplace(pi);
|
|
62
|
-
}
|
|
58
|
+
registerReplaceTool(pi, mode, autoRead);
|
|
63
59
|
ctx.ui.notify(`Replace mode switched to: ${mode}`, "info");
|
|
64
60
|
},
|
|
65
61
|
});
|
|
@@ -68,12 +64,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
68
64
|
description: "Toggle automatic hashline anchors after write and replace operations",
|
|
69
65
|
handler: async (_args, ctx) => {
|
|
70
66
|
autoRead = await toggleAutoRead();
|
|
71
|
-
const mode = await
|
|
72
|
-
|
|
73
|
-
regReplaceFlat(pi);
|
|
74
|
-
} else {
|
|
75
|
-
regReplace(pi);
|
|
76
|
-
}
|
|
67
|
+
const mode = (await readConfig()).replaceMode;
|
|
68
|
+
registerReplaceTool(pi, mode, autoRead);
|
|
77
69
|
const state = autoRead ? "enabled" : "disabled";
|
|
78
70
|
ctx.ui.notify(`Auto-read after write/replace: ${state}`, "info");
|
|
79
71
|
},
|
|
@@ -88,15 +80,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
88
80
|
if (typeof filePath !== "string") return;
|
|
89
81
|
|
|
90
82
|
try {
|
|
91
|
-
const absolutePath =
|
|
92
|
-
const resolvedPath = await resolveTarget(absolutePath);
|
|
93
|
-
const content = await readFile(resolvedPath, "utf-8");
|
|
94
|
-
const { text: rawContent } = stripBOM(content);
|
|
95
|
-
const normalized = toLF(rawContent);
|
|
83
|
+
const { normalized, fileHashes, absolutePath } = await readNormFile(filePath, ctx.cwd, undefined);
|
|
96
84
|
|
|
97
85
|
if (visLines(normalized).length === 0) return;
|
|
98
86
|
|
|
99
|
-
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX },
|
|
87
|
+
const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
|
|
100
88
|
|
|
101
89
|
return {
|
|
102
90
|
content: [
|
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Read a text file. Each line is returned as `HASH│content`.
|
|
2
2
|
|
|
3
|
+
Key rule: line numbers are NOT part of the output. Use the 3-char HASH to reference lines in replace calls. A HASH may change when the line content changes.
|
|
4
|
+
|
|
3
5
|
HASH format:
|
|
4
6
|
- The HASH is 3 characters from the URL-safe base64 alphabet `A-Za-z0-9-_` (e.g. `aB3`, `4yN`, `-qk`).
|
|
5
7
|
- The content after the `│` separator is the line verbatim.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{{MODE_PREFIX}}
|
|
2
|
+
- After a successful replace, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
3
|
+
- On [E_STALE_ANCHOR], call read to get fresh anchors, copy the 3-character HASH of the start and end of the range into hash_range_inclusive, and retry.
|
|
4
|
+
- hash_range_inclusive replaces the ENTIRE range inclusively. Every line from the first anchor through the second anchor is deleted. Only put replacement lines in content_lines — do not include lines that already exist outside the range.
|
|
5
|
+
- Preserve leading whitespace exactly. The content after │ in read output includes all leading spaces and tabs — copy them into content_lines unchanged.
|
|
6
|
+
- content_lines must be a native JSON array of strings, not a JSON string.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Replace lines in a text file via HASH anchors from read, {{MODE_PREFIX}}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Replace lines in a text file using HASH anchors from `read`.{{MODE_DESCRIPTION}}
|
|
2
|
+
|
|
3
|
+
Workflow:
|
|
4
|
+
1. Call `read` to get `HASH│content` lines for the file.
|
|
5
|
+
2. Identify the range to replace using the 3-char hashes from the read output.
|
|
6
|
+
3. Call `replace` with `hash_range_inclusive` (the two hashes) and `content_lines` (the new lines).
|
|
7
|
+
4. On `[E_STALE_ANCHOR]`, call `read` again for fresh anchors, then retry.
|
|
8
|
+
5. On success, call `read` to get fresh anchors for follow-up edits.
|
|
9
|
+
|
|
10
|
+
Request structure:
|
|
11
|
+
{{MODE_REQUEST_STRUCTURE}}
|
|
12
|
+
|
|
13
|
+
Fields:
|
|
14
|
+
- content_lines — replacement lines as a JSON array of strings. File content only — no HASH│ prefix.
|
|
15
|
+
- hash_range_inclusive — [start_hash, end_hash] from read output. 3-char base64 hash only — no │ or line content.
|
|
16
|
+
- path — file to edit.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
{{MODE_EXAMPLES}}
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
- Anchors must be 3-char base64 hashes from the most recent read. Stale anchors fail with [E_STALE_ANCHOR].
|
|
23
|
+
- The range is inclusive: every line from start_hash through end_hash is deleted.
|
|
24
|
+
- Those lines are replaced with content_lines — nothing is inserted, nothing is appended.
|
|
25
|
+
- content_lines is literal file content. Never include the HASH│ prefix — that goes in hash_range_inclusive.
|
|
26
|
+
- content_lines must be a native JSON array of strings, not a serialized string.
|
|
27
|
+
- Preserve leading whitespace exactly as it appears after │ in read output.
|
|
28
|
+
- To delete lines, use content_lines: [].
|
|
29
|
+
- If content_lines matches current content, the edit is a noop (file unchanged).
|
|
30
|
+
{{MODE_RULES}}
|
|
31
|
+
On success, the response shows the change summary. {{AUTO_READ_GUIDANCE}}
|
|
32
|
+
|
|
33
|
+
Recovery: If a replace produces incorrect results, call undo_last_replace with the file path to revert.
|
package/src/config.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { readFileSync } from "fs";
|
|
2
1
|
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
3
2
|
import { configDir, configPath } from "./paths";
|
|
4
|
-
import { errCode } from "./
|
|
3
|
+
import { errCode } from "./utils";
|
|
5
4
|
|
|
6
5
|
export type ReplaceMode = "bulk" | "flat";
|
|
7
|
-
|
|
8
6
|
export interface Config {
|
|
9
7
|
replaceMode: ReplaceMode;
|
|
10
8
|
autoRead: boolean;
|
|
@@ -15,14 +13,17 @@ const DEFAULT_CONFIG: Config = {
|
|
|
15
13
|
autoRead: false
|
|
16
14
|
};
|
|
17
15
|
|
|
16
|
+
function parseConfig(content: string): Config {
|
|
17
|
+
const parsed = JSON.parse(content) as Partial<Config>;
|
|
18
|
+
return {
|
|
19
|
+
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
20
|
+
autoRead: parsed.autoRead === true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
18
23
|
export async function readConfig(): Promise<Config> {
|
|
19
24
|
try {
|
|
20
25
|
const content = await readFile(configPath(), "utf-8");
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
24
|
-
autoRead: parsed.autoRead === true,
|
|
25
|
-
};
|
|
26
|
+
return parseConfig(content);
|
|
26
27
|
} catch (error: unknown) {
|
|
27
28
|
if (errCode(error) !== "ENOENT") {
|
|
28
29
|
console.error("Config file corrupted, using defaults:", error);
|
|
@@ -30,38 +31,11 @@ export async function readConfig(): Promise<Config> {
|
|
|
30
31
|
return { ...DEFAULT_CONFIG };
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
export function readConfigSync(): Config {
|
|
35
|
-
try {
|
|
36
|
-
const content = readFileSync(configPath(), "utf-8");
|
|
37
|
-
const parsed = JSON.parse(content) as Partial<Config>;
|
|
38
|
-
return {
|
|
39
|
-
replaceMode: parsed.replaceMode === "flat" ? "flat" : "bulk",
|
|
40
|
-
autoRead: parsed.autoRead === true,
|
|
41
|
-
};
|
|
42
|
-
} catch (error: unknown) {
|
|
43
|
-
if (errCode(error) !== "ENOENT") {
|
|
44
|
-
console.error("Config file corrupted, using defaults:", error);
|
|
45
|
-
}
|
|
46
|
-
return { ...DEFAULT_CONFIG };
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
34
|
export async function writeConfig(config: Config): Promise<void> {
|
|
51
35
|
await mkdir(configDir(), { recursive: true });
|
|
52
36
|
await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
53
37
|
}
|
|
54
38
|
|
|
55
|
-
export async function readReplaceMode(): Promise<ReplaceMode> {
|
|
56
|
-
const config = await readConfig();
|
|
57
|
-
return config.replaceMode;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function writeReplaceMode(mode: ReplaceMode): Promise<void> {
|
|
61
|
-
const config = await readConfig();
|
|
62
|
-
config.replaceMode = mode;
|
|
63
|
-
await writeConfig(config);
|
|
64
|
-
}
|
|
65
39
|
|
|
66
40
|
export async function toggleReplaceMode(): Promise<ReplaceMode> {
|
|
67
41
|
const config = await readConfig();
|
|
@@ -70,21 +44,6 @@ export async function toggleReplaceMode(): Promise<ReplaceMode> {
|
|
|
70
44
|
return config.replaceMode;
|
|
71
45
|
}
|
|
72
46
|
|
|
73
|
-
export async function readAutoRead(): Promise<boolean> {
|
|
74
|
-
const config = await readConfig();
|
|
75
|
-
return config.autoRead;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function readAutoReadSync(): boolean {
|
|
79
|
-
const config = readConfigSync();
|
|
80
|
-
return config.autoRead;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function writeAutoRead(value: boolean): Promise<void> {
|
|
84
|
-
const config = await readConfig();
|
|
85
|
-
config.autoRead = value;
|
|
86
|
-
await writeConfig(config);
|
|
87
|
-
}
|
|
88
47
|
|
|
89
48
|
export async function toggleAutoRead(): Promise<boolean> {
|
|
90
49
|
const config = await readConfig();
|
package/src/constants.ts
CHANGED
|
@@ -3,3 +3,8 @@ export const SNIFF_BYTES = 8192;
|
|
|
3
3
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
4
4
|
|
|
5
5
|
export const MAX_HASH_LINES = 1_000_000;
|
|
6
|
+
export const MAX_HASH_RETRIES = 262_144;
|
|
7
|
+
|
|
8
|
+
export const CONTENT_LINES_NOT_STRING_MSG =
|
|
9
|
+
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
10
|
+
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
|
package/src/file-reader.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { constants } from "fs";
|
|
2
|
+
import { stat } from "fs/promises";
|
|
2
3
|
import { lineHashes } from "./hashline";
|
|
3
4
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
4
5
|
import { resolveTarget } from "./fs-write";
|
|
5
|
-
import { toCwd } from "./
|
|
6
|
+
import { toCwd } from "./paths";
|
|
6
7
|
import { detectEnding, toLF, stripBOM } from "./replace-diff";
|
|
7
|
-
import { abortIf } from "./
|
|
8
|
-
import {
|
|
8
|
+
import { abortIf } from "./utils";
|
|
9
|
+
import { valKind, valAccess } from "./validation";
|
|
9
10
|
import { visLines } from "./utils";
|
|
11
|
+
import type { HashStore } from "./hash-store";
|
|
10
12
|
export interface NormFile {
|
|
11
13
|
absolutePath: string;
|
|
12
14
|
normalized: string;
|
|
@@ -16,6 +18,26 @@ export interface NormFile {
|
|
|
16
18
|
hadUtf8DecodeErrors: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
export type SnapInfo = {
|
|
22
|
+
snapshotId: string;
|
|
23
|
+
mtimeMs: number;
|
|
24
|
+
size: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function fmtSnapId(canonicalPath: string, info: { mtimeMs: number; size: number }): string {
|
|
28
|
+
return `v1|${canonicalPath}|${info.mtimeMs}|${info.size}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function fileSnap(absolutePath: string): Promise<SnapInfo> {
|
|
32
|
+
const canonicalPath = await resolveTarget(absolutePath);
|
|
33
|
+
const stats = await stat(canonicalPath);
|
|
34
|
+
return {
|
|
35
|
+
snapshotId: fmtSnapId(canonicalPath, stats),
|
|
36
|
+
mtimeMs: stats.mtimeMs,
|
|
37
|
+
size: stats.size,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
export async function readNormFile(
|
|
20
42
|
path: string,
|
|
21
43
|
cwd: string,
|
|
@@ -23,6 +45,7 @@ export async function readNormFile(
|
|
|
23
45
|
accessMode: number = constants.R_OK,
|
|
24
46
|
preloadedFile?: LFile,
|
|
25
47
|
maxLines?: number,
|
|
48
|
+
store?: HashStore,
|
|
26
49
|
): Promise<NormFile> {
|
|
27
50
|
const absolutePath = toCwd(path, cwd);
|
|
28
51
|
const resolvedPath = await resolveTarget(absolutePath);
|
|
@@ -32,7 +55,7 @@ export async function readNormFile(
|
|
|
32
55
|
|
|
33
56
|
abortIf(signal);
|
|
34
57
|
const file = preloadedFile ?? (await loadFileKindAndText(resolvedPath));
|
|
35
|
-
|
|
58
|
+
valKind(file, path);
|
|
36
59
|
|
|
37
60
|
abortIf(signal);
|
|
38
61
|
const { bom, text: rawContent } = stripBOM(file.text);
|
|
@@ -48,7 +71,7 @@ export async function readNormFile(
|
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
|
|
51
|
-
const fileHashes = await lineHashes(normalized, resolvedPath);
|
|
74
|
+
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, store);
|
|
52
75
|
return {
|
|
53
76
|
absolutePath: resolvedPath,
|
|
54
77
|
normalized,
|
package/src/fs-write.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readlink,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
copyFile,
|
|
11
12
|
} from "fs/promises";
|
|
12
13
|
import { dirname, join, parse, resolve, sep } from "path";
|
|
13
|
-
import { errCode } from "./
|
|
14
|
+
import { errCode } from "./utils";
|
|
14
15
|
|
|
15
16
|
export async function resolveTarget(path: string): Promise<string> {
|
|
16
17
|
const absolutePath = resolve(path);
|
|
@@ -108,6 +109,17 @@ export async function writeAtomic(
|
|
|
108
109
|
await tempHandle.close();
|
|
109
110
|
await rename(tempPath, targetPath);
|
|
110
111
|
} catch (error: unknown) {
|
|
112
|
+
if (errCode(error) === "EXDEV") {
|
|
113
|
+
try {
|
|
114
|
+
await tempHandle.close();
|
|
115
|
+
await copyFile(tempPath, targetPath);
|
|
116
|
+
await rm(tempPath, { force: true });
|
|
117
|
+
return;
|
|
118
|
+
} catch {
|
|
119
|
+
try { await rm(tempPath, { force: true }); } catch {}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
111
123
|
try { await rm(tempPath, { force: true }); } catch {}
|
|
112
124
|
throw error;
|
|
113
125
|
}
|
package/src/hash-store.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { readFile, mkdir } from "fs/promises";
|
|
2
2
|
import { stat } from "fs/promises";
|
|
3
3
|
import { hashStorePath, hashStoreDir } from "./paths";
|
|
4
|
-
import { errCode } from "./
|
|
4
|
+
import { errCode } from "./utils";
|
|
5
5
|
import { writeAtomic } from "./fs-write";
|
|
6
|
-
|
|
6
|
+
function isValidSnapshot(value: unknown): value is FileSnapshot {
|
|
7
|
+
if (typeof value !== "object" || value === null) return false;
|
|
8
|
+
const v = value as Record<string, unknown>;
|
|
9
|
+
if (typeof v.content !== "string") return false;
|
|
10
|
+
if (!Array.isArray(v.hashes)) return false;
|
|
11
|
+
for (const h of v.hashes) {
|
|
12
|
+
if (typeof h !== "string") return false;
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
7
16
|
export interface FileSnapshot {
|
|
8
17
|
content: string;
|
|
9
18
|
hashes: string[];
|
|
@@ -18,10 +27,16 @@ export async function loadHashStore(): Promise<HashStore> {
|
|
|
18
27
|
try {
|
|
19
28
|
const content = await readFile(hashStorePath(), "utf-8");
|
|
20
29
|
const parsed = JSON.parse(content) as Partial<HashStore>;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
const raw = parsed.snapshots;
|
|
31
|
+
const snapshots: Record<string, FileSnapshot> = {};
|
|
32
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
33
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
34
|
+
if (isValidSnapshot(value)) {
|
|
35
|
+
snapshots[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { version: 1, snapshots };
|
|
25
40
|
} catch (error: unknown) {
|
|
26
41
|
if (errCode(error) !== "ENOENT") {
|
|
27
42
|
console.error("Hash store corrupted, creating fresh store:", error);
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { abortIf } from "../
|
|
1
|
+
import { abortIf } from "../utils";
|
|
2
2
|
import { _lineHashesPure, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdits,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type BDupWarn,
|
|
13
13
|
type AutoFix,
|
|
14
14
|
} from "./resolve";
|
|
15
|
-
import {
|
|
15
|
+
import { visLines } from "../utils";
|
|
16
16
|
|
|
17
17
|
type LIdx = {
|
|
18
18
|
fileLines: string[];
|
|
@@ -377,14 +377,14 @@ export function changedRange(
|
|
|
377
377
|
if (original.length === 0) {
|
|
378
378
|
return {
|
|
379
379
|
firstChangedLine: 1,
|
|
380
|
-
lastChangedLine:
|
|
380
|
+
lastChangedLine: visLines(result).length,
|
|
381
381
|
};
|
|
382
382
|
}
|
|
383
383
|
|
|
384
384
|
if (result.startsWith(original) && original.endsWith("\n")) {
|
|
385
385
|
return {
|
|
386
|
-
firstChangedLine:
|
|
387
|
-
lastChangedLine:
|
|
386
|
+
firstChangedLine: visLines(original).length + 1,
|
|
387
|
+
lastChangedLine: visLines(result).length,
|
|
388
388
|
};
|
|
389
389
|
}
|
|
390
390
|
|
|
@@ -417,7 +417,7 @@ export function changedRange(
|
|
|
417
417
|
const firstChangedLine = idxToLine(firstDiff + 1, result);
|
|
418
418
|
let lastChangedLine: number;
|
|
419
419
|
if (lastRes < firstDiff) {
|
|
420
|
-
lastChangedLine = result.length === 0 ? 1 :
|
|
420
|
+
lastChangedLine = result.length === 0 ? 1 : visLines(result).length;
|
|
421
421
|
} else if (
|
|
422
422
|
firstDiff === 0 &&
|
|
423
423
|
original.length > 0 &&
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import xxhash from "xxhash-wasm";
|
|
2
2
|
import * as Diff from "diff";
|
|
3
|
-
import { loadHashStore, saveHashStore } from "../hash-store";
|
|
3
|
+
import { loadHashStore, saveHashStore, type HashStore } from "../hash-store";
|
|
4
|
+
import { MAX_HASH_RETRIES } from "../constants";
|
|
4
5
|
|
|
5
6
|
export const HASH_LEN = 3;
|
|
6
7
|
export const ANCHOR_LEN = HASH_LEN;
|
|
@@ -69,19 +70,25 @@ function canon(line: string): string {
|
|
|
69
70
|
return line.replace(/\r/g, "").trimEnd();
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function nextUniqueHash(content: string, used: Set<string>): string {
|
|
74
|
+
let retry = 0;
|
|
75
|
+
let hash = h2s(xxh32(content));
|
|
76
|
+
while (used.has(hash)) {
|
|
77
|
+
retry++;
|
|
78
|
+
if (retry > MAX_HASH_RETRIES) throw new Error("Hash space exhausted");
|
|
79
|
+
hash = h2s(xxh32(`${content}:R${retry}`));
|
|
80
|
+
}
|
|
81
|
+
used.add(hash);
|
|
82
|
+
return hash;
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
export function _lineHashesPure(content: string): string[] {
|
|
73
86
|
const lines = content.split("\n");
|
|
74
87
|
const hashes = new Array<string>(lines.length);
|
|
75
88
|
const assigned = new Set<string>();
|
|
76
89
|
for (let i = 0; i < lines.length; i++) {
|
|
77
90
|
const c = canon(lines[i]!);
|
|
78
|
-
|
|
79
|
-
let retry = 0;
|
|
80
|
-
while (assigned.has(hash)) {
|
|
81
|
-
retry++;
|
|
82
|
-
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
83
|
-
}
|
|
84
|
-
assigned.add(hash);
|
|
91
|
+
const hash = nextUniqueHash(c, assigned);
|
|
85
92
|
hashes[i] = hash;
|
|
86
93
|
}
|
|
87
94
|
return hashes;
|
|
@@ -91,12 +98,13 @@ export async function lineHashes(
|
|
|
91
98
|
content: string,
|
|
92
99
|
path?: string,
|
|
93
100
|
previous?: { content: string; hashes: string[]; removedHashes?: Set<string> },
|
|
101
|
+
store?: HashStore,
|
|
94
102
|
): Promise<string[]> {
|
|
95
103
|
if (!path) {
|
|
96
104
|
return _lineHashesPure(content);
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
const
|
|
107
|
+
const hashStore = store ?? await loadHashStore();
|
|
100
108
|
|
|
101
109
|
if (previous) {
|
|
102
110
|
const newHashes = mapStableHashes(
|
|
@@ -104,19 +112,19 @@ export async function lineHashes(
|
|
|
104
112
|
content,
|
|
105
113
|
previous.removedHashes,
|
|
106
114
|
);
|
|
107
|
-
|
|
108
|
-
await saveHashStore(
|
|
115
|
+
hashStore.snapshots[path] = { content, hashes: newHashes };
|
|
116
|
+
await saveHashStore(hashStore);
|
|
109
117
|
return newHashes;
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
const snapshot =
|
|
120
|
+
const snapshot = hashStore.snapshots[path];
|
|
113
121
|
if (snapshot && snapshot.content === content) {
|
|
114
122
|
return snapshot.hashes;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
const newHashes = _lineHashesPure(content);
|
|
118
|
-
|
|
119
|
-
await saveHashStore(
|
|
126
|
+
hashStore.snapshots[path] = { content, hashes: newHashes };
|
|
127
|
+
await saveHashStore(hashStore);
|
|
120
128
|
return newHashes;
|
|
121
129
|
}
|
|
122
130
|
|
|
@@ -166,13 +174,7 @@ function mapStableHashes(
|
|
|
166
174
|
for (let i = 0; i < newLines.length; i++) {
|
|
167
175
|
if (newHashes[i]) continue;
|
|
168
176
|
const c = canon(newLines[i]!);
|
|
169
|
-
|
|
170
|
-
let hash = h2s(xxh32(c));
|
|
171
|
-
while (used.has(hash)) {
|
|
172
|
-
retry++;
|
|
173
|
-
hash = h2s(xxh32(`${c}:R${retry}`));
|
|
174
|
-
}
|
|
175
|
-
used.add(hash);
|
|
177
|
+
const hash = nextUniqueHash(c, used);
|
|
176
178
|
newHashes[i] = hash;
|
|
177
179
|
}
|
|
178
180
|
return newHashes;
|
package/src/hashline/parse.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
HL_PREFIX_PLUS_RE,
|
|
6
6
|
DIFF_MINUS_RE,
|
|
7
7
|
} from "./hash";
|
|
8
|
+
import { CONTENT_LINES_NOT_STRING_MSG } from "../constants";
|
|
8
9
|
|
|
9
10
|
export type Anchor = { hash: string };
|
|
10
11
|
|
|
@@ -58,10 +59,7 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
58
59
|
export function parseText(edit: string[] | string | null): string[] {
|
|
59
60
|
if (edit === null) return [];
|
|
60
61
|
if (typeof edit === "string") {
|
|
61
|
-
throw new Error(
|
|
62
|
-
`[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
|
|
63
|
-
+ ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`
|
|
64
|
-
);
|
|
62
|
+
throw new Error(CONTENT_LINES_NOT_STRING_MSG);
|
|
65
63
|
}
|
|
66
64
|
assertNoPrefixes(edit);
|
|
67
65
|
return edit;
|