pi-hashline-edit-pro 2.0.1 → 2.1.0
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 +80 -105
- package/package.json +1 -1
- package/src/file-reader.ts +2 -1
- package/src/hash-store.ts +16 -9
- package/src/hashline/alphabet.ts +12 -0
- package/src/hashline/hash.ts +3 -11
- package/src/paths.ts +9 -1
- package/src/read.ts +7 -3
- package/src/replace.ts +1 -1
package/README.md
CHANGED
|
@@ -1,35 +1,21 @@
|
|
|
1
1
|
# pi-hashline-edit-pro
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Hash-anchored `read` and `replace` tools for [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent). Every line of a file gets a unique 3-character hash, and you edit by hash. No line numbers, no fuzzy matching, no edits landing on the wrong line.
|
|
4
4
|
|
|
5
|
-
Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW,
|
|
5
|
+
Fork of [pi-hashline-edit](https://github.com/RimuruW/pi-hashline-edit) by RimuruW, extended with 3-character hashes and collision resolution.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## What you get
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
|
|
16
|
-
## Installation
|
|
17
|
-
|
|
18
|
-
From npm:
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
pi install npm:pi-hashline-edit-pro
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
From a local checkout:
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
pi install /path/to/pi-hashline-edit-pro
|
|
28
|
-
```
|
|
9
|
+
- **Read with anchors.** Every line comes back as `HASH│content`. The hash is the line's address.
|
|
10
|
+
- **Edit by hash.** `replace` targets a range of hashes, so edits always land on the lines you meant.
|
|
11
|
+
- **Anchors that stay put.** Edit one part of a file and the hashes of the rest stay the same. Read once, keep editing.
|
|
12
|
+
- **Fresh anchors, automatically.** After every `write` you get the new anchors. After every `replace` you get the diff with the new hashes.
|
|
13
|
+
- **Undo when you need it.** The last replace on a file can be reverted, even after a restart.
|
|
14
|
+
- **Safe writes.** Permissions, line endings, BOMs, symlinks, and hard links survive every edit.
|
|
29
15
|
|
|
30
16
|
## Quick start
|
|
31
17
|
|
|
32
|
-
1. Read a file
|
|
18
|
+
1. Read a file:
|
|
33
19
|
|
|
34
20
|
```text
|
|
35
21
|
ve7│function hello() {
|
|
@@ -47,37 +33,47 @@ kQm│}
|
|
|
47
33
|
}
|
|
48
34
|
```
|
|
49
35
|
|
|
50
|
-
3. Keep editing. Anchors for
|
|
36
|
+
3. Keep editing. Anchors for lines you didn't touch stay valid, and auto-read hands you fresh anchors after each change.
|
|
51
37
|
|
|
52
|
-
##
|
|
38
|
+
## Installation
|
|
53
39
|
|
|
54
|
-
|
|
40
|
+
```bash
|
|
41
|
+
pi install npm:pi-hashline-edit-pro
|
|
42
|
+
```
|
|
55
43
|
|
|
56
|
-
|
|
44
|
+
From a local checkout:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi install /path/to/pi-hashline-edit-pro
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## The read tool
|
|
51
|
+
|
|
52
|
+
`read` returns a text file with every line prefixed by `HASH│content`. The hash is 3 characters from `A-Za-z0-9` (for example `aB3`).
|
|
57
53
|
|
|
58
54
|
| Parameter | Description |
|
|
59
55
|
| --- | --- |
|
|
60
56
|
| `offset` | Start reading from this line number (1-indexed). |
|
|
61
57
|
| `limit` | Maximum number of lines to return. |
|
|
62
58
|
|
|
63
|
-
Paged output ends with a continuation hint,
|
|
59
|
+
Paged output ends with a continuation hint, for example `[Showing lines 1-50 of 120. Use offset=51 to continue.]`.
|
|
64
60
|
|
|
65
|
-
Lines up to 200KB are
|
|
61
|
+
Lines up to 200KB are shown in full. Larger lines are replaced by a marker with a bash inspection hint (`sed -n 'Np' <path> | head -c 204800`), because hash anchors need full lines.
|
|
66
62
|
|
|
67
63
|
Edge cases:
|
|
68
64
|
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
65
|
+
- Images (JPEG, PNG, GIF, WebP) come back as visual attachments.
|
|
66
|
+
- Binary files and directories are rejected with a descriptive error.
|
|
67
|
+
- UTF-16 and UTF-32 text (detected via BOM) is rejected, since editing it would corrupt the file.
|
|
68
|
+
- Empty files come back as a single empty-line hash (`HASH│`); use `replace` on that hash to insert content.
|
|
69
|
+
- 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.
|
|
70
|
+
- Files over 238,328 lines are rejected with `[E_FILE_TOO_LARGE]`.
|
|
75
71
|
|
|
76
|
-
## The
|
|
72
|
+
## The replace tool
|
|
77
73
|
|
|
78
|
-
The built-in `edit` tool is disabled
|
|
74
|
+
The built-in `edit` tool is disabled. `replace` is the only edit path, and it takes the hash anchors from `read` output.
|
|
79
75
|
|
|
80
|
-
|
|
76
|
+
One edit per call, with `hash_bounds` and `new_content` at the top level:
|
|
81
77
|
|
|
82
78
|
```json
|
|
83
79
|
{
|
|
@@ -92,59 +88,39 @@ Exactly one edit per call, with `hash_bounds` and `new_content` at the top level
|
|
|
92
88
|
| `hash_bounds` | Pair of 3-char hashes from `read` output marking the first and last line of the range to replace (inclusive). |
|
|
93
89
|
| `new_content` | Replacement content as a single string with `\n` line separators; a trailing newline is the last line's ending, not an extra empty line. Use `""` to delete the range. |
|
|
94
90
|
|
|
95
|
-
|
|
91
|
+
Notes:
|
|
96
92
|
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
- A reversed range (start hash after end hash) is swapped and applied.
|
|
102
|
-
- A duplicated boundary line — the classic `}`, `});`, or `} else {` pasted twice — is silently removed; the duplicate never reaches the file.
|
|
103
|
-
- `file_path` is accepted as an alias for `path`.
|
|
104
|
-
- **Response.** With auto-read enabled (the default), a successful edit returns the post-edit diff — the same `+HASH│` / `- │` / ` HASH│` rows the user sees — instead of the summary. With auto-read disabled, the edit reports `Successfully replaced in {path}. Added X line(s), removed Y line(s).` plus any warnings, and no diff is shown to the model. Warnings are appended in both modes. An edit that produces identical content reports `No changes made` and never rotates anchors. The post-edit diff is exposed to the host UI via `details.diff` — the TUI always shows it — and reaches the model-visible text only while auto-read is on.
|
|
105
|
-
- **Undo.** Every successful replace is undoable once via `undo_last_replace` — see [Undo](#undo).
|
|
93
|
+
- The request is checked before any file I/O, so a bad request never touches the file.
|
|
94
|
+
- Common copy-paste slips are fixed automatically and reported: a leftover `HASH│` prefix, diff-preview rows pasted into the replacement, a reversed range, or a boundary line pasted twice. `file_path` works as an alias for `path`.
|
|
95
|
+
- An edit that produces identical content reports `No changes made` and leaves the anchors alone.
|
|
96
|
+
- After a successful edit you get the post-edit diff with fresh anchors, so you can keep editing without re-reading.
|
|
106
97
|
|
|
107
|
-
##
|
|
108
|
-
|
|
109
|
-
Hashes are stored in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
|
|
110
|
-
|
|
111
|
-
Two guarantees make this safe even with duplicated content:
|
|
98
|
+
## Undo
|
|
112
99
|
|
|
113
|
-
|
|
114
|
-
- **Re-inserted identical text keeps its hash.** If replacement content matches a line that was just removed, the removed line's hash is reused — "replace X with X" doesn't rotate the anchor.
|
|
100
|
+
`undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content, BOM and line endings included, plus the previous anchors.
|
|
115
101
|
|
|
116
|
-
|
|
102
|
+
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
103
|
+
- History is persisted and survives session restarts. A failed `write` does not clear it.
|
|
104
|
+
- Every applied replace is undoable: the undo record is saved before the edit is written.
|
|
105
|
+
- A successful `write` clears the history for that file.
|
|
106
|
+
- If the file was modified or deleted since the last replace, the undo is refused rather than overwriting those changes.
|
|
117
107
|
|
|
118
108
|
## Auto-read
|
|
119
109
|
|
|
120
|
-
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
|
|
110
|
+
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.
|
|
121
111
|
|
|
122
|
-
-
|
|
123
|
-
- After `
|
|
124
|
-
-
|
|
125
|
-
- After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint — use `read` with `offset` to continue.
|
|
126
|
-
- 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).
|
|
112
|
+
- 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.
|
|
113
|
+
- After `write`, the block dumps from the top of the file. For files over 2000 lines, the dump is truncated with a pagination hint; use `read` with `offset` to continue.
|
|
114
|
+
- 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).
|
|
127
115
|
- Toggle at runtime with `/toggle-auto-read`; the setting persists across sessions.
|
|
128
|
-
- If the auto-read itself fails (e.g. the file was deleted between the write and the read), a short `--- Auto-read failed: ... ---` notice is appended instead of the anchor block, so the model knows the anchors are missing.
|
|
129
116
|
|
|
130
|
-
##
|
|
131
|
-
|
|
132
|
-
`undo_last_replace` reverts the most recent successful `replace` on a file, restoring the exact previous content — BOM and line endings included — and the previous anchors.
|
|
133
|
-
|
|
134
|
-
- History is per-file and single-level: only the most recent replace can be reverted.
|
|
135
|
-
- History is persisted in the hash store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) and survives session restarts; a failed `write` does not clear it.
|
|
136
|
-
- **Undo is a precondition, not a convenience.** The undo record is persisted *before* the edit is written; if it cannot be persisted, the `replace` is refused with `[E_UNDO_UNAVAILABLE]` and the file is not touched, so every applied edit is undoable. If the file write itself then fails, the previous undo record is restored, so a refused edit never destroys earlier undo history.
|
|
137
|
-
- A successful `write` clears the history for that file.
|
|
138
|
-
- With auto-read enabled, the model sees the post-edit diff after an undo, just like a replace; with auto-read disabled it sees the plain summary. No anchors are appended after an undo — call `read` to get fresh anchors for follow-up edits.
|
|
139
|
-
- **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.
|
|
140
|
-
|
|
141
|
-
## Commands and configuration
|
|
117
|
+
## Settings
|
|
142
118
|
|
|
143
119
|
| Command | Description |
|
|
144
120
|
| --- | --- |
|
|
145
121
|
| `/toggle-auto-read` | Toggle automatic hashline anchors after write and post-edit diffs after replace and undo_last_replace operations. Persists across sessions. |
|
|
146
122
|
|
|
147
|
-
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled
|
|
123
|
+
Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatically when a setting is toggled. On non-Windows platforms, the config directory honors `XDG_CONFIG_HOME` when set (falling back to `~/.config`); on Windows it always uses `~/.config`:
|
|
148
124
|
|
|
149
125
|
```json
|
|
150
126
|
{
|
|
@@ -152,17 +128,34 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
152
128
|
}
|
|
153
129
|
```
|
|
154
130
|
|
|
131
|
+
## How anchors work
|
|
132
|
+
|
|
133
|
+
Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9`, which gives 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace.
|
|
134
|
+
|
|
135
|
+
The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded. A hash starting with `-` is shape-identical to a diff-preview deletion row, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
|
|
136
|
+
|
|
137
|
+
Unique anchors by construction. If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions, runs of blank lines, repeated `}`, land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor; two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
138
|
+
|
|
139
|
+
Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
|
|
140
|
+
|
|
141
|
+
Two guarantees make this safe even with duplicated content:
|
|
142
|
+
|
|
143
|
+
- An edited range never borrows a hash from a line outside it. Lines outside the replaced range keep their hashes unconditionally, even when their content is byte-identical to lines inside the range.
|
|
144
|
+
- Re-inserted identical text keeps its hash. If replacement content matches a line that was just removed, the removed line's hash is reused. "Replace X with X" doesn't rotate the anchor.
|
|
145
|
+
|
|
146
|
+
A no-op replace never changes the file, so anchors remain valid. On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`.
|
|
147
|
+
|
|
155
148
|
## Error codes
|
|
156
149
|
|
|
157
150
|
| Code | Meaning |
|
|
158
151
|
| --- | --- |
|
|
159
|
-
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (
|
|
152
|
+
| `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields (for example `new_content` must be a string with `\n` line separators). |
|
|
160
153
|
| `[E_BAD_REF]` | An anchor in `hash_bounds` is not a bare 3-char hash. |
|
|
161
154
|
| `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
|
|
162
155
|
| `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
|
|
163
|
-
| `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`)
|
|
164
|
-
| `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix
|
|
165
|
-
| `[E_BAD_OP]` | Range start line is after range end line
|
|
156
|
+
| `[E_INVALID_PATCH]` | A `new_content` line is a diff-preview row (`+HASH│`, `-HASH│`, `- │`). The marker is stripped automatically with a warning. |
|
|
157
|
+
| `[E_BARE_HASH_PREFIX]` | A `new_content` line starts with a hash-like `HASH│` prefix. The prefix is stripped automatically with a warning. |
|
|
158
|
+
| `[E_BAD_OP]` | Range start line is after range end line. The pair is swapped automatically with a warning. |
|
|
166
159
|
| `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
|
|
167
160
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
168
161
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
@@ -171,30 +164,12 @@ Settings live in `~/.config/pi-hashline-edit-pro/config.json`, created automatic
|
|
|
171
164
|
| `[E_UNDO_UNAVAILABLE]` | Undo history could not be persisted to the hash store; the `replace` was refused and the file was left unchanged. |
|
|
172
165
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit. |
|
|
173
166
|
|
|
174
|
-
## Hashing
|
|
175
|
-
|
|
176
|
-
Each line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32), then mapped to a 3-character string over `A-Za-z0-9` — 62³ = 238,328 possible anchors. The canonicalization keeps anchors stable across editor-save cycles that add or remove trailing whitespace.
|
|
177
|
-
|
|
178
|
-
The alphabet is sized for an LLM consumer: the model tokenizes rather than squinting at glyphs, so case and digits are all included. The URL-safe specials `-` and `_` are deliberately excluded — a hash starting with `-` is shape-identical to a diff-preview deletion row, and `-`/`_` at a line start are markdown-active, inviting mis-copying and false autocorrections.
|
|
179
|
-
|
|
180
|
-
**Unique anchors by construction.** If a line's base hash collides with an already-assigned hash, the next free hash is allocated from a bitset by probing with a stride coprime to the hash space (O(1) amortized). The stride is `62² + 62 + 1`, so consecutive collisions — runs of blank lines, repeated `}` — land on anchors that differ in all three characters instead of sharing a prefix. Every line in a file therefore gets a unique anchor — two byte-identical lines (repeated `}`, repeated `import` statements) never share one. The same guarantee sets the file size cap: at most 238,328 lines per file, beyond which `read` and `replace` reject with `[E_FILE_TOO_LARGE]` (use `write` for very large files).
|
|
181
|
-
|
|
182
|
-
## Design decisions
|
|
183
|
-
|
|
184
|
-
- **Stale anchors fail, per line.** A hash mismatch means that line's content changed since the last `read`. The error says so and, when only one anchor of a pair is stale, shows the current lines around the still-valid anchor so the range can be re-located without a full re-read. Mismatched anchors are never silently relocated to a "close enough" line — correctness over convenience.
|
|
185
|
-
- **Autocorrection only when the intent is unambiguous**, and always visible: hash-prefix and diff-row stripping produce a warning; the boundary-duplication fix is silent because the duplicate never reaches the file. Literal content is never silently altered when the intent is ambiguous (numbered deletion rows and unified-diff lines are written verbatim).
|
|
186
|
-
- **Byte-exact preservation.** UTF-8 BOMs, CRLF, LF, and CR-only line endings, file permissions, and trailing newlines survive edits and undo; files with mixed line endings are normalized to a single line ending on edit.
|
|
187
|
-
- **Atomic and ordered writes.** Files are written via temp-file-then-rename; symlink chains are resolved so the target is updated without replacing the symlink; hard-linked files are updated in place; concurrent edits to the same underlying file serialize through a per-target mutation queue.
|
|
188
|
-
- **One edit per call.** The request shape stays `{path, hash_bounds, new_content}` from schema through validation to application; there is no batching dialect.
|
|
189
|
-
|
|
190
167
|
## Troubleshooting
|
|
191
168
|
|
|
192
|
-
-
|
|
193
|
-
-
|
|
194
|
-
-
|
|
195
|
-
-
|
|
196
|
-
- **Legacy migration.** On first run after upgrading from an older version, the previous `hash-store.json` is imported once and renamed to `hash-store.json.bak`, which can be deleted. Legacy snapshots containing duplicate hashes are skipped and rebuilt on the next read.
|
|
197
|
-
- **`[E_UNDO_UNAVAILABLE]`.** The edit was refused because the undo record could not be written — check disk space and that the config directory is writable, then retry.
|
|
169
|
+
- Stale anchors. `[E_STALE_ANCHOR]` or `[E_AMBIGUOUS_ANCHOR]` mean the file changed since the anchors were read. Call `read` for fresh anchors and retry.
|
|
170
|
+
- Reset the hash store. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars). Quit pi, delete those three files, and the store is rebuilt on the next session. Anchor history is lost, but no project files are touched.
|
|
171
|
+
- Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
|
|
172
|
+
- Config directory moved. On non-Windows platforms, if `XDG_CONFIG_HOME` is set, the config directory (and the hash store inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
|
|
198
173
|
|
|
199
174
|
## Development
|
|
200
175
|
|
|
@@ -211,8 +186,8 @@ Set `PI_HASHLINE_DEBUG=1` to show an "active" notification at session start.
|
|
|
211
186
|
|
|
212
187
|
## Credits
|
|
213
188
|
|
|
214
|
-
- [RimuruW](https://github.com/RimuruW)
|
|
215
|
-
- [can1357](https://github.com/can1357)
|
|
189
|
+
- [RimuruW](https://github.com/RimuruW), original `pi-hashline-edit` and the strict-semantics policy
|
|
190
|
+
- [can1357](https://github.com/can1357), original [oh-my-pi](https://github.com/can1357/oh-my-pi) implementation and the hashline concept
|
|
216
191
|
|
|
217
192
|
## License
|
|
218
193
|
|
package/package.json
CHANGED
package/src/file-reader.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface ReadNormOptions {
|
|
|
51
51
|
preloadedFile?: LFile;
|
|
52
52
|
maxLines?: number;
|
|
53
53
|
store?: HashStore;
|
|
54
|
+
noPersist?: boolean;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export async function readNormFile(
|
|
@@ -83,7 +84,7 @@ export async function readNormFile(
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store);
|
|
87
|
+
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
|
|
87
88
|
return {
|
|
88
89
|
absolutePath: resolvedPath,
|
|
89
90
|
normalized,
|
package/src/hash-store.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
4
4
|
import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
|
|
5
5
|
import { errCode, splitLines } from "./utils";
|
|
6
6
|
import { initHasher, contentChecksum } from "./hashline/hasher";
|
|
7
|
+
import { HASH_RE } from "./hashline/alphabet";
|
|
7
8
|
import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
|
|
8
9
|
type SqlParams = (string | number)[];
|
|
9
10
|
|
|
@@ -35,15 +36,19 @@ interface LegacySnapshot {
|
|
|
35
36
|
hashes: string[];
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function isValidHashList(value: unknown): value is string[] {
|
|
40
|
+
if (!Array.isArray(value)) return false;
|
|
41
|
+
for (const hash of value) {
|
|
42
|
+
if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
function isValidSnapshot(value: unknown): value is LegacySnapshot {
|
|
39
48
|
if (typeof value !== "object" || value === null) return false;
|
|
40
49
|
const v = value as Record<string, unknown>;
|
|
41
50
|
if (typeof v.content !== "string") return false;
|
|
42
|
-
|
|
43
|
-
for (const h of v.hashes) {
|
|
44
|
-
if (typeof h !== "string") return false;
|
|
45
|
-
}
|
|
46
|
-
return true;
|
|
51
|
+
return isValidHashList(v.hashes);
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
export function isCorruptionError(error: unknown): boolean {
|
|
@@ -355,6 +360,7 @@ export function getSnapshot(
|
|
|
355
360
|
store: HashStore,
|
|
356
361
|
path: string,
|
|
357
362
|
content: string,
|
|
363
|
+
deleteCorrupt = true,
|
|
358
364
|
): string[] | undefined {
|
|
359
365
|
const checksum = contentChecksum(content);
|
|
360
366
|
const lineCount = splitLines(content).length;
|
|
@@ -362,10 +368,11 @@ export function getSnapshot(
|
|
|
362
368
|
if (!row) return undefined;
|
|
363
369
|
try {
|
|
364
370
|
const parsed = JSON.parse(row.hashes as string);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
371
|
+
if (isValidHashList(parsed)) return parsed;
|
|
372
|
+
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
373
|
+
return undefined;
|
|
368
374
|
} catch {
|
|
375
|
+
if (deleteCorrupt) store.stmts.deleteOne(path);
|
|
369
376
|
return undefined;
|
|
370
377
|
}
|
|
371
378
|
}
|
|
@@ -397,7 +404,7 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
|
|
|
397
404
|
if (!row) return undefined;
|
|
398
405
|
try {
|
|
399
406
|
const parsed = JSON.parse(row.hashes as string);
|
|
400
|
-
if (!
|
|
407
|
+
if (!isValidHashList(parsed)) {
|
|
401
408
|
store.stmts.undoDelete(path);
|
|
402
409
|
return undefined;
|
|
403
410
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const HASH_LEN = 3;
|
|
2
|
+
|
|
3
|
+
export const ALPH =
|
|
4
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
5
|
+
|
|
6
|
+
const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
|
|
7
|
+
|
|
8
|
+
export const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
|
|
9
|
+
|
|
10
|
+
export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
11
|
+
|
|
12
|
+
export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
|
package/src/hashline/hash.ts
CHANGED
|
@@ -6,19 +6,13 @@ import {
|
|
|
6
6
|
upsertSnapshot,
|
|
7
7
|
} from "../hash-store";
|
|
8
8
|
import { xxh32, contentChecksum, initHasher } from "./hasher";
|
|
9
|
-
|
|
9
|
+
import { HASH_LEN, ALPH, ALPH_RE, HASH_CLASS } from "./alphabet";
|
|
10
|
+
export { initHasher, HASH_LEN, ALPH_RE, HASH_CLASS };
|
|
10
11
|
|
|
11
|
-
export const HASH_LEN = 3;
|
|
12
12
|
export const ANCHOR_LEN = HASH_LEN;
|
|
13
13
|
|
|
14
14
|
export const HASH_SEP = "│";
|
|
15
15
|
|
|
16
|
-
const ALPH =
|
|
17
|
-
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
18
|
-
const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
|
|
19
|
-
const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
|
|
20
|
-
export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
|
|
21
|
-
|
|
22
16
|
export const HASH_SPACE = ALPH.length ** HASH_LEN;
|
|
23
17
|
export const MAX_HASH_LINES = HASH_SPACE;
|
|
24
18
|
|
|
@@ -138,7 +132,7 @@ export async function lineHashes(
|
|
|
138
132
|
|
|
139
133
|
let cached: string[] | undefined;
|
|
140
134
|
try {
|
|
141
|
-
cached = getSnapshot(hashStore, path, content);
|
|
135
|
+
cached = getSnapshot(hashStore, path, content, persist !== false);
|
|
142
136
|
} catch (error) {
|
|
143
137
|
console.error("Failed to read hash store snapshot:", error);
|
|
144
138
|
}
|
|
@@ -290,5 +284,3 @@ function mapStableHashes(
|
|
|
290
284
|
|
|
291
285
|
return newHashes;
|
|
292
286
|
}
|
|
293
|
-
|
|
294
|
-
export { ALPH_RE };
|
package/src/paths.ts
CHANGED
|
@@ -7,8 +7,16 @@ function homeBase(): string {
|
|
|
7
7
|
return envHome && envHome.length > 0 ? envHome : homedir();
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function configBase(): string {
|
|
11
|
+
if (process.platform !== "win32") {
|
|
12
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
13
|
+
if (xdg && xdg.length > 0) return xdg;
|
|
14
|
+
}
|
|
15
|
+
return join(homeBase(), ".config");
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export function configDir(): string {
|
|
11
|
-
return join(
|
|
19
|
+
return join(configBase(), "pi-hashline-edit-pro");
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
export function configPath(): string {
|
package/src/read.ts
CHANGED
|
@@ -204,8 +204,12 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
204
204
|
fileHashes,
|
|
205
205
|
absolutePath,
|
|
206
206
|
);
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
let snapshotId: string | undefined;
|
|
208
|
+
try {
|
|
209
|
+
snapshotId = (await fileSnap(absolutePath)).snapshotId;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error("Failed to compute snapshot for read:", error);
|
|
212
|
+
}
|
|
209
213
|
const previewText =
|
|
210
214
|
hadUtf8DecodeErrors
|
|
211
215
|
? `${preview.text}\n\n[Non-UTF-8 bytes shown as U+FFFD; editing rewrites the file as UTF-8.]`
|
|
@@ -215,7 +219,7 @@ export function regRead(pi: ExtensionAPI): void {
|
|
|
215
219
|
content: [{ type: "text", text: previewText }],
|
|
216
220
|
details: {
|
|
217
221
|
truncation: preview.truncation,
|
|
218
|
-
snapshotId
|
|
222
|
+
snapshotId,
|
|
219
223
|
...(preview.nextOffset !== undefined
|
|
220
224
|
? { nextOffset: preview.nextOffset }
|
|
221
225
|
: {}),
|
package/src/replace.ts
CHANGED
|
@@ -182,7 +182,7 @@ export async function execPipeline(
|
|
|
182
182
|
|
|
183
183
|
const hashStore = options?.store ?? await loadHashStore();
|
|
184
184
|
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
|
|
185
|
-
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore },
|
|
185
|
+
path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist },
|
|
186
186
|
);
|
|
187
187
|
|
|
188
188
|
const anchorResult = applyEdit(
|