pi-hashline-edit-pro 1.0.0 → 1.0.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 +5 -0
- package/package.json +2 -2
- package/src/hash-store.ts +13 -0
- package/src/replace-diff.ts +7 -3
- package/src/replace-undo.ts +30 -4
- package/src/replace.ts +1 -0
- package/src/validation.ts +6 -6
package/README.md
CHANGED
|
@@ -127,6 +127,7 @@ Enabled by default. After a successful `write`, `replace`, or `undo_last_replace
|
|
|
127
127
|
- History is in-memory and is lost when the session ends or the extension reloads.
|
|
128
128
|
- A successful `write` clears the history for that file.
|
|
129
129
|
- Call `read` after an undo to get fresh anchors for follow-up edits.
|
|
130
|
+
- **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.
|
|
130
131
|
|
|
131
132
|
## Commands and configuration
|
|
132
133
|
|
|
@@ -155,6 +156,10 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
155
156
|
| `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
|
|
156
157
|
| `[E_BAD_OP]` | Range start line is after range end line — the pair is swapped automatically with a warning. |
|
|
157
158
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
159
|
+
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
160
|
+
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
161
|
+
| `[E_NOT_TEXT]` | The path is a directory, binary file, or image; hashline editing only supports text files. |
|
|
162
|
+
| `[E_UNDO_STALE]` | `undo_last_replace` refused: the file was modified or deleted after the last replace. |
|
|
158
163
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
159
164
|
|
|
160
165
|
## Hashing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 62-symbol, perfect hashing)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"test": "vitest run",
|
|
48
48
|
"test:watch": "vitest",
|
|
49
49
|
"test:coverage": "vitest run --coverage",
|
|
50
|
-
"lint": "eslint
|
|
50
|
+
"lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
|
|
51
51
|
"typecheck": "tsc --noEmit"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
package/src/hash-store.ts
CHANGED
|
@@ -42,6 +42,19 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
|
|
|
42
42
|
const db = new DatabaseSync(storePath, {
|
|
43
43
|
timeout: HASH_STORE_BUSY_TIMEOUT,
|
|
44
44
|
});
|
|
45
|
+
try {
|
|
46
|
+
return buildStore(db);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
try {
|
|
49
|
+
db.close();
|
|
50
|
+
} catch {}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildStore(
|
|
56
|
+
db: DatabaseSync,
|
|
57
|
+
): { db: DatabaseSync; stmts: Prepared } {
|
|
45
58
|
db.exec("PRAGMA journal_mode = WAL");
|
|
46
59
|
db.exec("PRAGMA synchronous = NORMAL");
|
|
47
60
|
db.exec(
|
package/src/replace-diff.ts
CHANGED
|
@@ -40,6 +40,10 @@ function fmtDiffLine(
|
|
|
40
40
|
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
|
|
44
|
+
const isEllipsisMarker = (line: string | symbol): line is symbol =>
|
|
45
|
+
line === ELLIPSIS_MARKER;
|
|
46
|
+
|
|
43
47
|
export function genDiff(
|
|
44
48
|
oldContent: string,
|
|
45
49
|
newContent: string,
|
|
@@ -78,7 +82,7 @@ export function genDiff(
|
|
|
78
82
|
const nextPartIsChange =
|
|
79
83
|
i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed);
|
|
80
84
|
if (lastWasChange || nextPartIsChange) {
|
|
81
|
-
let linesToShow = displayLines;
|
|
85
|
+
let linesToShow: (string | symbol)[] = displayLines;
|
|
82
86
|
let skipStart = 0;
|
|
83
87
|
let skipMiddle = 0;
|
|
84
88
|
|
|
@@ -87,7 +91,7 @@ export function genDiff(
|
|
|
87
91
|
linesToShow = displayLines.slice(skipStart);
|
|
88
92
|
} else if (nextPartIsChange && displayLines.length > contextLines * 2) {
|
|
89
93
|
const tail = displayLines.slice(-contextLines);
|
|
90
|
-
linesToShow = [...displayLines.slice(0, contextLines),
|
|
94
|
+
linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
|
|
91
95
|
skipMiddle = displayLines.length - contextLines * 2;
|
|
92
96
|
} else if (linesToShow.length > contextLines) {
|
|
93
97
|
linesToShow = linesToShow.slice(0, contextLines);
|
|
@@ -98,7 +102,7 @@ export function genDiff(
|
|
|
98
102
|
newLineNum += skipStart;
|
|
99
103
|
}
|
|
100
104
|
for (const line of linesToShow) {
|
|
101
|
-
if (line
|
|
105
|
+
if (isEllipsisMarker(line)) {
|
|
102
106
|
output.push(" ...");
|
|
103
107
|
newLineNum += skipMiddle;
|
|
104
108
|
continue;
|
package/src/replace-undo.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { contentChecksum } from "./hashline/hasher";
|
|
|
7
7
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
8
8
|
import { toCwd } from "./paths";
|
|
9
9
|
import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
|
|
10
|
-
import { cntDiff, splitLines } from "./utils";
|
|
10
|
+
import { cntDiff, splitLines, errCode } from "./utils";
|
|
11
11
|
import { loadP, loadGuide } from "./prompts";
|
|
12
12
|
import { buildMetrics } from "./replace-response";
|
|
13
13
|
import { changedRange } from "./hashline";
|
|
@@ -16,6 +16,7 @@ export interface UndoEntry {
|
|
|
16
16
|
bom: string;
|
|
17
17
|
originalEnding: "\r\n" | "\n";
|
|
18
18
|
hashes: string[];
|
|
19
|
+
resultContent: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const undoMap = new Map<string, UndoEntry>();
|
|
@@ -66,13 +67,38 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
return withFileMutationQueue(mutationTargetPath, async () => {
|
|
69
|
-
let currentNormalized
|
|
70
|
+
let currentNormalized: string | undefined;
|
|
70
71
|
try {
|
|
71
72
|
const currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
72
73
|
const { text: currentStripped } = stripBOM(currentRaw);
|
|
73
74
|
currentNormalized = toLF(currentStripped);
|
|
74
|
-
} catch {
|
|
75
|
-
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (errCode(error) !== "ENOENT") throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (currentNormalized === undefined) {
|
|
80
|
+
return {
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: "text",
|
|
84
|
+
text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file no longer exists. Call read() to inspect the current state.`
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
isError: true,
|
|
88
|
+
details: {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (currentNormalized !== undo.resultContent) {
|
|
92
|
+
return {
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "text",
|
|
96
|
+
text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file was modified after the replace, so undoing would overwrite those changes. Call read() to inspect the current state.`
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
isError: true,
|
|
100
|
+
details: {},
|
|
101
|
+
};
|
|
76
102
|
}
|
|
77
103
|
|
|
78
104
|
const diffResult = genDiff(undo.content, currentNormalized, 0);
|
package/src/replace.ts
CHANGED
package/src/validation.ts
CHANGED
|
@@ -13,25 +13,25 @@ export async function valAccess(
|
|
|
13
13
|
} catch (error: unknown) {
|
|
14
14
|
const code = errCode(error);
|
|
15
15
|
if (code === "ENOENT") {
|
|
16
|
-
throw new Error(`File not found: ${path}`);
|
|
16
|
+
throw new Error(`[E_NOT_FOUND] File not found: ${path}`);
|
|
17
17
|
}
|
|
18
18
|
if (code === "EACCES" || code === "EPERM") {
|
|
19
19
|
const accessLabel = accessMode & constants.W_OK ? "not writable" : "not readable";
|
|
20
|
-
throw new Error(`File is ${accessLabel}: ${path}`);
|
|
20
|
+
throw new Error(`[E_ACCESS] File is ${accessLabel}: ${path}`);
|
|
21
21
|
}
|
|
22
|
-
throw new Error(`Cannot access file: ${path}`);
|
|
22
|
+
throw new Error(`[E_ACCESS] Cannot access file: ${path}`);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
|
|
27
27
|
if (file.kind === "directory") {
|
|
28
|
-
throw new Error(`Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
28
|
+
throw new Error(`[E_NOT_TEXT] Path is a directory: ${path}. Use ls to inspect directories.`);
|
|
29
29
|
}
|
|
30
30
|
if (file.kind === "binary") {
|
|
31
|
-
throw new Error(`Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`);
|
|
31
|
+
throw new Error(`[E_NOT_TEXT] Path is a binary file: ${path} (${file.description}). Hashline edit only supports text files.`);
|
|
32
32
|
}
|
|
33
33
|
if (file.kind === "image") {
|
|
34
|
-
throw new Error(`Path is an image file: ${path}. Hashline edit only supports text files.`);
|
|
34
|
+
throw new Error(`[E_NOT_TEXT] Path is an image file: ${path}. Hashline edit only supports text files.`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|