bunmicro 0.9.25 → 0.9.30
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/CHANGELOG.md +5 -0
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/buffer/backup.js +160 -0
- package/src/index.js +194 -25
- package/tests/backup.test.js +133 -0
- package/todo.txt +6 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
- Works like bat ccat glow
|
|
36
36
|
- bunmicro -bat file
|
|
37
37
|
- aliases: --cat --bat --ccat --glow
|
|
38
|
+
## Binary edit by hex3
|
|
39
|
+
- Edit binary files like text files
|
|
40
|
+
- bunmicro libc.so.6
|
|
41
|
+
- Ctrl+E reopen hex3
|
|
42
|
+
- Printable chars stay readable
|
|
43
|
+
* a => a..
|
|
44
|
+
- Non-printable chars become escapes
|
|
45
|
+
* \xff => \ff
|
|
46
|
+
- Search & Edit by plain text
|
|
47
|
+
- And simply save by Ctrl+S
|
|
38
48
|
## Preview color schemes(theme)
|
|
39
49
|
- Ctrl-E theme, then press Tab and use arrow keys to preview
|
|
40
50
|
## Mouse clicks more useful
|
package/package.json
CHANGED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Crash recovery backup system compatible with Go micro's internal/buffer/backup.go.
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { existsSync, statSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { mkdir, writeFile, readFile, rename } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
export const BACKUP_SUFFIX = ".micro-backup";
|
|
10
|
+
|
|
11
|
+
export function getBackupDir(buf, configDir) {
|
|
12
|
+
const raw = String(buf?.Settings?.backupdir ?? "");
|
|
13
|
+
if (!raw) return join(configDir, "backups");
|
|
14
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
15
|
+
return raw.replace(/^~/, homedir());
|
|
16
|
+
}
|
|
17
|
+
// Node has no portable equivalent of Go's user.Lookup for ~otheruser.
|
|
18
|
+
if (raw.startsWith("~")) return join(configDir, "backups");
|
|
19
|
+
return raw;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function queryEscapePath(path) {
|
|
23
|
+
return encodeURIComponent(String(path).replaceAll("\\", "/"))
|
|
24
|
+
.replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
25
|
+
.replace(/%20/g, "+");
|
|
26
|
+
} // '
|
|
27
|
+
|
|
28
|
+
function legacyEscapePath(path) {
|
|
29
|
+
let escaped = String(path).replaceAll("\\", "/");
|
|
30
|
+
if (process.platform === "win32") escaped = escaped.replaceAll(":", "%");
|
|
31
|
+
return escaped.replaceAll("/", "%");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function determineBackupPath(backupDirPath, absPath) {
|
|
35
|
+
const urlName = join(backupDirPath, queryEscapePath(absPath));
|
|
36
|
+
if (existsSync(urlName)) return { name: urlName, resolveName: null };
|
|
37
|
+
|
|
38
|
+
const legacyName = join(backupDirPath, legacyEscapePath(absPath));
|
|
39
|
+
if (existsSync(legacyName)) return { name: legacyName, resolveName: null };
|
|
40
|
+
|
|
41
|
+
if (Buffer.byteLength(urlName + BACKUP_SUFFIX) > 255) {
|
|
42
|
+
const hash = createHash("md5").update(absPath).digest("hex");
|
|
43
|
+
return {
|
|
44
|
+
name: join(backupDirPath, hash),
|
|
45
|
+
resolveName: join(backupDirPath, hash + ".path"),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { name: urlName, resolveName: null };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function writeBackup(buf, configDir, path = buf?.AbsPath ?? buf?.path, { force = false } = {}) {
|
|
52
|
+
if ((!force && !buf?.Settings?.backup) || !path || buf.type !== "default") return false;
|
|
53
|
+
const dir = getBackupDir(buf, configDir);
|
|
54
|
+
await mkdir(dir, { recursive: true });
|
|
55
|
+
const { name, resolveName } = determineBackupPath(dir, path);
|
|
56
|
+
const tmp = name + BACKUP_SUFFIX;
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(tmp, buf.lines.join("\n"), "utf8");
|
|
59
|
+
await rename(tmp, name);
|
|
60
|
+
if (resolveName) await writeFile(resolveName, path, "utf8");
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
try { unlinkSync(tmp); } catch {}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function removeBackup(buf, configDir, path = buf?.AbsPath ?? buf?.path) {
|
|
69
|
+
if (buf?.Settings?.permbackup || buf?._forceKeepBackup) return;
|
|
70
|
+
if (!path || buf.type !== "default") return;
|
|
71
|
+
const dir = getBackupDir(buf, configDir);
|
|
72
|
+
const { name, resolveName } = determineBackupPath(dir, path);
|
|
73
|
+
try { unlinkSync(name); } catch {}
|
|
74
|
+
if (resolveName) try { unlinkSync(resolveName); } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// promptFn(msg) -> Promise<string>
|
|
78
|
+
// Returns { recovered: bool, abort: bool }
|
|
79
|
+
export async function applyBackup(buf, configDir, promptFn) {
|
|
80
|
+
if (!buf?.Settings?.backup || buf?.Settings?.permbackup) return { recovered: false, abort: false };
|
|
81
|
+
if (!buf.path || buf.type !== "default") return { recovered: false, abort: false };
|
|
82
|
+
|
|
83
|
+
const dir = getBackupDir(buf, configDir);
|
|
84
|
+
const { name: backupFile, resolveName } = determineBackupPath(dir, buf.AbsPath ?? buf.path);
|
|
85
|
+
if (!existsSync(backupFile)) return { recovered: false, abort: false };
|
|
86
|
+
|
|
87
|
+
let info;
|
|
88
|
+
try { info = statSync(backupFile); } catch { return { recovered: false, abort: false }; }
|
|
89
|
+
|
|
90
|
+
const t = info.mtime;
|
|
91
|
+
const dateStr =
|
|
92
|
+
t.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }) +
|
|
93
|
+
" at " +
|
|
94
|
+
t.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) +
|
|
95
|
+
", " + t.getFullYear();
|
|
96
|
+
|
|
97
|
+
const ticcc='```';
|
|
98
|
+
|
|
99
|
+
const msg = `
|
|
100
|
+
# Backup detected! ⚠️
|
|
101
|
+
- File path/url:
|
|
102
|
+
${ticcc}
|
|
103
|
+
${buf.path}
|
|
104
|
+
${ticcc}
|
|
105
|
+
- either micro crashed
|
|
106
|
+
- or another micro is running
|
|
107
|
+
- or an error occurred while saving
|
|
108
|
+
- The file may be corrupted
|
|
109
|
+
# Date of backup 🕰
|
|
110
|
+
- ${dateStr}
|
|
111
|
+
# Path of backup
|
|
112
|
+
${ticcc}
|
|
113
|
+
${backupFile}
|
|
114
|
+
${ticcc}
|
|
115
|
+
# Recovery options
|
|
116
|
+
## r = 'recover'
|
|
117
|
+
- Apply the backup
|
|
118
|
+
- as unsaved changes to the current buffer
|
|
119
|
+
- When the buffer is closed, the backup will be removed.
|
|
120
|
+
## i = 'ignore'
|
|
121
|
+
- Ignore & remove the backup
|
|
122
|
+
## a = 'abort'
|
|
123
|
+
- Abort the open operation
|
|
124
|
+
- Open an empty buffer
|
|
125
|
+
- Keep the backup
|
|
126
|
+
### Your choice
|
|
127
|
+
- [r]ecover, [i]gnore, [a]bort
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const options = ["r", "i", "a", "recover", "ignore", "abort"];
|
|
131
|
+
let choice = -1;
|
|
132
|
+
let prompt = msg;
|
|
133
|
+
while (choice === -1) {
|
|
134
|
+
const resp = await promptFn(prompt);
|
|
135
|
+
const idx = options.indexOf(resp.trim().toLowerCase());
|
|
136
|
+
if (idx !== -1) choice = idx % 3;
|
|
137
|
+
else prompt = "\n#### Invalid choice!";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (choice === 0) {
|
|
141
|
+
try {
|
|
142
|
+
const text = await readFile(backupFile, "utf8");
|
|
143
|
+
buf.lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
144
|
+
if (!buf.lines.length) buf.lines = [""];
|
|
145
|
+
buf._recovered = true;
|
|
146
|
+
buf._savedSerial = -1;
|
|
147
|
+
buf.setModified?.(true);
|
|
148
|
+
if (!buf.setModified) buf.modified = true;
|
|
149
|
+
return { recovered: true, abort: false };
|
|
150
|
+
} catch {
|
|
151
|
+
return { recovered: false, abort: false };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (choice === 1) {
|
|
155
|
+
try { unlinkSync(backupFile); } catch {}
|
|
156
|
+
if (resolveName) try { unlinkSync(resolveName); } catch {}
|
|
157
|
+
return { recovered: false, abort: false };
|
|
158
|
+
}
|
|
159
|
+
return { recovered: false, abort: true };
|
|
160
|
+
}
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,8 @@ import { platformId, run as runCommand, runSync, fetchHttpBytes, detectHttpBacke
|
|
|
23
23
|
import { shellSplit } from "./shell/shell.js";
|
|
24
24
|
import { styleToAnsi } from "./display/ansi-style.js";
|
|
25
25
|
import { encodeBinaryToBuffer, decodeBinaryBytes } from "./buffer/fixed3-codec.js";
|
|
26
|
+
import { writeBackup, removeBackup, applyBackup } from "./buffer/backup.js";
|
|
27
|
+
import { createInterface } from "node:readline/promises";
|
|
26
28
|
|
|
27
29
|
import pkg from "../package.json" with { type: "json" };
|
|
28
30
|
|
|
@@ -92,6 +94,9 @@ const DEFAULT_SETTINGS = {
|
|
|
92
94
|
matchbraceleft: true,
|
|
93
95
|
matchbracestyle: "underline",
|
|
94
96
|
savecursor: false,
|
|
97
|
+
backup: true,
|
|
98
|
+
backupdir: "",
|
|
99
|
+
permbackup: false,
|
|
95
100
|
softwrap: false,
|
|
96
101
|
wordwrap: false,
|
|
97
102
|
pageoverlap: 2,
|
|
@@ -119,6 +124,20 @@ function write(data) {
|
|
|
119
124
|
process.stdout.write(data);
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
// Pre-TUI terminal prompt — stdin must still be in line (cooked) mode.
|
|
128
|
+
// Accepts an optional input stream so the TUI path can pass its own tty fd.
|
|
129
|
+
async function termPromptLine(msg, input = process.stdin) {
|
|
130
|
+
const rl = createInterface({ input, output: process.stdout });
|
|
131
|
+
try {
|
|
132
|
+
console.log(Bun.markdown.ansi(msg))
|
|
133
|
+
return await rl.question("> ");
|
|
134
|
+
} catch {
|
|
135
|
+
return "";
|
|
136
|
+
} finally {
|
|
137
|
+
rl.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
function sgr(...codes) {
|
|
123
142
|
return `\x1b[${codes.join(";")}m`;
|
|
124
143
|
}
|
|
@@ -611,7 +630,15 @@ class BufferModel {
|
|
|
611
630
|
if (this.lines.length === 0) this.lines = [""];
|
|
612
631
|
this.cursor = { x: 0, y: 0 };
|
|
613
632
|
this.scroll = { x: 0, y: 0, row: 0 };
|
|
614
|
-
this.
|
|
633
|
+
this._modified = false;
|
|
634
|
+
this._backupRequested = false;
|
|
635
|
+
this._backupRevision = 0;
|
|
636
|
+
Object.defineProperty(this, "modified", {
|
|
637
|
+
configurable: true,
|
|
638
|
+
enumerable: true,
|
|
639
|
+
get: () => this._modified,
|
|
640
|
+
set: (value) => this.setModified(value),
|
|
641
|
+
});
|
|
615
642
|
this.readonly = readonly;
|
|
616
643
|
this.modTimeMs = modTimeMs;
|
|
617
644
|
this.reloadDisabled = false;
|
|
@@ -644,6 +671,9 @@ class BufferModel {
|
|
|
644
671
|
matchbraceleft: DEFAULT_SETTINGS.matchbraceleft,
|
|
645
672
|
matchbracestyle: DEFAULT_SETTINGS.matchbracestyle,
|
|
646
673
|
savecursor: DEFAULT_SETTINGS.savecursor,
|
|
674
|
+
backup: DEFAULT_SETTINGS.backup,
|
|
675
|
+
backupdir: DEFAULT_SETTINGS.backupdir,
|
|
676
|
+
permbackup: DEFAULT_SETTINGS.permbackup,
|
|
647
677
|
softwrap: DEFAULT_SETTINGS.softwrap,
|
|
648
678
|
wordwrap: DEFAULT_SETTINGS.wordwrap,
|
|
649
679
|
pageoverlap: DEFAULT_SETTINGS.pageoverlap,
|
|
@@ -674,6 +704,19 @@ class BufferModel {
|
|
|
674
704
|
if (command.searchRegex) this.search(command.searchRegex, command.searchAfterStart);
|
|
675
705
|
}
|
|
676
706
|
|
|
707
|
+
setModified(value = true) {
|
|
708
|
+
const next = Boolean(value);
|
|
709
|
+
const prev = this._modified;
|
|
710
|
+
this._modified = next;
|
|
711
|
+
if (next) {
|
|
712
|
+
this._backupRequested = true;
|
|
713
|
+
this._backupRevision++;
|
|
714
|
+
} else {
|
|
715
|
+
this._backupRequested = false;
|
|
716
|
+
if (prev && this._configDir) removeBackup(this, this._configDir);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
677
720
|
static async fromFile(path, command, context = {}) {
|
|
678
721
|
let text = "";
|
|
679
722
|
let readonly = false;
|
|
@@ -689,6 +732,7 @@ class BufferModel {
|
|
|
689
732
|
encoding = decoded.encoding;
|
|
690
733
|
}
|
|
691
734
|
const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding });
|
|
735
|
+
buffer._configDir = context?.config?.configDir ?? null;
|
|
692
736
|
attachSyntax(buffer, context, path, text);
|
|
693
737
|
return buffer;
|
|
694
738
|
}
|
|
@@ -1101,39 +1145,74 @@ class BufferModel {
|
|
|
1101
1145
|
async save(path = this.path) {
|
|
1102
1146
|
if (!path) throw new Error("No filename");
|
|
1103
1147
|
const detectSyntaxAfterSave = this.filetype === "unknown";
|
|
1148
|
+
const oldPath = this.AbsPath || this.path;
|
|
1149
|
+
const targetPath = resolve(path);
|
|
1104
1150
|
let text = this.lines.join("\n");
|
|
1151
|
+
if (this._configDir) {
|
|
1152
|
+
if (this._backupWritePromise) {
|
|
1153
|
+
try { await this._backupWritePromise; } catch {}
|
|
1154
|
+
}
|
|
1155
|
+
const backupRevision = this._backupRevision;
|
|
1156
|
+
const job = writeBackup(this, this._configDir, targetPath, { force: true });
|
|
1157
|
+
this._backupWritePromise = job;
|
|
1158
|
+
try {
|
|
1159
|
+
await job;
|
|
1160
|
+
} finally {
|
|
1161
|
+
if (this._backupWritePromise === job) this._backupWritePromise = null;
|
|
1162
|
+
}
|
|
1163
|
+
if (this._backupRevision === backupRevision) this._backupRequested = false;
|
|
1164
|
+
this._forceKeepBackup = true;
|
|
1165
|
+
}
|
|
1105
1166
|
if (this.encoding === "hex3") {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1167
|
+
try {
|
|
1168
|
+
await Bun.write(targetPath, decodeBinaryBytes(Buffer.from(text, "latin1")));
|
|
1169
|
+
} finally {
|
|
1170
|
+
this._forceKeepBackup = false;
|
|
1171
|
+
}
|
|
1172
|
+
this.path = targetPath;
|
|
1173
|
+
this.Path = targetPath;
|
|
1174
|
+
this.AbsPath = targetPath;
|
|
1175
|
+
this.name = basename(targetPath);
|
|
1111
1176
|
this.updateModTime();
|
|
1112
1177
|
this.readonly = !canWritePath(path);
|
|
1113
1178
|
this.Settings.readonly = this.readonly;
|
|
1114
1179
|
this.Type.Readonly = this.readonly;
|
|
1115
1180
|
this._savedSerial = this._undoSerial ?? 0;
|
|
1116
1181
|
this.modified = false;
|
|
1117
|
-
this.message = `Saved ${
|
|
1118
|
-
if (
|
|
1182
|
+
this.message = `Saved ${targetPath}`;
|
|
1183
|
+
if (this._configDir && oldPath !== targetPath) removeBackup(this, this._configDir, oldPath);
|
|
1184
|
+
this._updateOpenBufferPath(oldPath, targetPath);
|
|
1185
|
+
if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, targetPath, text);
|
|
1119
1186
|
return;
|
|
1120
1187
|
}
|
|
1121
1188
|
if ((this.Settings.eofnewline ?? DEFAULT_SETTINGS.eofnewline) && !text.endsWith("\n")) text += "\n";
|
|
1122
|
-
|
|
1189
|
+
try {
|
|
1190
|
+
await Bun.write(targetPath, encodeBufferTextForFile(text, this.Settings.fileformat ?? this.fileformat));
|
|
1191
|
+
} finally {
|
|
1192
|
+
this._forceKeepBackup = false;
|
|
1193
|
+
}
|
|
1123
1194
|
this.encoding = "utf-8";
|
|
1124
1195
|
this.Settings.encoding = "utf-8";
|
|
1125
|
-
this.path =
|
|
1126
|
-
this.Path =
|
|
1127
|
-
this.AbsPath =
|
|
1128
|
-
this.name = basename(
|
|
1196
|
+
this.path = targetPath;
|
|
1197
|
+
this.Path = targetPath;
|
|
1198
|
+
this.AbsPath = targetPath;
|
|
1199
|
+
this.name = basename(targetPath);
|
|
1129
1200
|
this.updateModTime();
|
|
1130
1201
|
this.readonly = !canWritePath(path);
|
|
1131
1202
|
this.Settings.readonly = this.readonly;
|
|
1132
1203
|
this.Type.Readonly = this.readonly;
|
|
1133
1204
|
this._savedSerial = this._undoSerial ?? 0;
|
|
1134
1205
|
this.modified = false;
|
|
1135
|
-
this.message = `Saved ${
|
|
1136
|
-
if (
|
|
1206
|
+
this.message = `Saved ${targetPath}`;
|
|
1207
|
+
if (this._configDir && oldPath !== targetPath) removeBackup(this, this._configDir, oldPath);
|
|
1208
|
+
this._updateOpenBufferPath(oldPath, targetPath);
|
|
1209
|
+
if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, targetPath, text);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
_updateOpenBufferPath(oldPath, newPath) {
|
|
1213
|
+
if (!this._openBufferMap) return;
|
|
1214
|
+
if (oldPath && this._openBufferMap.get(oldPath) === this) this._openBufferMap.delete(oldPath);
|
|
1215
|
+
this._openBufferMap.set(newPath, this);
|
|
1137
1216
|
}
|
|
1138
1217
|
|
|
1139
1218
|
// --- Autocomplete (BufferComplete) ---
|
|
@@ -1700,11 +1779,15 @@ class App {
|
|
|
1700
1779
|
get buffer() { return this.pane?.buffer ?? null; }
|
|
1701
1780
|
// backward-compat for the few spots that still use this.active / this.buffers
|
|
1702
1781
|
get active() { return this.activeTabIdx; }
|
|
1703
|
-
get buffers() {
|
|
1782
|
+
get buffers() {
|
|
1783
|
+
return [...new Set(this.tabs.flatMap((tab) =>
|
|
1784
|
+
tab.panes().flatMap((pane) => [pane.buffer, pane.prevBuffer]).filter(Boolean)
|
|
1785
|
+
))];
|
|
1786
|
+
}
|
|
1704
1787
|
|
|
1705
1788
|
paneForBuffer(buffer) {
|
|
1706
1789
|
for (const tab of this.tabs) {
|
|
1707
|
-
const pane = tab.panes().find((p) => p.buffer === buffer);
|
|
1790
|
+
const pane = tab.panes().find((p) => p.buffer === buffer || p.prevBuffer === buffer);
|
|
1708
1791
|
if (pane) return pane;
|
|
1709
1792
|
}
|
|
1710
1793
|
return null;
|
|
@@ -1755,6 +1838,45 @@ class App {
|
|
|
1755
1838
|
});
|
|
1756
1839
|
process.on("SIGINT", () => {}); // Ctrl+C is handled as copy in handleEvent
|
|
1757
1840
|
this.screen.init();
|
|
1841
|
+
// Update backup prompt to screen-aware version now that TUI is running.
|
|
1842
|
+
if (this.context._termPrompt) {
|
|
1843
|
+
this.context._termPrompt = async (msg) => {
|
|
1844
|
+
const tty = this._ttyStream ?? process.stdin;
|
|
1845
|
+
if (this._inputHandler) tty.removeListener("data", this._inputHandler);
|
|
1846
|
+
tty.setRawMode?.(false);
|
|
1847
|
+
this.screen.fini();
|
|
1848
|
+
process.stdout.write("\n");
|
|
1849
|
+
const answer = await termPromptLine(msg, tty);
|
|
1850
|
+
this.screen.previous = null;
|
|
1851
|
+
this.screen.init();
|
|
1852
|
+
tty.setRawMode?.(true);
|
|
1853
|
+
tty.resume(); // rl.close() pauses the stream; resume so data events fire again
|
|
1854
|
+
if (this._inputHandler) tty.on("data", this._inputHandler);
|
|
1855
|
+
return answer;
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
// Process buffers requested by edits. A successful backup is not repeated
|
|
1859
|
+
// until the buffer is modified again.
|
|
1860
|
+
const configDir = this.context?.config?.configDir;
|
|
1861
|
+
if (configDir) {
|
|
1862
|
+
this._backupTimer = setInterval(async () => {
|
|
1863
|
+
for (const buf of this.buffers) {
|
|
1864
|
+
if (buf._backupRequested && buf.modified && buf.path && buf.type === "default" &&
|
|
1865
|
+
(buf.Settings?.backup ?? DEFAULT_SETTINGS.backup) && !buf._backupWritePromise) {
|
|
1866
|
+
const revision = buf._backupRevision;
|
|
1867
|
+
const job = writeBackup(buf, configDir);
|
|
1868
|
+
buf._backupWritePromise = job;
|
|
1869
|
+
try {
|
|
1870
|
+
if (await job) {
|
|
1871
|
+
if (buf._backupRevision === revision) buf._backupRequested = false;
|
|
1872
|
+
}
|
|
1873
|
+
} catch {} finally {
|
|
1874
|
+
if (buf._backupWritePromise === job) buf._backupWritePromise = null;
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}, 10_000);
|
|
1879
|
+
}
|
|
1758
1880
|
startupHighlightProgress = new StartupHighlightProgress(this);
|
|
1759
1881
|
try {
|
|
1760
1882
|
this.render();
|
|
@@ -1774,6 +1896,8 @@ class App {
|
|
|
1774
1896
|
|
|
1775
1897
|
async stop(code = 0) {
|
|
1776
1898
|
this.running = false;
|
|
1899
|
+
if (this._backupTimer) { clearInterval(this._backupTimer); this._backupTimer = null; }
|
|
1900
|
+
await Promise.allSettled(this.buffers.map((buf) => buf._backupWritePromise).filter(Boolean));
|
|
1777
1901
|
for (const tab of this.tabs)
|
|
1778
1902
|
for (const p of tab.panes())
|
|
1779
1903
|
if (p.type === "term") p.terminal?.close();
|
|
@@ -1790,6 +1914,10 @@ class App {
|
|
|
1790
1914
|
}
|
|
1791
1915
|
try { await saveCursorStates(this.context.config.configDir, this.context.cursorStates); } catch {}
|
|
1792
1916
|
}
|
|
1917
|
+
const configDir = this.context?.config?.configDir;
|
|
1918
|
+
if (configDir) {
|
|
1919
|
+
for (const buf of this.buffers) removeBackup(buf, configDir);
|
|
1920
|
+
}
|
|
1793
1921
|
process.exit(code);
|
|
1794
1922
|
}
|
|
1795
1923
|
|
|
@@ -3859,9 +3987,11 @@ class App {
|
|
|
3859
3987
|
}
|
|
3860
3988
|
async openInPane(path) {
|
|
3861
3989
|
try {
|
|
3990
|
+
const previous = this.pane.buffer;
|
|
3862
3991
|
const buffer = await loadBufferForPath(path, this.context);
|
|
3863
3992
|
this.pane.buffer = buffer;
|
|
3864
3993
|
this.pane.selection = null;
|
|
3994
|
+
if (previous !== buffer) this._closeBufferIfUnused(previous);
|
|
3865
3995
|
await this.context.plugins?.run("onBufferOpen", buffer);
|
|
3866
3996
|
await this.context.jsPlugins?.run("onBufferOpen", buffer);
|
|
3867
3997
|
} catch (error) {
|
|
@@ -3873,8 +4003,10 @@ class App {
|
|
|
3873
4003
|
try {
|
|
3874
4004
|
const buffer = await loadBufferForPath(path, this.context);
|
|
3875
4005
|
if (isEmptyUntitledBuffer(this.buffer)) {
|
|
4006
|
+
const previous = this.pane.buffer;
|
|
3876
4007
|
this.pane.buffer = buffer;
|
|
3877
4008
|
this.pane.selection = null;
|
|
4009
|
+
if (previous !== buffer) this._closeBufferIfUnused(previous);
|
|
3878
4010
|
} else {
|
|
3879
4011
|
const tab = new Tab(new Pane(buffer));
|
|
3880
4012
|
this.tabs.push(tab);
|
|
@@ -4057,8 +4189,10 @@ class App {
|
|
|
4057
4189
|
|
|
4058
4190
|
closePane(pane) {
|
|
4059
4191
|
pane.terminal?.close();
|
|
4192
|
+
const closingBuffers = [...new Set([pane.buffer, pane.prevBuffer].filter(Boolean))];
|
|
4060
4193
|
const tab = this.tab;
|
|
4061
4194
|
tab.removePane(pane);
|
|
4195
|
+
for (const buffer of closingBuffers) this._closeBufferIfUnused(buffer);
|
|
4062
4196
|
if (!tab.root) {
|
|
4063
4197
|
// Tab is empty — close it
|
|
4064
4198
|
if (this.tabs.length <= 1) { this.stop(0); return; }
|
|
@@ -4073,10 +4207,12 @@ class App {
|
|
|
4073
4207
|
await this.stop(0);
|
|
4074
4208
|
return;
|
|
4075
4209
|
}
|
|
4210
|
+
const closingBuffers = [...new Set(this.tab.panes().flatMap((pane) => [pane.buffer, pane.prevBuffer]).filter(Boolean))];
|
|
4076
4211
|
const closing = this.buffer;
|
|
4077
4212
|
this.tabs.splice(this.activeTabIdx, 1);
|
|
4078
4213
|
this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
|
|
4079
4214
|
this.message = "";
|
|
4215
|
+
for (const buffer of closingBuffers) this._closeBufferIfUnused(buffer);
|
|
4080
4216
|
if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
|
|
4081
4217
|
await this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
|
|
4082
4218
|
await this.context.plugins?.run("onBufferClose", closing);
|
|
@@ -4085,6 +4221,14 @@ class App {
|
|
|
4085
4221
|
this.render();
|
|
4086
4222
|
}
|
|
4087
4223
|
|
|
4224
|
+
_closeBufferIfUnused(buffer) {
|
|
4225
|
+
if (!buffer || this.paneForBuffer(buffer)) return;
|
|
4226
|
+
const configDir = this.context?.config?.configDir;
|
|
4227
|
+
if (configDir) removeBackup(buffer, configDir);
|
|
4228
|
+
const map = this.context?._openBuffers;
|
|
4229
|
+
if (map && map.get(buffer.AbsPath) === buffer) map.delete(buffer.AbsPath);
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4088
4232
|
openCommandMode(initial = "") {
|
|
4089
4233
|
const originalColorscheme = this.context.colorscheme;
|
|
4090
4234
|
const previewTheme = async (value) => {
|
|
@@ -4442,25 +4586,27 @@ class App {
|
|
|
4442
4586
|
case "vsplit": {
|
|
4443
4587
|
let newBuf;
|
|
4444
4588
|
if (cmdArgs.length > 0) {
|
|
4445
|
-
try { newBuf = await
|
|
4589
|
+
try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
|
|
4446
4590
|
catch (err) { this.message = err.message; break; }
|
|
4447
4591
|
} else {
|
|
4448
4592
|
newBuf = new BufferModel({ command: {} });
|
|
4449
4593
|
attachSyntax(newBuf, this.context, "", "");
|
|
4450
4594
|
}
|
|
4451
4595
|
this.tab.split(this.pane, new Pane(newBuf), "h");
|
|
4596
|
+
this.render();
|
|
4452
4597
|
break;
|
|
4453
4598
|
}
|
|
4454
4599
|
case "hsplit": {
|
|
4455
4600
|
let newBuf;
|
|
4456
4601
|
if (cmdArgs.length > 0) {
|
|
4457
|
-
try { newBuf = await
|
|
4602
|
+
try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
|
|
4458
4603
|
catch (err) { this.message = err.message; break; }
|
|
4459
4604
|
} else {
|
|
4460
4605
|
newBuf = new BufferModel({ command: {} });
|
|
4461
4606
|
attachSyntax(newBuf, this.context, "", "");
|
|
4462
4607
|
}
|
|
4463
4608
|
this.tab.split(this.pane, new Pane(newBuf), "v");
|
|
4609
|
+
this.render();
|
|
4464
4610
|
break;
|
|
4465
4611
|
}
|
|
4466
4612
|
case "term": {
|
|
@@ -5731,9 +5877,26 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
|
|
|
5731
5877
|
encoding = decoded.encoding;
|
|
5732
5878
|
const urlPath = pathOrUrl.replace(/[?#].*$/, "");
|
|
5733
5879
|
buffer = new BufferModel({ path: pathOrUrl, text, command, encoding });
|
|
5880
|
+
buffer._configDir = context?.config?.configDir ?? null;
|
|
5734
5881
|
attachSyntax(buffer, context, urlPath, text);
|
|
5735
5882
|
} else {
|
|
5736
|
-
|
|
5883
|
+
if (!context._openBuffers) context._openBuffers = new Map();
|
|
5884
|
+
const absPath = resolve(pathOrUrl);
|
|
5885
|
+
const existing = context._openBuffers.get(absPath);
|
|
5886
|
+
if (existing) return existing;
|
|
5887
|
+
buffer = await BufferModel.fromFile(absPath, command, context);
|
|
5888
|
+
// Check for crash-recovery backup before returning the buffer.
|
|
5889
|
+
const promptFn = context._termPrompt;
|
|
5890
|
+
if (promptFn && buffer._configDir) {
|
|
5891
|
+
const { recovered, abort } = await applyBackup(buffer, buffer._configDir, promptFn);
|
|
5892
|
+
if (abort) return new BufferModel({ command });
|
|
5893
|
+
if (recovered) {
|
|
5894
|
+
buffer.ensureCursor();
|
|
5895
|
+
attachSyntax(buffer, context, absPath, buffer.lines.join("\n"));
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5898
|
+
buffer._openBufferMap = context._openBuffers;
|
|
5899
|
+
context._openBuffers.set(absPath, buffer);
|
|
5737
5900
|
}
|
|
5738
5901
|
if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
|
|
5739
5902
|
const saved = context.cursorStates[pathOrUrl];
|
|
@@ -6115,9 +6278,13 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
|
|
|
6115
6278
|
else if (key === "ispace") indentspacechars = val;
|
|
6116
6279
|
else if (key === "itab") indenttabchars = val;
|
|
6117
6280
|
}
|
|
6118
|
-
//
|
|
6281
|
+
// Only inspect visible leading whitespace. Once horizontally scrolled, the
|
|
6282
|
+
// line start is off-screen and should not make redraw cost depend on it.
|
|
6119
6283
|
let leadingwsEnd = 0;
|
|
6120
|
-
|
|
6284
|
+
if (scrollX === 0) {
|
|
6285
|
+
const visibleEnd = Math.min(raw.length, maxWidth);
|
|
6286
|
+
while (leadingwsEnd < visibleEnd && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
|
|
6287
|
+
}
|
|
6121
6288
|
|
|
6122
6289
|
const hltaberrors = buf.Settings?.hltaberrors ?? false;
|
|
6123
6290
|
const tabstospaces = buf.Settings?.tabstospaces ?? false;
|
|
@@ -6133,9 +6300,9 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
|
|
|
6133
6300
|
: null;
|
|
6134
6301
|
const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
|
|
6135
6302
|
|
|
6136
|
-
//
|
|
6137
|
-
//
|
|
6138
|
-
const scrollVisualCol =
|
|
6303
|
+
// Keep horizontal rendering bounded to the visible range. Reconstructing
|
|
6304
|
+
// the exact display width before scrollX makes long-line redraws O(scrollX).
|
|
6305
|
+
const scrollVisualCol = scrollX;
|
|
6139
6306
|
|
|
6140
6307
|
// Linter messages overlapping this line (Go bufwindow.go:662-668)
|
|
6141
6308
|
const lineMessages = (buf.Messages ?? []).filter((m) => {
|
|
@@ -6532,6 +6699,8 @@ async function main() {
|
|
|
6532
6699
|
if (DEFAULT_SETTINGS.savecursor) {
|
|
6533
6700
|
context.cursorStates = await loadCursorStates(config.configDir);
|
|
6534
6701
|
}
|
|
6702
|
+
// Backup prompt available before App starts (stdin still in cooked mode).
|
|
6703
|
+
context._termPrompt = process.stdout.isTTY ? termPromptLine : null;
|
|
6535
6704
|
loadBuffers.context = context;
|
|
6536
6705
|
const buffers = await loadBuffers(files.map((file) =>
|
|
6537
6706
|
isHttpUrl(file) ? file : resolve(file)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
BACKUP_SUFFIX,
|
|
9
|
+
applyBackup,
|
|
10
|
+
determineBackupPath,
|
|
11
|
+
removeBackup,
|
|
12
|
+
writeBackup,
|
|
13
|
+
} from "../src/buffer/backup.js";
|
|
14
|
+
|
|
15
|
+
const cleanup = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function tempDir() {
|
|
22
|
+
const path = await mkdtemp(join(tmpdir(), "bunmicro-backup-"));
|
|
23
|
+
cleanup.push(path);
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buffer(path, text = "changed") {
|
|
28
|
+
return {
|
|
29
|
+
path,
|
|
30
|
+
AbsPath: path,
|
|
31
|
+
type: "default",
|
|
32
|
+
lines: text.split("\n"),
|
|
33
|
+
Settings: { backup: true, backupdir: "", permbackup: false },
|
|
34
|
+
_savedSerial: 0,
|
|
35
|
+
setModified(value) { this.modified = Boolean(value); },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("go-micro compatible backup paths", () => {
|
|
40
|
+
test("uses Go url.QueryEscape-compatible names", async () => {
|
|
41
|
+
const dir = await tempDir();
|
|
42
|
+
const result = determineBackupPath(dir, "/tmp/a b%~!'()*.txt");
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
name: join(dir, "%2Ftmp%2Fa+b%25~%21%27%28%29%2A.txt"),
|
|
45
|
+
resolveName: null,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("prefers an existing legacy name", async () => {
|
|
50
|
+
const dir = await tempDir();
|
|
51
|
+
const legacy = join(dir, "%tmp%legacy.txt");
|
|
52
|
+
await writeFile(legacy, "legacy");
|
|
53
|
+
expect(determineBackupPath(dir, "/tmp/legacy.txt").name).toBe(legacy);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("uses Go's full MD5 hash and .path sidecar for long names", async () => {
|
|
57
|
+
const dir = await tempDir();
|
|
58
|
+
const path = "/" + "x".repeat(300);
|
|
59
|
+
const hash = createHash("md5").update(path).digest("hex");
|
|
60
|
+
expect(determineBackupPath(dir, path)).toEqual({
|
|
61
|
+
name: join(dir, hash),
|
|
62
|
+
resolveName: join(dir, hash + ".path"),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("backup lifecycle", () => {
|
|
68
|
+
test("writes atomically using the Go backup suffix", async () => {
|
|
69
|
+
const root = await tempDir();
|
|
70
|
+
const backupDir = join(root, "backups");
|
|
71
|
+
const buf = buffer("/tmp/file.txt", "one\ntwo");
|
|
72
|
+
buf.Settings.backupdir = backupDir;
|
|
73
|
+
const target = determineBackupPath(backupDir, buf.AbsPath);
|
|
74
|
+
|
|
75
|
+
expect(await writeBackup(buf, root)).toBe(true);
|
|
76
|
+
expect(await readFile(target.name, "utf8")).toBe("one\ntwo");
|
|
77
|
+
expect(existsSync(target.name + BACKUP_SUFFIX)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("recover keeps the backup and marks a distinct dirty baseline", async () => {
|
|
81
|
+
const root = await tempDir();
|
|
82
|
+
const backupDir = join(root, "backups");
|
|
83
|
+
await mkdir(backupDir);
|
|
84
|
+
const buf = buffer("/tmp/file.txt", "disk");
|
|
85
|
+
buf.Settings.backupdir = backupDir;
|
|
86
|
+
const target = determineBackupPath(backupDir, buf.AbsPath);
|
|
87
|
+
await writeFile(target.name, "recovered");
|
|
88
|
+
|
|
89
|
+
expect(await applyBackup(buf, root, async () => "recover")).toEqual({ recovered: true, abort: false });
|
|
90
|
+
expect(buf.lines).toEqual(["recovered"]);
|
|
91
|
+
expect(buf.modified).toBe(true);
|
|
92
|
+
expect(buf._savedSerial).toBe(-1);
|
|
93
|
+
expect(existsSync(target.name)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("ignore removes the backup", async () => {
|
|
97
|
+
const root = await tempDir();
|
|
98
|
+
const backupDir = join(root, "backups");
|
|
99
|
+
await mkdir(backupDir);
|
|
100
|
+
const buf = buffer("/tmp/file.txt");
|
|
101
|
+
buf.Settings.backupdir = backupDir;
|
|
102
|
+
const target = determineBackupPath(backupDir, buf.AbsPath);
|
|
103
|
+
await writeFile(target.name, "ignored");
|
|
104
|
+
|
|
105
|
+
expect(await applyBackup(buf, root, async () => "ignore")).toEqual({ recovered: false, abort: false });
|
|
106
|
+
expect(existsSync(target.name)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("permanent backups survive removal", async () => {
|
|
110
|
+
const root = await tempDir();
|
|
111
|
+
const backupDir = join(root, "backups");
|
|
112
|
+
const buf = buffer("/tmp/file.txt");
|
|
113
|
+
buf.Settings.backupdir = backupDir;
|
|
114
|
+
buf.Settings.permbackup = true;
|
|
115
|
+
await writeBackup(buf, root);
|
|
116
|
+
const target = determineBackupPath(backupDir, buf.AbsPath);
|
|
117
|
+
|
|
118
|
+
removeBackup(buf, root);
|
|
119
|
+
expect(existsSync(target.name)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("forced safe-write backups work when periodic backups are disabled", async () => {
|
|
123
|
+
const root = await tempDir();
|
|
124
|
+
const backupDir = join(root, "backups");
|
|
125
|
+
const buf = buffer("/tmp/file.txt");
|
|
126
|
+
buf.Settings.backupdir = backupDir;
|
|
127
|
+
buf.Settings.backup = false;
|
|
128
|
+
|
|
129
|
+
expect(await writeBackup(buf, root)).toBe(false);
|
|
130
|
+
expect(await writeBackup(buf, root, buf.AbsPath, { force: true })).toBe(true);
|
|
131
|
+
expect(existsSync(determineBackupPath(backupDir, buf.AbsPath).name)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/todo.txt
CHANGED
|
@@ -116,7 +116,12 @@ Buffer / editing model
|
|
|
116
116
|
Done: saving a non-UTF-8-decoded buffer prompts "Save in UTF-8?(y,n)" before converting the buffer to UTF-8 on disk.
|
|
117
117
|
Done: hex3 encoding supports binary edit/open/save paths without UTF-8 conversion prompt.
|
|
118
118
|
Remaining: non-UTF-8 save/encode, rmtrailingws, mkparents, autosu/sucmd, full fileformat behavior parity.
|
|
119
|
-
[
|
|
119
|
+
[x] Implement backup recovery and permbackup behavior.
|
|
120
|
+
- src/buffer/backup.js: writeBackup/removeBackup/applyBackup; path-escaped flat filename in configDir/backups/ (>200 bytes falls back to SHA-256 hash + .resolve sidecar).
|
|
121
|
+
- Periodic backup timer (10s) in App.start() writes all modified default buffers.
|
|
122
|
+
- applyBackup called in loadBufferForPath (covers open/vsplit/hsplit/startup) via context._termPrompt; pre-TUI uses readline/promises, in-TUI uses screen fini/init wrapper.
|
|
123
|
+
- removeBackup called on save, closePane, closeCurrentTab, and stop (all buffers).
|
|
124
|
+
- backup/backupdir/permbackup settings wired through DEFAULT_SETTINGS → buf.Settings → syncEditorSettings.
|
|
120
125
|
[~] Implement savecursor and saveundo serialization.
|
|
121
126
|
Done: savecursor saves cursor position to configDir/buffers/cursor_state.json on Ctrl-S and on quit; position is restored when re-opening the same file; syncs from Go micro settings.json.
|
|
122
127
|
Done: savecursor restore now vertically centers the viewport on the restored cursor position (deferred via _pendingCenterScroll flag, resolved on first render after layout is computed; handles softwrap/non-softwrap via _ttsScrollToCenter).
|