bunmicro 0.9.25 → 1.0.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/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
  }
@@ -497,6 +516,8 @@ function parseArgs(argv) {
497
516
  debug: false,
498
517
  profile: false,
499
518
  plugin: "",
519
+ cdpPort: 0,
520
+ cdpAddress: "",
500
521
  settings: new Map(),
501
522
  };
502
523
  const files = [];
@@ -513,7 +534,15 @@ function parseArgs(argv) {
513
534
  else if (arg === "-profile") flags.profile = true;
514
535
  else if (arg === "-config-dir") flags.configDir = argv[++i] ?? "";
515
536
  else if (arg === "-plugin") flags.plugin = argv[++i] ?? "";
516
- else if (arg.startsWith("-") && arg.length > 1 && i + 1 < argv.length) {
537
+ else if (arg.startsWith("--remote-debugging-port=")) {
538
+ flags.cdpPort = parseInt(arg.slice("--remote-debugging-port=".length)) || 9222;
539
+ } else if (arg === "--remote-debugging-port") {
540
+ flags.cdpPort = parseInt(argv[++i]) || 9222;
541
+ } else if (arg.startsWith("--remote-debugging-address=")) {
542
+ flags.cdpAddress = arg.slice("--remote-debugging-address=".length);
543
+ } else if (arg === "--remote-debugging-address") {
544
+ flags.cdpAddress = argv[++i] ?? "";
545
+ } else if (arg.startsWith("-") && arg.length > 1 && i + 1 < argv.length) {
517
546
  flags.settings.set(arg.slice(1), argv[++i]);
518
547
  } else {
519
548
  files.push(arg);
@@ -557,7 +586,11 @@ function usage() {
557
586
  " Show version+backend info & exit",
558
587
  "--docs, --readme",
559
588
  ` Show ${pkg.name}'s README.md & exit`,
560
-
589
+ "",
590
+ "--remote-debugging-port=PORT",
591
+ " Start CDP (Chrome DevTools Protocol) server on PORT at launch",
592
+ "--remote-debugging-address=ADDRESS",
593
+ " Bind CDP server to ADDRESS (default: 127.0.0.1); use 0.0.0.0 for all interfaces",
561
594
 
562
595
  ].join("\n");
563
596
  }
@@ -611,7 +644,15 @@ class BufferModel {
611
644
  if (this.lines.length === 0) this.lines = [""];
612
645
  this.cursor = { x: 0, y: 0 };
613
646
  this.scroll = { x: 0, y: 0, row: 0 };
614
- this.modified = false;
647
+ this._modified = false;
648
+ this._backupRequested = false;
649
+ this._backupRevision = 0;
650
+ Object.defineProperty(this, "modified", {
651
+ configurable: true,
652
+ enumerable: true,
653
+ get: () => this._modified,
654
+ set: (value) => this.setModified(value),
655
+ });
615
656
  this.readonly = readonly;
616
657
  this.modTimeMs = modTimeMs;
617
658
  this.reloadDisabled = false;
@@ -644,6 +685,9 @@ class BufferModel {
644
685
  matchbraceleft: DEFAULT_SETTINGS.matchbraceleft,
645
686
  matchbracestyle: DEFAULT_SETTINGS.matchbracestyle,
646
687
  savecursor: DEFAULT_SETTINGS.savecursor,
688
+ backup: DEFAULT_SETTINGS.backup,
689
+ backupdir: DEFAULT_SETTINGS.backupdir,
690
+ permbackup: DEFAULT_SETTINGS.permbackup,
647
691
  softwrap: DEFAULT_SETTINGS.softwrap,
648
692
  wordwrap: DEFAULT_SETTINGS.wordwrap,
649
693
  pageoverlap: DEFAULT_SETTINGS.pageoverlap,
@@ -674,6 +718,19 @@ class BufferModel {
674
718
  if (command.searchRegex) this.search(command.searchRegex, command.searchAfterStart);
675
719
  }
676
720
 
721
+ setModified(value = true) {
722
+ const next = Boolean(value);
723
+ const prev = this._modified;
724
+ this._modified = next;
725
+ if (next) {
726
+ this._backupRequested = true;
727
+ this._backupRevision++;
728
+ } else {
729
+ this._backupRequested = false;
730
+ if (prev && this._configDir) removeBackup(this, this._configDir);
731
+ }
732
+ }
733
+
677
734
  static async fromFile(path, command, context = {}) {
678
735
  let text = "";
679
736
  let readonly = false;
@@ -689,6 +746,7 @@ class BufferModel {
689
746
  encoding = decoded.encoding;
690
747
  }
691
748
  const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding });
749
+ buffer._configDir = context?.config?.configDir ?? null;
692
750
  attachSyntax(buffer, context, path, text);
693
751
  return buffer;
694
752
  }
@@ -1101,39 +1159,74 @@ class BufferModel {
1101
1159
  async save(path = this.path) {
1102
1160
  if (!path) throw new Error("No filename");
1103
1161
  const detectSyntaxAfterSave = this.filetype === "unknown";
1162
+ const oldPath = this.AbsPath || this.path;
1163
+ const targetPath = resolve(path);
1104
1164
  let text = this.lines.join("\n");
1165
+ if (this._configDir) {
1166
+ if (this._backupWritePromise) {
1167
+ try { await this._backupWritePromise; } catch {}
1168
+ }
1169
+ const backupRevision = this._backupRevision;
1170
+ const job = writeBackup(this, this._configDir, targetPath, { force: true });
1171
+ this._backupWritePromise = job;
1172
+ try {
1173
+ await job;
1174
+ } finally {
1175
+ if (this._backupWritePromise === job) this._backupWritePromise = null;
1176
+ }
1177
+ if (this._backupRevision === backupRevision) this._backupRequested = false;
1178
+ this._forceKeepBackup = true;
1179
+ }
1105
1180
  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);
1181
+ try {
1182
+ await Bun.write(targetPath, decodeBinaryBytes(Buffer.from(text, "latin1")));
1183
+ } finally {
1184
+ this._forceKeepBackup = false;
1185
+ }
1186
+ this.path = targetPath;
1187
+ this.Path = targetPath;
1188
+ this.AbsPath = targetPath;
1189
+ this.name = basename(targetPath);
1111
1190
  this.updateModTime();
1112
1191
  this.readonly = !canWritePath(path);
1113
1192
  this.Settings.readonly = this.readonly;
1114
1193
  this.Type.Readonly = this.readonly;
1115
1194
  this._savedSerial = this._undoSerial ?? 0;
1116
1195
  this.modified = false;
1117
- this.message = `Saved ${path}`;
1118
- if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, path, text);
1196
+ this.message = `Saved ${targetPath}`;
1197
+ if (this._configDir && oldPath !== targetPath) removeBackup(this, this._configDir, oldPath);
1198
+ this._updateOpenBufferPath(oldPath, targetPath);
1199
+ if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, targetPath, text);
1119
1200
  return;
1120
1201
  }
1121
1202
  if ((this.Settings.eofnewline ?? DEFAULT_SETTINGS.eofnewline) && !text.endsWith("\n")) text += "\n";
1122
- await Bun.write(path, encodeBufferTextForFile(text, this.Settings.fileformat ?? this.fileformat));
1203
+ try {
1204
+ await Bun.write(targetPath, encodeBufferTextForFile(text, this.Settings.fileformat ?? this.fileformat));
1205
+ } finally {
1206
+ this._forceKeepBackup = false;
1207
+ }
1123
1208
  this.encoding = "utf-8";
1124
1209
  this.Settings.encoding = "utf-8";
1125
- this.path = path;
1126
- this.Path = path;
1127
- this.AbsPath = path;
1128
- this.name = basename(path);
1210
+ this.path = targetPath;
1211
+ this.Path = targetPath;
1212
+ this.AbsPath = targetPath;
1213
+ this.name = basename(targetPath);
1129
1214
  this.updateModTime();
1130
1215
  this.readonly = !canWritePath(path);
1131
1216
  this.Settings.readonly = this.readonly;
1132
1217
  this.Type.Readonly = this.readonly;
1133
1218
  this._savedSerial = this._undoSerial ?? 0;
1134
1219
  this.modified = false;
1135
- this.message = `Saved ${path}`;
1136
- if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, path, text);
1220
+ this.message = `Saved ${targetPath}`;
1221
+ if (this._configDir && oldPath !== targetPath) removeBackup(this, this._configDir, oldPath);
1222
+ this._updateOpenBufferPath(oldPath, targetPath);
1223
+ if (detectSyntaxAfterSave && this._syntaxContext) attachSyntax(this, this._syntaxContext, targetPath, text);
1224
+ }
1225
+
1226
+ _updateOpenBufferPath(oldPath, newPath) {
1227
+ if (!this._openBufferMap) return;
1228
+ if (oldPath && this._openBufferMap.get(oldPath) === this) this._openBufferMap.delete(oldPath);
1229
+ this._openBufferMap.set(newPath, this);
1137
1230
  }
1138
1231
 
1139
1232
  // --- Autocomplete (BufferComplete) ---
@@ -1700,11 +1793,15 @@ class App {
1700
1793
  get buffer() { return this.pane?.buffer ?? null; }
1701
1794
  // backward-compat for the few spots that still use this.active / this.buffers
1702
1795
  get active() { return this.activeTabIdx; }
1703
- get buffers() { return this.tabs.map(t => t.buffer).filter(Boolean); }
1796
+ get buffers() {
1797
+ return [...new Set(this.tabs.flatMap((tab) =>
1798
+ tab.panes().flatMap((pane) => [pane.buffer, pane.prevBuffer]).filter(Boolean)
1799
+ ))];
1800
+ }
1704
1801
 
1705
1802
  paneForBuffer(buffer) {
1706
1803
  for (const tab of this.tabs) {
1707
- const pane = tab.panes().find((p) => p.buffer === buffer);
1804
+ const pane = tab.panes().find((p) => p.buffer === buffer || p.prevBuffer === buffer);
1708
1805
  if (pane) return pane;
1709
1806
  }
1710
1807
  return null;
@@ -1755,6 +1852,45 @@ class App {
1755
1852
  });
1756
1853
  process.on("SIGINT", () => {}); // Ctrl+C is handled as copy in handleEvent
1757
1854
  this.screen.init();
1855
+ // Update backup prompt to screen-aware version now that TUI is running.
1856
+ if (this.context._termPrompt) {
1857
+ this.context._termPrompt = async (msg) => {
1858
+ const tty = this._ttyStream ?? process.stdin;
1859
+ if (this._inputHandler) tty.removeListener("data", this._inputHandler);
1860
+ tty.setRawMode?.(false);
1861
+ this.screen.fini();
1862
+ process.stdout.write("\n");
1863
+ const answer = await termPromptLine(msg, tty);
1864
+ this.screen.previous = null;
1865
+ this.screen.init();
1866
+ tty.setRawMode?.(true);
1867
+ tty.resume(); // rl.close() pauses the stream; resume so data events fire again
1868
+ if (this._inputHandler) tty.on("data", this._inputHandler);
1869
+ return answer;
1870
+ };
1871
+ }
1872
+ // Process buffers requested by edits. A successful backup is not repeated
1873
+ // until the buffer is modified again.
1874
+ const configDir = this.context?.config?.configDir;
1875
+ if (configDir) {
1876
+ this._backupTimer = setInterval(async () => {
1877
+ for (const buf of this.buffers) {
1878
+ if (buf._backupRequested && buf.modified && buf.path && buf.type === "default" &&
1879
+ (buf.Settings?.backup ?? DEFAULT_SETTINGS.backup) && !buf._backupWritePromise) {
1880
+ const revision = buf._backupRevision;
1881
+ const job = writeBackup(buf, configDir);
1882
+ buf._backupWritePromise = job;
1883
+ try {
1884
+ if (await job) {
1885
+ if (buf._backupRevision === revision) buf._backupRequested = false;
1886
+ }
1887
+ } catch {} finally {
1888
+ if (buf._backupWritePromise === job) buf._backupWritePromise = null;
1889
+ }
1890
+ }
1891
+ }
1892
+ }, 10_000);
1893
+ }
1758
1894
  startupHighlightProgress = new StartupHighlightProgress(this);
1759
1895
  try {
1760
1896
  this.render();
@@ -1774,6 +1910,8 @@ class App {
1774
1910
 
1775
1911
  async stop(code = 0) {
1776
1912
  this.running = false;
1913
+ if (this._backupTimer) { clearInterval(this._backupTimer); this._backupTimer = null; }
1914
+ await Promise.allSettled(this.buffers.map((buf) => buf._backupWritePromise).filter(Boolean));
1777
1915
  for (const tab of this.tabs)
1778
1916
  for (const p of tab.panes())
1779
1917
  if (p.type === "term") p.terminal?.close();
@@ -1790,6 +1928,10 @@ class App {
1790
1928
  }
1791
1929
  try { await saveCursorStates(this.context.config.configDir, this.context.cursorStates); } catch {}
1792
1930
  }
1931
+ const configDir = this.context?.config?.configDir;
1932
+ if (configDir) {
1933
+ for (const buf of this.buffers) removeBackup(buf, configDir);
1934
+ }
1793
1935
  process.exit(code);
1794
1936
  }
1795
1937
 
@@ -3859,9 +4001,11 @@ class App {
3859
4001
  }
3860
4002
  async openInPane(path) {
3861
4003
  try {
4004
+ const previous = this.pane.buffer;
3862
4005
  const buffer = await loadBufferForPath(path, this.context);
3863
4006
  this.pane.buffer = buffer;
3864
4007
  this.pane.selection = null;
4008
+ if (previous !== buffer) this._closeBufferIfUnused(previous);
3865
4009
  await this.context.plugins?.run("onBufferOpen", buffer);
3866
4010
  await this.context.jsPlugins?.run("onBufferOpen", buffer);
3867
4011
  } catch (error) {
@@ -3873,8 +4017,10 @@ class App {
3873
4017
  try {
3874
4018
  const buffer = await loadBufferForPath(path, this.context);
3875
4019
  if (isEmptyUntitledBuffer(this.buffer)) {
4020
+ const previous = this.pane.buffer;
3876
4021
  this.pane.buffer = buffer;
3877
4022
  this.pane.selection = null;
4023
+ if (previous !== buffer) this._closeBufferIfUnused(previous);
3878
4024
  } else {
3879
4025
  const tab = new Tab(new Pane(buffer));
3880
4026
  this.tabs.push(tab);
@@ -4057,8 +4203,10 @@ class App {
4057
4203
 
4058
4204
  closePane(pane) {
4059
4205
  pane.terminal?.close();
4206
+ const closingBuffers = [...new Set([pane.buffer, pane.prevBuffer].filter(Boolean))];
4060
4207
  const tab = this.tab;
4061
4208
  tab.removePane(pane);
4209
+ for (const buffer of closingBuffers) this._closeBufferIfUnused(buffer);
4062
4210
  if (!tab.root) {
4063
4211
  // Tab is empty — close it
4064
4212
  if (this.tabs.length <= 1) { this.stop(0); return; }
@@ -4073,10 +4221,12 @@ class App {
4073
4221
  await this.stop(0);
4074
4222
  return;
4075
4223
  }
4224
+ const closingBuffers = [...new Set(this.tab.panes().flatMap((pane) => [pane.buffer, pane.prevBuffer]).filter(Boolean))];
4076
4225
  const closing = this.buffer;
4077
4226
  this.tabs.splice(this.activeTabIdx, 1);
4078
4227
  this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
4079
4228
  this.message = "";
4229
+ for (const buffer of closingBuffers) this._closeBufferIfUnused(buffer);
4080
4230
  if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
4081
4231
  await this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
4082
4232
  await this.context.plugins?.run("onBufferClose", closing);
@@ -4085,6 +4235,14 @@ class App {
4085
4235
  this.render();
4086
4236
  }
4087
4237
 
4238
+ _closeBufferIfUnused(buffer) {
4239
+ if (!buffer || this.paneForBuffer(buffer)) return;
4240
+ const configDir = this.context?.config?.configDir;
4241
+ if (configDir) removeBackup(buffer, configDir);
4242
+ const map = this.context?._openBuffers;
4243
+ if (map && map.get(buffer.AbsPath) === buffer) map.delete(buffer.AbsPath);
4244
+ }
4245
+
4088
4246
  openCommandMode(initial = "") {
4089
4247
  const originalColorscheme = this.context.colorscheme;
4090
4248
  const previewTheme = async (value) => {
@@ -4442,25 +4600,27 @@ class App {
4442
4600
  case "vsplit": {
4443
4601
  let newBuf;
4444
4602
  if (cmdArgs.length > 0) {
4445
- try { newBuf = await BufferModel.fromFile(resolve(expandHome(cmdArgs[0])), {}, this.context); }
4603
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
4446
4604
  catch (err) { this.message = err.message; break; }
4447
4605
  } else {
4448
4606
  newBuf = new BufferModel({ command: {} });
4449
4607
  attachSyntax(newBuf, this.context, "", "");
4450
4608
  }
4451
4609
  this.tab.split(this.pane, new Pane(newBuf), "h");
4610
+ this.render();
4452
4611
  break;
4453
4612
  }
4454
4613
  case "hsplit": {
4455
4614
  let newBuf;
4456
4615
  if (cmdArgs.length > 0) {
4457
- try { newBuf = await BufferModel.fromFile(resolve(expandHome(cmdArgs[0])), {}, this.context); }
4616
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
4458
4617
  catch (err) { this.message = err.message; break; }
4459
4618
  } else {
4460
4619
  newBuf = new BufferModel({ command: {} });
4461
4620
  attachSyntax(newBuf, this.context, "", "");
4462
4621
  }
4463
4622
  this.tab.split(this.pane, new Pane(newBuf), "v");
4623
+ this.render();
4464
4624
  break;
4465
4625
  }
4466
4626
  case "term": {
@@ -5665,7 +5825,7 @@ function detectTtsCmd() {
5665
5825
  Bun.env.TTS_LANG = lang ;
5666
5826
 
5667
5827
  if (platform === "android") {
5668
- if (runSync(["sh", "-c", "command -v termux-tts-speak"], { stdout: "ignore", stderr: "ignore" }).ok)
5828
+ if (Bun.which("termux-tts-speak"))
5669
5829
  return { cmd: ["termux-tts-speak", "-p", String(pitch), "-r", String(speed)], via: "arg" };
5670
5830
  }
5671
5831
 
@@ -5686,7 +5846,7 @@ function detectTtsCmd() {
5686
5846
  const pitchPct = Math.round((pitch - 1) * 100);
5687
5847
  const pitchAttr = (pitchPct >= 0 ? "+" : "") + pitchPct + "%";
5688
5848
  for (const shell of ["pwsh.exe", "powershell.exe"]) {
5689
- if (runSync(["where.exe", shell], { stdout: "ignore", stderr: "ignore" }).ok) {
5849
+ if (Bun.which(shell)) {
5690
5850
  const psCmd =
5691
5851
  "Add-Type -AssemblyName System.Speech; " +
5692
5852
  `$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = ${rate}; ` +
@@ -5701,7 +5861,7 @@ function detectTtsCmd() {
5701
5861
  // Linux / Android fallback: espeak-ng / espeak
5702
5862
  // Speed: -s <wpm> (175 = normal), Pitch: -p <n> (0-99, 50 = normal)
5703
5863
  for (const bin of ["espeak-ng", "espeak"]) {
5704
- if (runSync(["sh", "-c", `command -v ${bin}`], { stdout: "ignore", stderr: "ignore" }).ok) {
5864
+ if (Bun.which(bin)) {
5705
5865
  const spd = Math.round(175 * speed);
5706
5866
  const pit = Math.max(0, Math.min(99, Math.round(50 * pitch)));
5707
5867
  return { cmd: [bin, '-s', spd, '-p', pit], via: "arg" };
@@ -5731,9 +5891,26 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
5731
5891
  encoding = decoded.encoding;
5732
5892
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
5733
5893
  buffer = new BufferModel({ path: pathOrUrl, text, command, encoding });
5894
+ buffer._configDir = context?.config?.configDir ?? null;
5734
5895
  attachSyntax(buffer, context, urlPath, text);
5735
5896
  } else {
5736
- buffer = await BufferModel.fromFile(pathOrUrl, command, context);
5897
+ if (!context._openBuffers) context._openBuffers = new Map();
5898
+ const absPath = resolve(pathOrUrl);
5899
+ const existing = context._openBuffers.get(absPath);
5900
+ if (existing) return existing;
5901
+ buffer = await BufferModel.fromFile(absPath, command, context);
5902
+ // Check for crash-recovery backup before returning the buffer.
5903
+ const promptFn = context._termPrompt;
5904
+ if (promptFn && buffer._configDir) {
5905
+ const { recovered, abort } = await applyBackup(buffer, buffer._configDir, promptFn);
5906
+ if (abort) return new BufferModel({ command });
5907
+ if (recovered) {
5908
+ buffer.ensureCursor();
5909
+ attachSyntax(buffer, context, absPath, buffer.lines.join("\n"));
5910
+ }
5911
+ }
5912
+ buffer._openBufferMap = context._openBuffers;
5913
+ context._openBuffers.set(absPath, buffer);
5737
5914
  }
5738
5915
  if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
5739
5916
  const saved = context.cursorStates[pathOrUrl];
@@ -6115,9 +6292,13 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6115
6292
  else if (key === "ispace") indentspacechars = val;
6116
6293
  else if (key === "itab") indenttabchars = val;
6117
6294
  }
6118
- // leadingwsEnd: index of first non-whitespace char (raw code-unit index)
6295
+ // Only inspect visible leading whitespace. Once horizontally scrolled, the
6296
+ // line start is off-screen and should not make redraw cost depend on it.
6119
6297
  let leadingwsEnd = 0;
6120
- while (leadingwsEnd < raw.length && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
6298
+ if (scrollX === 0) {
6299
+ const visibleEnd = Math.min(raw.length, maxWidth);
6300
+ while (leadingwsEnd < visibleEnd && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
6301
+ }
6121
6302
 
6122
6303
  const hltaberrors = buf.Settings?.hltaberrors ?? false;
6123
6304
  const tabstospaces = buf.Settings?.tabstospaces ?? false;
@@ -6133,9 +6314,9 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6133
6314
  : null;
6134
6315
  const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
6135
6316
 
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));
6317
+ // Keep horizontal rendering bounded to the visible range. Reconstructing
6318
+ // the exact display width before scrollX makes long-line redraws O(scrollX).
6319
+ const scrollVisualCol = scrollX;
6139
6320
 
6140
6321
  // Linter messages overlapping this line (Go bufwindow.go:662-668)
6141
6322
  const lineMessages = (buf.Messages ?? []).filter((m) => {
@@ -6532,6 +6713,8 @@ async function main() {
6532
6713
  if (DEFAULT_SETTINGS.savecursor) {
6533
6714
  context.cursorStates = await loadCursorStates(config.configDir);
6534
6715
  }
6716
+ // Backup prompt available before App starts (stdin still in cooked mode).
6717
+ context._termPrompt = process.stdout.isTTY ? termPromptLine : null;
6535
6718
  loadBuffers.context = context;
6536
6719
  const buffers = await loadBuffers(files.map((file) =>
6537
6720
  isHttpUrl(file) ? file : resolve(file)
@@ -6556,6 +6739,11 @@ async function main() {
6556
6739
  for (const buffer of buffers) await plugins.run("onBufferOpen", buffer);
6557
6740
  }
6558
6741
  for (const buffer of buffers) await jsPlugins.run("onBufferOpen", buffer);
6742
+ if (flags.cdpPort) {
6743
+ const cdpArgs = [flags.cdpPort];
6744
+ if (flags.cdpAddress) cdpArgs.push(`--address=${flags.cdpAddress}`);
6745
+ await app.handleCommand(`cdp ${cdpArgs.join(" ")}`);
6746
+ }
6559
6747
  await app.start();
6560
6748
  }
6561
6749
 
@@ -118,10 +118,7 @@ export async function runBytes(command, options = {}) {
118
118
  }
119
119
 
120
120
  export function hasCommand(name) {
121
- if (platformId() === "win32") {
122
- return runSync(["where.exe", name], { stdout: "ignore", stderr: "ignore" }).ok;
123
- }
124
- return runSync(["sh", "-c", `command -v ${shellQuote(name)}`], { stdout: "ignore", stderr: "ignore" }).ok;
121
+ return Bun.which(name);
125
122
  }
126
123
 
127
124
  export function firstCommand(names) {
@@ -455,10 +455,7 @@ function registerBuiltinActions() {
455
455
  if (pane?.buffer?.modified) try { await pane.buffer.save?.(); } catch {}
456
456
  await app.stop?.(0);
457
457
  });
458
- reg("Escape", (app) => {
459
- if (app.pane) app.pane.selection = null;
460
- if (app.buffer) app.buffer.searchPattern = "";
461
- });
458
+ reg("Escape", (app) => app._dispatchInput?.(new TextEncoder().encode("\x1b")));
462
459
 
463
460
  // Toggle settings
464
461
  reg("ToggleDiffGutter", (app) => {
@@ -768,7 +765,9 @@ export function buildMicroGlobal(jsManager) {
768
765
  return async (...args) => {
769
766
  const app = getApp();
770
767
  if (!app) return;
771
- return app.handleCommand(buildCmdString(name, args));
768
+ const result = await app.handleCommand(buildCmdString(name, args));
769
+ app.render?.();
770
+ return result;
772
771
  };
773
772
  },
774
773
  }),
@@ -872,6 +871,7 @@ function _makePaneAPI(buffer, app) {
872
871
  EndOfLine: () => buffer.moveEnd(),
873
872
  InsertNewline: () => buffer.newline(),
874
873
  InsertTab: () => buffer.insertTab(),
874
+ Insert: (text) => { buffer.pushUndo?.(); buffer.insert(text); app?.render?.(); },
875
875
  HandleCommand: (cmd) => app?.handleCommand?.(cmd),
876
876
 
877
877
  // Run a named action on this pane
@@ -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
+ });