pi-hashline-edit-pro 4.2.8 → 4.2.10
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 +1 -1
- package/package.json +1 -1
- package/src/anchor-registry.ts +98 -17
- package/src/fs-write.ts +4 -2
- package/src/hash-store/retry.ts +1 -1
- package/src/hash-store.ts +10 -4
- package/src/replace-undo.ts +14 -1
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ The table is curated for tokenizers, not for humans. Every anchor is the concate
|
|
|
199
199
|
|
|
200
200
|
Each line also carries a content checksum. The line is canonicalized (carriage returns stripped, trailing whitespace trimmed) and hashed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm). The canonicalization keeps the checksum stable across editor-save cycles that add or remove trailing whitespace. A line over 500 bytes is hashed from its first 500 bytes.
|
|
201
201
|
|
|
202
|
-
Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup, and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
|
|
202
|
+
Allocated anchors live in a persistent per-file snapshot (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) keyed by content checksum, so resume-after-restart and cross-session edits reuse ownership instead of minting duplicates. Each session also appends an ownership log (`allocate`/`free`/`clear` events) to a sidecar file under `~/.config/pi-hashline-edit-pro/sessions/`; the fold of that log is the session's source of truth, sidecars whose session file is gone are garbage-collected at startup (never the sidecar of a session that is currently loaded in this process), and the in-memory ownership of a session is released when that session shuts down and rebuilt from its sidecar on next use.
|
|
203
203
|
|
|
204
204
|
When a range is edited, the mapping between old and new content is computed per span: lines whose content is unchanged keep their allocated anchors, anchors of removed lines are freed, and every genuinely new line is minted a fresh anchor. Anchors are never assigned by matching content; only positional survival across an edit preserves one.
|
|
205
205
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor 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/anchor-registry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { appendFileSync, chmodSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, chmodSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -158,11 +158,12 @@ interface SessionState {
|
|
|
158
158
|
const SIDECAR_SUFFIX = ".registry.jsonl";
|
|
159
159
|
const SIDECAR_COMPACT_LINES = 5000;
|
|
160
160
|
const SIDECAR_COMPACT_BYTES = 1024 * 1024;
|
|
161
|
-
const
|
|
161
|
+
export const SIDECAR_COMPACT_LINE_BYTES = 48 * 1024;
|
|
162
162
|
export const SIDECAR_HEADER_BYTES = 64 * 1024;
|
|
163
163
|
const SIDECAR_HEADER_CHUNK = 4096;
|
|
164
164
|
let currentKey: string | undefined;
|
|
165
165
|
const sidecarByKey = new Map<string, string>();
|
|
166
|
+
const sessionFileByKey = new Map<string, string>();
|
|
166
167
|
const pendingInits = new Map<string, Promise<void>>();
|
|
167
168
|
const loadTokens = new Map<string, object>();
|
|
168
169
|
const registries = new Map<string, SessionState>();
|
|
@@ -247,6 +248,45 @@ export function shouldCompactSidecar(raw: string): boolean {
|
|
|
247
248
|
for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) lines += 1;
|
|
248
249
|
return lines >= SIDECAR_COMPACT_LINES;
|
|
249
250
|
}
|
|
251
|
+
|
|
252
|
+
function parseSessionFileLine(line: string): string | undefined {
|
|
253
|
+
const trimmed = line.trim();
|
|
254
|
+
if (trimmed.length === 0) return undefined;
|
|
255
|
+
try {
|
|
256
|
+
const event = JSON.parse(trimmed) as { kind?: unknown; sessionFile?: unknown };
|
|
257
|
+
if (event.kind === "session" && typeof event.sessionFile === "string" && event.sessionFile.length > 0) return event.sessionFile;
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function firstLineSessionFile(raw: string): string | undefined {
|
|
264
|
+
const newline = raw.indexOf("\n");
|
|
265
|
+
return parseSessionFileLine(newline >= 0 ? raw.slice(0, newline) : raw);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function chunkBySerializedBytes<T>(items: T[], wrap: (chunk: T[]) => unknown, maxBytes: number): T[][] {
|
|
269
|
+
const chunks: T[][] = [];
|
|
270
|
+
if (items.length === 0) return chunks;
|
|
271
|
+
const overhead = Buffer.byteLength(JSON.stringify(wrap([])), "utf-8");
|
|
272
|
+
let chunk: T[] = [];
|
|
273
|
+
let bytes = overhead;
|
|
274
|
+
for (const item of items) {
|
|
275
|
+
const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf-8");
|
|
276
|
+
const nextBytes = bytes + itemBytes + (chunk.length > 0 ? 1 : 0);
|
|
277
|
+
if (chunk.length > 0 && nextBytes > maxBytes) {
|
|
278
|
+
chunks.push(chunk);
|
|
279
|
+
chunk = [item];
|
|
280
|
+
bytes = overhead + itemBytes;
|
|
281
|
+
} else {
|
|
282
|
+
chunk.push(item);
|
|
283
|
+
bytes = nextBytes;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
chunks.push(chunk);
|
|
287
|
+
return chunks;
|
|
288
|
+
}
|
|
289
|
+
|
|
250
290
|
export function buildCompactedLog(sessionFile: string, state: SessionState): string {
|
|
251
291
|
const byPath = new Map<string, Array<[string, string]>>();
|
|
252
292
|
for (const [anchor, entry] of state.owned) {
|
|
@@ -256,18 +296,18 @@ export function buildCompactedLog(sessionFile: string, state: SessionState): str
|
|
|
256
296
|
}
|
|
257
297
|
const out: string[] = [JSON.stringify({ kind: "session", sessionFile })];
|
|
258
298
|
for (const [path, rows] of byPath) {
|
|
259
|
-
for (
|
|
260
|
-
out.push(JSON.stringify({ kind: "allocate", path, rows:
|
|
299
|
+
for (const chunk of chunkBySerializedBytes(rows, (part) => ({ kind: "allocate", path, rows: part }), SIDECAR_COMPACT_LINE_BYTES)) {
|
|
300
|
+
out.push(JSON.stringify({ kind: "allocate", path, rows: chunk }));
|
|
261
301
|
}
|
|
262
302
|
}
|
|
263
303
|
const freedHistory = [...state.everMinted].filter((anchor) => !state.owned.has(anchor));
|
|
264
|
-
for (
|
|
265
|
-
out.push(JSON.stringify({ kind: "minted", anchors:
|
|
304
|
+
for (const chunk of chunkBySerializedBytes(freedHistory, (part) => ({ kind: "minted", anchors: part }), SIDECAR_COMPACT_LINE_BYTES)) {
|
|
305
|
+
out.push(JSON.stringify({ kind: "minted", anchors: chunk }));
|
|
266
306
|
}
|
|
267
307
|
return out.join("\n") + "\n";
|
|
268
308
|
}
|
|
269
|
-
async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState): Promise<void> {
|
|
270
|
-
if (!shouldCompactSidecar(raw)) return;
|
|
309
|
+
async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState, force = false): Promise<void> {
|
|
310
|
+
if (!force && !shouldCompactSidecar(raw)) return;
|
|
271
311
|
const tmp = `${sidecar}.compact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
272
312
|
try {
|
|
273
313
|
const compacted = buildCompactedLog(sessionFile, state);
|
|
@@ -300,7 +340,7 @@ async function loadRegistryState(key: string, sessionFile: string, token: object
|
|
|
300
340
|
registries.set(key, folded);
|
|
301
341
|
sidecarByKey.set(key, sidecar);
|
|
302
342
|
if (rawLog.length > 0) {
|
|
303
|
-
await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded);
|
|
343
|
+
await compactSidecarIfNeeded(sidecar, rawLog, sessionFile, folded, firstLineSessionFile(rawLog) !== sessionFile);
|
|
304
344
|
}
|
|
305
345
|
try {
|
|
306
346
|
await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
|
|
@@ -329,6 +369,7 @@ async function ensureRegistryForKey(key: string, sessionFile: string | undefined
|
|
|
329
369
|
registries.set(key, newSessionState(key));
|
|
330
370
|
return;
|
|
331
371
|
}
|
|
372
|
+
sessionFileByKey.set(key, sessionFile);
|
|
332
373
|
await loadRegistryState(key, sessionFile, token);
|
|
333
374
|
})();
|
|
334
375
|
pendingInits.set(key, promise);
|
|
@@ -384,12 +425,27 @@ function current(): SessionState | undefined {
|
|
|
384
425
|
return registries.get(key);
|
|
385
426
|
}
|
|
386
427
|
|
|
428
|
+
function sidecarNeedsSessionRecord(sidecar: string): boolean {
|
|
429
|
+
try {
|
|
430
|
+
return statSync(sidecar).size === 0;
|
|
431
|
+
} catch {
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function ensureSidecarSessionRecord(key: string, sidecar: string): void {
|
|
437
|
+
const sessionFile = sessionFileByKey.get(key);
|
|
438
|
+
if (sessionFile === undefined || !sidecarNeedsSessionRecord(sidecar)) return;
|
|
439
|
+
appendEvent({ kind: "session", sessionFile } satisfies RegistryEvent);
|
|
440
|
+
}
|
|
441
|
+
|
|
387
442
|
function appendEvent(event: RegistryEvent): void {
|
|
388
443
|
const key = activeKey();
|
|
389
444
|
if (!key) return;
|
|
390
445
|
const sidecar = sidecarByKey.get(key);
|
|
391
446
|
if (!sidecar) return;
|
|
392
447
|
try {
|
|
448
|
+
if (event.kind !== "session") ensureSidecarSessionRecord(key, sidecar);
|
|
393
449
|
appendFileSync(sidecar, JSON.stringify(event) + "\n", "utf-8");
|
|
394
450
|
if (process.platform !== "win32") {
|
|
395
451
|
try { chmodSync(sidecar, 0o600); } catch (error) { if (errCode(error) !== "ENOENT") console.error("Failed to secure anchor registry sidecar:", error); }
|
|
@@ -841,12 +897,13 @@ export function adoptAnchors(path: string, entries: Map<string, string>): void {
|
|
|
841
897
|
export function resetRegistryForTests(): void {
|
|
842
898
|
currentKey = undefined;
|
|
843
899
|
sidecarByKey.clear();
|
|
900
|
+
sessionFileByKey.clear();
|
|
844
901
|
pendingInits.clear();
|
|
845
902
|
loadTokens.clear();
|
|
846
903
|
registries.clear();
|
|
847
904
|
}
|
|
848
905
|
|
|
849
|
-
|
|
906
|
+
async function readSidecarWindow(sidecar: string): Promise<{ text: string; filled: boolean }> {
|
|
850
907
|
const handle = await open(sidecar, "r");
|
|
851
908
|
const buffer = Buffer.alloc(SIDECAR_HEADER_BYTES);
|
|
852
909
|
try {
|
|
@@ -855,24 +912,47 @@ export async function readSidecarHeader(sidecar: string): Promise<string> {
|
|
|
855
912
|
const { bytesRead } = await handle.read(buffer, readBytes, Math.min(SIDECAR_HEADER_CHUNK, buffer.length - readBytes), readBytes);
|
|
856
913
|
if (bytesRead === 0) break;
|
|
857
914
|
readBytes += bytesRead;
|
|
858
|
-
const text = buffer.subarray(0, readBytes).toString("utf-8");
|
|
859
|
-
const newline = text.indexOf("\n");
|
|
860
|
-
if (newline >= 0) return text.slice(0, newline);
|
|
861
915
|
}
|
|
862
|
-
return
|
|
916
|
+
return { text: buffer.subarray(0, readBytes).toString("utf-8"), filled: readBytes === buffer.length };
|
|
863
917
|
} finally {
|
|
864
918
|
await handle.close();
|
|
865
919
|
}
|
|
866
920
|
}
|
|
867
921
|
|
|
922
|
+
export async function readSidecarHeader(sidecar: string): Promise<string> {
|
|
923
|
+
const { text, filled } = await readSidecarWindow(sidecar);
|
|
924
|
+
const newline = text.indexOf("\n");
|
|
925
|
+
if (newline >= 0) return text.slice(0, newline);
|
|
926
|
+
return filled ? "" : text;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export async function readSidecarSessionFile(sidecar: string): Promise<string | undefined> {
|
|
930
|
+
const { text } = await readSidecarWindow(sidecar);
|
|
931
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
932
|
+
const complete = lastNewline >= 0 ? text.slice(0, lastNewline).split("\n") : [];
|
|
933
|
+
for (const line of complete) {
|
|
934
|
+
const sessionFile = parseSessionFileLine(line);
|
|
935
|
+
if (sessionFile !== undefined) return sessionFile;
|
|
936
|
+
}
|
|
937
|
+
return parseSessionFileLine(lastNewline >= 0 ? text.slice(lastNewline + 1) : text);
|
|
938
|
+
}
|
|
939
|
+
|
|
868
940
|
export function releaseRegistrySession(key: string): void {
|
|
869
941
|
loadTokens.delete(key);
|
|
870
942
|
if (currentKey === key) currentKey = undefined;
|
|
871
943
|
pendingInits.delete(key);
|
|
872
944
|
sidecarByKey.delete(key);
|
|
945
|
+
sessionFileByKey.delete(key);
|
|
873
946
|
registries.delete(key);
|
|
874
947
|
}
|
|
875
948
|
|
|
949
|
+
function isClaimedSidecar(sidecar: string): boolean {
|
|
950
|
+
for (const claimed of sidecarByKey.values()) {
|
|
951
|
+
if (claimed === sidecar) return true;
|
|
952
|
+
}
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
|
|
876
956
|
export async function gcRegistrySidecars(): Promise<void> {
|
|
877
957
|
let names: string[];
|
|
878
958
|
try {
|
|
@@ -894,10 +974,11 @@ export async function gcRegistrySidecars(): Promise<void> {
|
|
|
894
974
|
}
|
|
895
975
|
if (!name.endsWith(SIDECAR_SUFFIX)) continue;
|
|
896
976
|
const sidecar = join(sessionClaimsDir(), name);
|
|
977
|
+
if (isClaimedSidecar(sidecar)) continue;
|
|
897
978
|
try {
|
|
898
|
-
const
|
|
899
|
-
if (
|
|
900
|
-
await stat(
|
|
979
|
+
const sessionFile = await readSidecarSessionFile(sidecar);
|
|
980
|
+
if (sessionFile === undefined) continue;
|
|
981
|
+
await stat(sessionFile);
|
|
901
982
|
} catch (error) {
|
|
902
983
|
if (errCode(error) === "ENOENT") {
|
|
903
984
|
await rm(sidecar, { force: true });
|
package/src/fs-write.ts
CHANGED
|
@@ -152,6 +152,7 @@ export async function writeAtomic(
|
|
|
152
152
|
path: string,
|
|
153
153
|
content: string,
|
|
154
154
|
expectedIdentity?: FileIdentity,
|
|
155
|
+
restoreMode?: number,
|
|
155
156
|
): Promise<void> {
|
|
156
157
|
const targetPath = await resolveTarget(path);
|
|
157
158
|
|
|
@@ -193,8 +194,9 @@ export async function writeAtomic(
|
|
|
193
194
|
const tempHandle = await open(tempPath, "wx", 0o600);
|
|
194
195
|
try {
|
|
195
196
|
await tempHandle.writeFile(content, "utf-8");
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
const mode = existingStats ? existingStats.mode & 0o7777 : restoreMode;
|
|
198
|
+
if (mode !== undefined) {
|
|
199
|
+
await tempHandle.chmod(mode);
|
|
198
200
|
}
|
|
199
201
|
await tempHandle.sync();
|
|
200
202
|
} catch (error: unknown) {
|
package/src/hash-store/retry.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function withBusyRetryAsync<T>(fn: () => T): Promise<T> {
|
|
|
37
37
|
throw lastError;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export function retriedWrite(stmt: { run(...params: (string | number)[]): unknown }): (...params: (string | number)[]) => void {
|
|
40
|
+
export function retriedWrite(stmt: { run(...params: (string | number | null)[]): unknown }): (...params: (string | number | null)[]) => void {
|
|
41
41
|
return (...params) => {
|
|
42
42
|
withBusyRetry(() => { stmt.run(...params); });
|
|
43
43
|
};
|
package/src/hash-store.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { isValidHashList, isValidServedMap, parseHashList, parseServedMap, parse
|
|
|
26
26
|
export { SNAPSHOT_CACHE_LIMIT };
|
|
27
27
|
export const STORE_NOT_OPEN_MESSAGE = "Hash store is not open; transactional update aborted";
|
|
28
28
|
|
|
29
|
-
type SqlParams = (string | number)[];
|
|
29
|
+
type SqlParams = (string | number | null)[];
|
|
30
30
|
|
|
31
31
|
interface RawStatement {
|
|
32
32
|
get(...params: SqlParams): unknown;
|
|
@@ -140,6 +140,7 @@ export interface UndoRecord {
|
|
|
140
140
|
ending: string;
|
|
141
141
|
hashes: string[];
|
|
142
142
|
resultContent: string;
|
|
143
|
+
mode?: number;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
let cachedDb: { path: string; db: RawDb; stmts: Prepared } | null = null;
|
|
@@ -190,6 +191,9 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
190
191
|
try {
|
|
191
192
|
db.exec("ALTER TABLE snapshots ADD COLUMN line_checksums TEXT");
|
|
192
193
|
} catch {}
|
|
194
|
+
try {
|
|
195
|
+
db.exec("ALTER TABLE undo ADD COLUMN mode INTEGER");
|
|
196
|
+
} catch {}
|
|
193
197
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'version'").get() as { value?: string } | undefined;
|
|
194
198
|
if (versionRow && versionRow.value !== String(HASH_STORE_VERSION)) {
|
|
195
199
|
db.exec("DELETE FROM snapshots");
|
|
@@ -209,11 +213,11 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
209
213
|
"ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, line_checksums = excluded.line_checksums, updated_at = excluded.updated_at"
|
|
210
214
|
);
|
|
211
215
|
const undoUpsertStmt = db.prepare(
|
|
212
|
-
"INSERT INTO undo (path, content, bom, ending, hashes, result_content, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) " +
|
|
213
|
-
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, updated_at = excluded.updated_at"
|
|
216
|
+
"INSERT INTO undo (path, content, bom, ending, hashes, result_content, mode, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) " +
|
|
217
|
+
"ON CONFLICT(path) DO UPDATE SET content = excluded.content, bom = excluded.bom, ending = excluded.ending, hashes = excluded.hashes, result_content = excluded.result_content, mode = excluded.mode, updated_at = excluded.updated_at"
|
|
214
218
|
);
|
|
215
219
|
const undoGetStmt = db.prepare(
|
|
216
|
-
"SELECT content, bom, ending, hashes, result_content FROM undo WHERE path = ?"
|
|
220
|
+
"SELECT content, bom, ending, hashes, result_content, mode FROM undo WHERE path = ?"
|
|
217
221
|
);
|
|
218
222
|
const undoDelStmt = db.prepare("DELETE FROM undo WHERE path = ?");
|
|
219
223
|
const snapshotTimeStmt = db.prepare("SELECT updated_at FROM snapshots WHERE path = ?");
|
|
@@ -533,6 +537,7 @@ export function upsertUndo(store: HashStore, path: string, entry: UndoRecord): v
|
|
|
533
537
|
entry.ending,
|
|
534
538
|
JSON.stringify(entry.hashes),
|
|
535
539
|
entry.resultContent,
|
|
540
|
+
typeof entry.mode === "number" ? entry.mode : null,
|
|
536
541
|
Date.now(),
|
|
537
542
|
);
|
|
538
543
|
touchSession(path);
|
|
@@ -549,6 +554,7 @@ export function getUndoEntry(store: HashStore, path: string): UndoRecord | undef
|
|
|
549
554
|
ending: row.ending as string,
|
|
550
555
|
hashes: parsed,
|
|
551
556
|
resultContent: row.result_content as string,
|
|
557
|
+
...(typeof row.mode === "number" ? { mode: row.mode } : {}),
|
|
552
558
|
};
|
|
553
559
|
}
|
|
554
560
|
|
package/src/replace-undo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { open } from "node:fs/promises";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
@@ -24,6 +24,7 @@ export interface UndoEntry {
|
|
|
24
24
|
originalEnding: LineEnding;
|
|
25
25
|
hashes: string[];
|
|
26
26
|
resultContent: string;
|
|
27
|
+
mode?: number;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export async function saveUndo(
|
|
@@ -34,12 +35,18 @@ export async function saveUndo(
|
|
|
34
35
|
try {
|
|
35
36
|
const store = await loadHashStore();
|
|
36
37
|
previous = getUndoEntry(store, path);
|
|
38
|
+
let mode: number | undefined;
|
|
39
|
+
try {
|
|
40
|
+
mode = (await stat(path)).mode & 0o7777;
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
37
43
|
upsertUndo(store, path, {
|
|
38
44
|
content: entry.content,
|
|
39
45
|
bom: entry.bom,
|
|
40
46
|
ending: entry.originalEnding,
|
|
41
47
|
hashes: entry.hashes,
|
|
42
48
|
resultContent: entry.resultContent,
|
|
49
|
+
...(mode !== undefined ? { mode } : {}),
|
|
43
50
|
});
|
|
44
51
|
} catch (error) {
|
|
45
52
|
console.error("Failed to persist undo entry:", error);
|
|
@@ -75,6 +82,7 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
|
75
82
|
originalEnding,
|
|
76
83
|
hashes: record.hashes,
|
|
77
84
|
resultContent: record.resultContent,
|
|
85
|
+
...(record.mode !== undefined ? { mode: record.mode } : {}),
|
|
78
86
|
};
|
|
79
87
|
} catch (error) {
|
|
80
88
|
console.error("Failed to load undo entry:", error);
|
|
@@ -91,6 +99,10 @@ export async function clearUndo(path: string): Promise<void> {
|
|
|
91
99
|
}
|
|
92
100
|
}
|
|
93
101
|
|
|
102
|
+
function fallbackFileMode(): number {
|
|
103
|
+
return 0o666 & ~process.umask();
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
export function regUndo(pi: ExtensionAPI): void {
|
|
95
107
|
pi.registerTool({
|
|
96
108
|
name: "undo_last_change",
|
|
@@ -172,6 +184,7 @@ export function regUndo(pi: ExtensionAPI): void {
|
|
|
172
184
|
mutationTargetPath,
|
|
173
185
|
undo.bom + restoreEndings(undo.content, undo.originalEnding),
|
|
174
186
|
currentIdentity,
|
|
187
|
+
undo.mode ?? fallbackFileMode(),
|
|
175
188
|
);
|
|
176
189
|
|
|
177
190
|
const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
|