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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.30] - 2026-06-13
4
+ - Fixed binary edit hex3 regression
5
+ * see readme
6
+ - Added crash backup recovery
7
+
3
8
  ## [0.9.25] - 2026-06-10
4
9
  - Fixed colorscheme: aligned with go
5
10
  - Added colorcolumn taberror showchars
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmicro",
3
- "version": "0.9.25",
3
+ "version": "0.9.30",
4
4
  "description": "Bun JavaScript rewrite of the micro editor originally in Golang",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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.modified = false;
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
- await Bun.write(path, decodeBinaryBytes(Buffer.from(text, "latin1")));
1107
- this.path = path;
1108
- this.Path = path;
1109
- this.AbsPath = path;
1110
- this.name = basename(path);
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 ${path}`;
1118
- if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, path, text);
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
- await Bun.write(path, encodeBufferTextForFile(text, this.Settings.fileformat ?? this.fileformat));
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 = path;
1126
- this.Path = path;
1127
- this.AbsPath = path;
1128
- this.name = basename(path);
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 ${path}`;
1136
- if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, path, text);
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() { return this.tabs.map(t => t.buffer).filter(Boolean); }
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 BufferModel.fromFile(resolve(expandHome(cmdArgs[0])), {}, this.context); }
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 BufferModel.fromFile(resolve(expandHome(cmdArgs[0])), {}, this.context); }
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
- buffer = await BufferModel.fromFile(pathOrUrl, command, context);
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
- // leadingwsEnd: index of first non-whitespace char (raw code-unit index)
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
- while (leadingwsEnd < raw.length && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
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
- // scrollVisualCol: visual column of raw[0..scrollX). Uses displayWidth (tab = full
6137
- // tabsize, not aligned-to-boundary) to stay consistent with cursor/scroll math.
6138
- const scrollVisualCol = displayWidth(raw.slice(0, scrollX));
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
- [ ] Implement backup recovery and permbackup behavior.
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).