bunmicro 0.9.23 → 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/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,
@@ -101,7 +106,11 @@ const DEFAULT_SETTINGS = {
101
106
  fileformat: process.platform === "win32" ? "dos" : "unix",
102
107
  "comment.type": "",
103
108
  commenttype: "",
104
- trailingws: false,
109
+ hltrailingws: false,
110
+ hltaberrors: false,
111
+ colorcolumn: 0,
112
+ showchars: "",
113
+ indentchar: " ",
105
114
  };
106
115
 
107
116
  const LONG_LINE_REHIGHLIGHT_LIMIT = 300;
@@ -115,6 +124,20 @@ function write(data) {
115
124
  process.stdout.write(data);
116
125
  }
117
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
+
118
141
  function sgr(...codes) {
119
142
  return `\x1b[${codes.join(";")}m`;
120
143
  }
@@ -607,7 +630,15 @@ class BufferModel {
607
630
  if (this.lines.length === 0) this.lines = [""];
608
631
  this.cursor = { x: 0, y: 0 };
609
632
  this.scroll = { x: 0, y: 0, row: 0 };
610
- 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
+ });
611
642
  this.readonly = readonly;
612
643
  this.modTimeMs = modTimeMs;
613
644
  this.reloadDisabled = false;
@@ -640,6 +671,9 @@ class BufferModel {
640
671
  matchbraceleft: DEFAULT_SETTINGS.matchbraceleft,
641
672
  matchbracestyle: DEFAULT_SETTINGS.matchbracestyle,
642
673
  savecursor: DEFAULT_SETTINGS.savecursor,
674
+ backup: DEFAULT_SETTINGS.backup,
675
+ backupdir: DEFAULT_SETTINGS.backupdir,
676
+ permbackup: DEFAULT_SETTINGS.permbackup,
643
677
  softwrap: DEFAULT_SETTINGS.softwrap,
644
678
  wordwrap: DEFAULT_SETTINGS.wordwrap,
645
679
  pageoverlap: DEFAULT_SETTINGS.pageoverlap,
@@ -647,7 +681,11 @@ class BufferModel {
647
681
  reload: DEFAULT_SETTINGS.reload,
648
682
  eofnewline: DEFAULT_SETTINGS.eofnewline,
649
683
  fileformat: this.fileformat,
650
- trailingws: DEFAULT_SETTINGS.trailingws,
684
+ hltrailingws: DEFAULT_SETTINGS.hltrailingws,
685
+ hltaberrors: DEFAULT_SETTINGS.hltaberrors,
686
+ colorcolumn: DEFAULT_SETTINGS.colorcolumn,
687
+ showchars: DEFAULT_SETTINGS.showchars,
688
+ indentchar: DEFAULT_SETTINGS.indentchar,
651
689
  encoding: this.encoding,
652
690
  readonly,
653
691
  };
@@ -666,6 +704,19 @@ class BufferModel {
666
704
  if (command.searchRegex) this.search(command.searchRegex, command.searchAfterStart);
667
705
  }
668
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
+
669
720
  static async fromFile(path, command, context = {}) {
670
721
  let text = "";
671
722
  let readonly = false;
@@ -681,6 +732,7 @@ class BufferModel {
681
732
  encoding = decoded.encoding;
682
733
  }
683
734
  const buffer = new BufferModel({ path, text, command, readonly, modTimeMs, encoding });
735
+ buffer._configDir = context?.config?.configDir ?? null;
684
736
  attachSyntax(buffer, context, path, text);
685
737
  return buffer;
686
738
  }
@@ -1093,39 +1145,74 @@ class BufferModel {
1093
1145
  async save(path = this.path) {
1094
1146
  if (!path) throw new Error("No filename");
1095
1147
  const detectSyntaxAfterSave = this.filetype === "unknown";
1148
+ const oldPath = this.AbsPath || this.path;
1149
+ const targetPath = resolve(path);
1096
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
+ }
1097
1166
  if (this.encoding === "hex3") {
1098
- await Bun.write(path, decodeBinaryBytes(Buffer.from(text, "latin1")));
1099
- this.path = path;
1100
- this.Path = path;
1101
- this.AbsPath = path;
1102
- 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);
1103
1176
  this.updateModTime();
1104
1177
  this.readonly = !canWritePath(path);
1105
1178
  this.Settings.readonly = this.readonly;
1106
1179
  this.Type.Readonly = this.readonly;
1107
1180
  this._savedSerial = this._undoSerial ?? 0;
1108
1181
  this.modified = false;
1109
- this.message = `Saved ${path}`;
1110
- 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);
1111
1186
  return;
1112
1187
  }
1113
1188
  if ((this.Settings.eofnewline ?? DEFAULT_SETTINGS.eofnewline) && !text.endsWith("\n")) text += "\n";
1114
- 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
+ }
1115
1194
  this.encoding = "utf-8";
1116
1195
  this.Settings.encoding = "utf-8";
1117
- this.path = path;
1118
- this.Path = path;
1119
- this.AbsPath = path;
1120
- this.name = basename(path);
1196
+ this.path = targetPath;
1197
+ this.Path = targetPath;
1198
+ this.AbsPath = targetPath;
1199
+ this.name = basename(targetPath);
1121
1200
  this.updateModTime();
1122
1201
  this.readonly = !canWritePath(path);
1123
1202
  this.Settings.readonly = this.readonly;
1124
1203
  this.Type.Readonly = this.readonly;
1125
1204
  this._savedSerial = this._undoSerial ?? 0;
1126
1205
  this.modified = false;
1127
- this.message = `Saved ${path}`;
1128
- 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);
1129
1216
  }
1130
1217
 
1131
1218
  // --- Autocomplete (BufferComplete) ---
@@ -1692,11 +1779,15 @@ class App {
1692
1779
  get buffer() { return this.pane?.buffer ?? null; }
1693
1780
  // backward-compat for the few spots that still use this.active / this.buffers
1694
1781
  get active() { return this.activeTabIdx; }
1695
- 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
+ }
1696
1787
 
1697
1788
  paneForBuffer(buffer) {
1698
1789
  for (const tab of this.tabs) {
1699
- const pane = tab.panes().find((p) => p.buffer === buffer);
1790
+ const pane = tab.panes().find((p) => p.buffer === buffer || p.prevBuffer === buffer);
1700
1791
  if (pane) return pane;
1701
1792
  }
1702
1793
  return null;
@@ -1747,6 +1838,45 @@ class App {
1747
1838
  });
1748
1839
  process.on("SIGINT", () => {}); // Ctrl+C is handled as copy in handleEvent
1749
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
+ }
1750
1880
  startupHighlightProgress = new StartupHighlightProgress(this);
1751
1881
  try {
1752
1882
  this.render();
@@ -1766,6 +1896,8 @@ class App {
1766
1896
 
1767
1897
  async stop(code = 0) {
1768
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));
1769
1901
  for (const tab of this.tabs)
1770
1902
  for (const p of tab.panes())
1771
1903
  if (p.type === "term") p.terminal?.close();
@@ -1782,6 +1914,10 @@ class App {
1782
1914
  }
1783
1915
  try { await saveCursorStates(this.context.config.configDir, this.context.cursorStates); } catch {}
1784
1916
  }
1917
+ const configDir = this.context?.config?.configDir;
1918
+ if (configDir) {
1919
+ for (const buf of this.buffers) removeBackup(buf, configDir);
1920
+ }
1785
1921
  process.exit(code);
1786
1922
  }
1787
1923
 
@@ -1879,7 +2015,9 @@ class App {
1879
2015
  const ft = (buf?.filetype && buf.filetype !== "unknown") ? buf.filetype : "?";
1880
2016
  const fmt = buf?.fileformat ?? "unix";
1881
2017
  const enc = buf?.encoding ?? "utf-8";
1882
- const baseStatus = { ...defaultStyle, reverse: true };
2018
+ const baseStatus = this.context.colorscheme?.styles?.has("statusline")
2019
+ ? this.context.colorscheme.get("statusline")
2020
+ : { ...defaultStyle, reverse: true };
1883
2021
  const redStatus = { ...baseStatus, fg: "red" };
1884
2022
  // Fill entire row with base style first
1885
2023
  putText(this.screen, 0, statusRow, " ".repeat(this.cols), baseStatus, this.cols);
@@ -2010,8 +2148,9 @@ class App {
2010
2148
  }
2011
2149
 
2012
2150
  renderMessageRow(defaultStyle, row, message) {
2013
- const statusStyle = this.context.colorscheme?.get("statusline") ?? { ...defaultStyle, reverse: true };
2014
- const style = { ...statusStyle, reverse: false };
2151
+ const style = this.context.colorscheme?.styles?.has("message")
2152
+ ? this.context.colorscheme.get("message")
2153
+ : defaultStyle;
2015
2154
  putText(this.screen, 0, row, " ".repeat(this.cols), style, this.cols);
2016
2155
  this._messageRowY = row;
2017
2156
  this._messageRowClickZone = null;
@@ -2041,15 +2180,15 @@ class App {
2041
2180
  }
2042
2181
 
2043
2182
  renderSuggestions(defaultStyle, row, suggestions, curIdx) {
2044
- const suggestionStyle = this.context.colorscheme?.get("statusline.suggestions")
2045
- ?? this.context.colorscheme?.get("statusline")
2046
- ?? defaultStyle;
2047
- const baseStyle = { ...suggestionStyle, reverse: false };
2183
+ const cs = this.context.colorscheme;
2184
+ const baseStyle = cs?.styles?.has("statusline.suggestions")
2185
+ ? cs.get("statusline.suggestions")
2186
+ : cs?.styles?.has("statusline")
2187
+ ? cs.get("statusline")
2188
+ : { ...defaultStyle, reverse: true };
2048
2189
  const selStyle = {
2049
2190
  ...baseStyle,
2050
- fg: baseStyle.bg === "default" ? "black" : baseStyle.bg,
2051
- bg: baseStyle.fg === "default" ? "brightwhite" : baseStyle.fg,
2052
- reverse: false,
2191
+ reverse: true,
2053
2192
  };
2054
2193
 
2055
2194
  // Compute each item's position in the virtual (pre-scroll) space.
@@ -2172,9 +2311,16 @@ class App {
2172
2311
  const softwrap = buf.Settings?.softwrap ?? false;
2173
2312
  const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2174
2313
  const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2175
- const gutterStyle = { ...defaultStyle, fg: "brightblack" };
2176
- const dirtyGutterStyle = { ...gutterStyle, fg: "red" };
2177
2314
  const isActivePane = pane === this.tab.activePane;
2315
+ const gutterStyle = this.context.colorscheme?.styles?.has("line-number")
2316
+ ? this.context.colorscheme.get("line-number")
2317
+ : defaultStyle;
2318
+ const cursorlineOn = buf.Settings?.cursorline ?? DEFAULT_SETTINGS.cursorline;
2319
+ const csHasCurNum = this.context.colorscheme?.styles?.has("current-line-number");
2320
+ const curNumStyle = !csHasCurNum
2321
+ ? defaultStyle
2322
+ : (cursorlineOn ? this.context.colorscheme.get("current-line-number") : gutterStyle);
2323
+ const dirtyGutterStyle = { ...gutterStyle, fg: "red" };
2178
2324
  const useCursorline = (buf.Settings?.cursorline ?? DEFAULT_SETTINGS.cursorline) && isActivePane;
2179
2325
  const clBg = (useCursorline && this.context.colorscheme?.styles?.has("cursor-line"))
2180
2326
  ? (this.context.colorscheme.get("cursor-line")?.fg ?? null)
@@ -2187,12 +2333,12 @@ class App {
2187
2333
  const diffCol = (buf.Settings?.diffgutter ?? false) ? 1 : 0;
2188
2334
  const lineNumW = gutterW - msgW - diffCol;
2189
2335
  const cs = this.context.colorscheme;
2190
- const diffAddStyle = cs?.get("diff-added") ?? { ...defaultStyle, fg: "green" };
2191
- const diffModStyle = cs?.get("diff-modified") ?? { ...defaultStyle, fg: "yellow" };
2192
- const diffDelStyle = cs?.get("diff-deleted") ?? { ...defaultStyle, fg: "red" };
2193
- const msgInfoStyle = cs?.get("gutter-info") ?? { ...defaultStyle, fg: "cyan" };
2194
- const msgWarnStyle = cs?.get("gutter-warning") ?? { ...defaultStyle, fg: "yellow" };
2195
- const msgErrStyle = cs?.get("gutter-error") ?? { ...defaultStyle, fg: "red" };
2336
+ const diffAddStyle = cs?.styles?.has("diff-added") ? cs.get("diff-added") : null;
2337
+ const diffModStyle = cs?.styles?.has("diff-modified") ? cs.get("diff-modified") : null;
2338
+ const diffDelStyle = cs?.styles?.has("diff-deleted") ? cs.get("diff-deleted") : null;
2339
+ const msgInfoStyle = cs?.styles?.has("gutter-info") ? cs.get("gutter-info") : defaultStyle;
2340
+ const msgWarnStyle = cs?.styles?.has("gutter-warning") ? cs.get("gutter-warning") : defaultStyle;
2341
+ const msgErrStyle = cs?.styles?.has("gutter-error") ? cs.get("gutter-error") : defaultStyle;
2196
2342
 
2197
2343
  const renderGutter = (lineNo, row, screenRow, subRow = 0) => {
2198
2344
  // Message indicator: 2 cols, '> ' with kind-based style (Go: drawGutter)
@@ -2209,19 +2355,22 @@ class App {
2209
2355
  }
2210
2356
  putText(this.screen, pane.x, screenRow, msgCh + " ", msgSt, 2);
2211
2357
  }
2358
+ const isCurrentLine = isActivePane && !pane.selection && lineNo === buf.cursor.y;
2359
+ const lineStyle = isCurrentLine ? curNumStyle : gutterStyle;
2212
2360
  if (diffCol > 0) {
2213
2361
  const m = diffMarks?.[lineNo] ?? 0;
2214
- const [ch, st] = m === 1 ? ["▌", diffAddStyle]
2215
- : m === 2 ? ["▌", diffModStyle]
2216
- : m === 3 ? ["▔", diffDelStyle]
2217
- : [" ", gutterStyle];
2362
+ const [ch, colorStyle] = m === 1 ? ["▌", diffAddStyle]
2363
+ : m === 2 ? ["▌", diffModStyle]
2364
+ : m === 3 ? ["▔", diffDelStyle]
2365
+ : [" ", null];
2366
+ const st = colorStyle ? { ...lineStyle, fg: colorStyle.fg } : lineStyle;
2218
2367
  putText(this.screen, pane.x + msgW, screenRow, subRow === 0 ? ch : " ", st, 1);
2219
2368
  }
2220
2369
  if (lineNumW > 0) {
2221
2370
  const prefix = subRow === 0
2222
2371
  ? lineNumberText(buf, lineNo, row, lineNumW)
2223
2372
  : visualLineNumberText(subRow, lineNumW);
2224
- putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : gutterStyle, lineNumW);
2373
+ putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : lineStyle, lineNumW);
2225
2374
  }
2226
2375
  };
2227
2376
 
@@ -2305,7 +2454,11 @@ class App {
2305
2454
 
2306
2455
  renderDividers(node, defaultStyle) {
2307
2456
  if (node instanceof Pane) return;
2308
- const divStyle = { ...defaultStyle, fg: "brightblack" };
2457
+ const divReverse = this.context.config?.globalSettings?.divreverse ?? true;
2458
+ const baseDiv = this.context.colorscheme?.styles?.has("divider")
2459
+ ? this.context.colorscheme.get("divider")
2460
+ : defaultStyle;
2461
+ const divStyle = divReverse ? { ...baseDiv, reverse: true } : baseDiv;
2309
2462
  for (let i = 0; i < node.children.length - 1; i++) {
2310
2463
  const child = node.children[i];
2311
2464
  if (node.dir === "h") {
@@ -2322,21 +2475,37 @@ class App {
2322
2475
  }
2323
2476
 
2324
2477
  renderTabbar(defaultStyle) {
2325
- const tabBarStyle = this.context.colorscheme?.get("tabbar") ?? { ...defaultStyle, reverse: true };
2326
- const activeStyle = this.context.colorscheme?.get("tabbar.active") ?? { ...tabBarStyle, bold: true };
2478
+ const cs = this.context.colorscheme;
2479
+ const gs = this.context.config?.globalSettings;
2480
+ const tabReverse = gs?.tabreverse ?? true;
2481
+ const tabHighlight = gs?.tabhighlight ?? false;
2482
+ const tabCharReverse = (tabReverse || tabHighlight) && !(tabReverse && tabHighlight);
2483
+
2484
+ const stylesFor = (reverse) => {
2485
+ const base = cs?.styles?.has("tabbar")
2486
+ ? cs.get("tabbar")
2487
+ : { ...defaultStyle, reverse };
2488
+ const active = cs?.styles?.has("tabbar.active")
2489
+ ? cs.get("tabbar.active")
2490
+ : base;
2491
+ return [base, active];
2492
+ };
2493
+ const [sepStyle] = stylesFor(tabReverse);
2494
+ const [charBase, charActive] = stylesFor(tabCharReverse);
2495
+
2327
2496
  let x = 0;
2328
2497
  for (let i = 0; i < this.tabs.length && x < this.cols; i++) {
2329
2498
  const name = this.tabs[i].name || "No name";
2330
2499
  const isActive = i === this.activeTabIdx;
2331
2500
  const label = isActive ? `[${name}]` : ` ${name} `;
2332
- const style = isActive ? activeStyle : tabBarStyle;
2501
+ const style = isActive ? charActive : charBase;
2333
2502
  const start = x;
2334
2503
  x = putText(this.screen, x, 0, label, style, this.cols - x);
2335
2504
  this.tabRects.push({ index: i, start, end: x });
2336
2505
  if (i < this.tabs.length - 1 && x < this.cols)
2337
- x = putText(this.screen, x, 0, " ", tabBarStyle, this.cols - x);
2506
+ x = putText(this.screen, x, 0, " ", sepStyle, this.cols - x);
2338
2507
  }
2339
- if (x < this.cols) putText(this.screen, x, 0, " ".repeat(this.cols - x), tabBarStyle, this.cols - x);
2508
+ if (x < this.cols) putText(this.screen, x, 0, " ".repeat(this.cols - x), sepStyle, this.cols - x);
2340
2509
  }
2341
2510
 
2342
2511
  updateScrollForPane(pane) {
@@ -3818,9 +3987,11 @@ class App {
3818
3987
  }
3819
3988
  async openInPane(path) {
3820
3989
  try {
3990
+ const previous = this.pane.buffer;
3821
3991
  const buffer = await loadBufferForPath(path, this.context);
3822
3992
  this.pane.buffer = buffer;
3823
3993
  this.pane.selection = null;
3994
+ if (previous !== buffer) this._closeBufferIfUnused(previous);
3824
3995
  await this.context.plugins?.run("onBufferOpen", buffer);
3825
3996
  await this.context.jsPlugins?.run("onBufferOpen", buffer);
3826
3997
  } catch (error) {
@@ -3832,8 +4003,10 @@ class App {
3832
4003
  try {
3833
4004
  const buffer = await loadBufferForPath(path, this.context);
3834
4005
  if (isEmptyUntitledBuffer(this.buffer)) {
4006
+ const previous = this.pane.buffer;
3835
4007
  this.pane.buffer = buffer;
3836
4008
  this.pane.selection = null;
4009
+ if (previous !== buffer) this._closeBufferIfUnused(previous);
3837
4010
  } else {
3838
4011
  const tab = new Tab(new Pane(buffer));
3839
4012
  this.tabs.push(tab);
@@ -4016,8 +4189,10 @@ class App {
4016
4189
 
4017
4190
  closePane(pane) {
4018
4191
  pane.terminal?.close();
4192
+ const closingBuffers = [...new Set([pane.buffer, pane.prevBuffer].filter(Boolean))];
4019
4193
  const tab = this.tab;
4020
4194
  tab.removePane(pane);
4195
+ for (const buffer of closingBuffers) this._closeBufferIfUnused(buffer);
4021
4196
  if (!tab.root) {
4022
4197
  // Tab is empty — close it
4023
4198
  if (this.tabs.length <= 1) { this.stop(0); return; }
@@ -4032,10 +4207,12 @@ class App {
4032
4207
  await this.stop(0);
4033
4208
  return;
4034
4209
  }
4210
+ const closingBuffers = [...new Set(this.tab.panes().flatMap((pane) => [pane.buffer, pane.prevBuffer]).filter(Boolean))];
4035
4211
  const closing = this.buffer;
4036
4212
  this.tabs.splice(this.activeTabIdx, 1);
4037
4213
  this.activeTabIdx = Math.min(this.activeTabIdx, this.tabs.length - 1);
4038
4214
  this.message = "";
4215
+ for (const buffer of closingBuffers) this._closeBufferIfUnused(buffer);
4039
4216
  if (this.context.plugins && this.buffer) this.context.plugins.curPaneAdapter = makePaneAdapter(this.buffer, this);
4040
4217
  await this.context.plugins?.run("onSetActive", makePaneAdapter(this.buffer, this));
4041
4218
  await this.context.plugins?.run("onBufferClose", closing);
@@ -4044,6 +4221,14 @@ class App {
4044
4221
  this.render();
4045
4222
  }
4046
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
+
4047
4232
  openCommandMode(initial = "") {
4048
4233
  const originalColorscheme = this.context.colorscheme;
4049
4234
  const previewTheme = async (value) => {
@@ -4401,25 +4586,27 @@ class App {
4401
4586
  case "vsplit": {
4402
4587
  let newBuf;
4403
4588
  if (cmdArgs.length > 0) {
4404
- try { newBuf = await BufferModel.fromFile(resolve(expandHome(cmdArgs[0])), {}, this.context); }
4589
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
4405
4590
  catch (err) { this.message = err.message; break; }
4406
4591
  } else {
4407
4592
  newBuf = new BufferModel({ command: {} });
4408
4593
  attachSyntax(newBuf, this.context, "", "");
4409
4594
  }
4410
4595
  this.tab.split(this.pane, new Pane(newBuf), "h");
4596
+ this.render();
4411
4597
  break;
4412
4598
  }
4413
4599
  case "hsplit": {
4414
4600
  let newBuf;
4415
4601
  if (cmdArgs.length > 0) {
4416
- try { newBuf = await BufferModel.fromFile(resolve(expandHome(cmdArgs[0])), {}, this.context); }
4602
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
4417
4603
  catch (err) { this.message = err.message; break; }
4418
4604
  } else {
4419
4605
  newBuf = new BufferModel({ command: {} });
4420
4606
  attachSyntax(newBuf, this.context, "", "");
4421
4607
  }
4422
4608
  this.tab.split(this.pane, new Pane(newBuf), "v");
4609
+ this.render();
4423
4610
  break;
4424
4611
  }
4425
4612
  case "term": {
@@ -5690,9 +5877,26 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
5690
5877
  encoding = decoded.encoding;
5691
5878
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
5692
5879
  buffer = new BufferModel({ path: pathOrUrl, text, command, encoding });
5880
+ buffer._configDir = context?.config?.configDir ?? null;
5693
5881
  attachSyntax(buffer, context, urlPath, text);
5694
5882
  } else {
5695
- 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);
5696
5900
  }
5697
5901
  if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
5698
5902
  const saved = context.cursorStates[pathOrUrl];
@@ -6047,22 +6251,79 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6047
6251
  // Go: cursor-line bg is skipped when a syntax style already has a non-default background (preservebg)
6048
6252
  const defBg = colorscheme?.defaultStyle?.bg ?? "default";
6049
6253
 
6050
- const showTrailingWs = buf.Settings?.trailingws ?? false;
6254
+ const showTrailingWs = buf.Settings?.hltrailingws ?? false;
6051
6255
  let trailingWsIdx = raw.length;
6052
6256
  if (showTrailingWs) {
6053
6257
  let k = raw.length - 1;
6054
6258
  while (k >= 0 && (raw[k] === " " || raw[k] === "\t")) k--;
6055
6259
  trailingWsIdx = k + 1;
6056
6260
  }
6057
- const trailingWsStyle = showTrailingWs
6058
- ? (colorscheme?.get("trailingws") ?? { fg: "red", underline: true })
6261
+ const trailingWsColor = showTrailingWs && colorscheme?.styles?.has("trailingws")
6262
+ ? colorscheme.get("trailingws")?.fg
6263
+ : null;
6264
+
6265
+ // showchars parsing (Go bufwindow.go:455-476)
6266
+ const indentchar = buf.Settings?.indentchar ?? " ";
6267
+ let spacechars = " ";
6268
+ let tabchars = indentchar;
6269
+ let indentspacechars = "";
6270
+ let indenttabchars = "";
6271
+ for (const entry of String(buf.Settings?.showchars ?? "").split(",")) {
6272
+ const eq = entry.indexOf("=");
6273
+ if (eq < 0) continue;
6274
+ const key = entry.slice(0, eq);
6275
+ const val = entry.slice(eq + 1);
6276
+ if (key === "space") spacechars = val;
6277
+ else if (key === "tab") tabchars = val;
6278
+ else if (key === "ispace") indentspacechars = val;
6279
+ else if (key === "itab") indenttabchars = val;
6280
+ }
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.
6283
+ let leadingwsEnd = 0;
6284
+ if (scrollX === 0) {
6285
+ const visibleEnd = Math.min(raw.length, maxWidth);
6286
+ while (leadingwsEnd < visibleEnd && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
6287
+ }
6288
+
6289
+ const hltaberrors = buf.Settings?.hltaberrors ?? false;
6290
+ const tabstospaces = buf.Settings?.tabstospaces ?? false;
6291
+ const tabErrorFg = hltaberrors && colorscheme?.styles?.has("tab-error")
6292
+ ? colorscheme.get("tab-error")?.fg
6293
+ : null;
6294
+ const indentCharFg = colorscheme?.styles?.has("indent-char")
6295
+ ? colorscheme.get("indent-char")?.fg
6059
6296
  : null;
6297
+ const colorcolumn = Number(buf.Settings?.colorcolumn ?? 0) | 0;
6298
+ const colorColumnBg = colorcolumn > 0 && colorscheme?.styles?.has("color-column")
6299
+ ? colorscheme.get("color-column")?.fg
6300
+ : null;
6301
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
6302
+
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;
6306
+
6307
+ // Linter messages overlapping this line (Go bufwindow.go:662-668)
6308
+ const lineMessages = (buf.Messages ?? []).filter((m) => {
6309
+ const sy = m.Start?.Y ?? 0, ey = m.End?.Y ?? 0;
6310
+ return sy <= lineNo && ey >= lineNo;
6311
+ });
6312
+ const inMessageAt = (charIdx) => {
6313
+ for (const m of lineMessages) {
6314
+ const sY = m.Start?.Y ?? 0, sX = m.Start?.X ?? 0;
6315
+ const eY = m.End?.Y ?? 0, eX = m.End?.X ?? 0;
6316
+ const ge = lineNo > sY || (lineNo === sY && charIdx >= sX);
6317
+ const lt = lineNo < eY || (lineNo === eY && charIdx < eX);
6318
+ if (ge && lt) return true;
6319
+ }
6320
+ return false;
6321
+ };
6060
6322
 
6061
6323
  let changeIndex = 0;
6062
6324
  let searchIdx = 0;
6063
6325
  let i = scrollX;
6064
6326
  while (i < raw.length && width < maxWidth) {
6065
- const cp = raw.codePointAt(i);
6066
6327
  const unit = displayUnitAt(raw, i);
6067
6328
  const ch = unit.text;
6068
6329
  const charLen = unit.length;
@@ -6071,16 +6332,30 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6071
6332
  while (changeIndex + 1 < changes.length && i >= changes[changeIndex + 1][0]) changeIndex++;
6072
6333
  const group = changes[changeIndex]?.[1] ?? "default";
6073
6334
  const syntaxStyle = colorscheme?.get(group) ?? colorscheme?.defaultStyle ?? {};
6074
- const preservebg = cursorLineBg != null && syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
6075
- const baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
6335
+ let preservebg = syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
6336
+ let baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
6076
6337
  while (searchIdx < searchRanges.length && searchRanges[searchIdx][1] <= i) searchIdx++;
6077
6338
  const inSearch = searchIdx < searchRanges.length && i >= searchRanges[searchIdx][0] && i < searchRanges[searchIdx][1];
6078
6339
  const selected = isSelected(selection, lineNo, i, i + charLen);
6079
6340
  const braceMatched = braceMatches?.has(String(lineNo) + ":" + String(i));
6080
- let style = (showTrailingWs && i >= trailingWsIdx) ? trailingWsStyle : baseStyle;
6341
+
6342
+ // tab-error in leading whitespace
6343
+ let style = baseStyle;
6344
+ const inLeading = i < leadingwsEnd;
6345
+ if (tabErrorFg != null && inLeading) {
6346
+ if ((tabstospaces && ch === "\t") || (!tabstospaces && ch === " ")) {
6347
+ style = { ...style, bg: tabErrorFg };
6348
+ preservebg = true;
6349
+ }
6350
+ }
6351
+ if (trailingWsColor != null && i >= trailingWsIdx) {
6352
+ style = { ...style, bg: trailingWsColor };
6353
+ preservebg = true;
6354
+ }
6081
6355
  if (inSearch) {
6082
6356
  const searchStyle = colorscheme?.styles?.has("hlsearch") ? colorscheme.get("hlsearch") : null;
6083
- style = searchStyle ?? { ...baseStyle, reverse: !baseStyle.reverse };
6357
+ style = searchStyle ?? { ...(colorscheme?.defaultStyle ?? {}), reverse: true };
6358
+ if ((style.bg ?? "default") !== defBg) preservebg = true;
6084
6359
  }
6085
6360
  if (braceMatched) {
6086
6361
  if ((buf.Settings?.matchbracestyle ?? DEFAULT_SETTINGS.matchbracestyle) === "highlight") {
@@ -6091,22 +6366,73 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6091
6366
  }
6092
6367
  }
6093
6368
  if (selected) {
6094
- style = { ...style, reverse: !style.reverse };
6369
+ const selectionStyle = colorscheme?.styles?.has("selection") ? colorscheme.get("selection") : null;
6370
+ style = selectionStyle ?? { ...(colorscheme?.defaultStyle ?? {}), reverse: true };
6371
+ }
6372
+ if (lineMessages.length > 0 && inMessageAt(i)) {
6373
+ style = { ...style, underline: true };
6374
+ }
6375
+
6376
+ // Visualize whitespace
6377
+ let displayCh = ch;
6378
+ let useIndentCharFg = false;
6379
+ if (ch === " ") {
6380
+ // Go bufwindow.go:554-559: ispace only kicks in at tabsize boundaries (indent guide).
6381
+ const visualCol = scrollVisualCol + width;
6382
+ const useIspace = inLeading && indentspacechars && (visualCol % tabsize === 0);
6383
+ const candidate = useIspace ? indentspacechars : spacechars;
6384
+ if (candidate && candidate !== " ") {
6385
+ displayCh = candidate[0] ?? " ";
6386
+ useIndentCharFg = true;
6387
+ }
6388
+ } else if (ch === "\t") {
6389
+ const candidate = (inLeading && indenttabchars) ? indenttabchars : tabchars;
6390
+ if (candidate && candidate !== " ") {
6391
+ displayCh = candidate[0] ?? " ";
6392
+ useIndentCharFg = true;
6393
+ } else {
6394
+ displayCh = " ";
6395
+ }
6095
6396
  }
6397
+ if (useIndentCharFg && indentCharFg != null) {
6398
+ style = { ...style, fg: indentCharFg };
6399
+ }
6400
+
6401
+ const ccAt = (visualCol) => {
6402
+ if (colorColumnBg == null || preservebg) return null;
6403
+ return visualCol === colorcolumn ? colorColumnBg : null;
6404
+ };
6405
+
6096
6406
  if (ch === "\t") {
6097
- const spaces = Math.min(DEFAULT_SETTINGS.tabsize, maxWidth - width);
6098
- for (let j = 0; j < spaces; j++) cells.push({ ch: " ", style });
6099
- width += spaces;
6407
+ const spaces = Math.min(tabsize, maxWidth - width);
6408
+ for (let j = 0; j < spaces; j++) {
6409
+ const visualCol = scrollVisualCol + width;
6410
+ const cellCh = j === 0 ? displayCh : " ";
6411
+ const ccBg = ccAt(visualCol);
6412
+ const cellStyle = ccBg != null ? { ...style, bg: ccBg } : style;
6413
+ cells.push({ ch: cellCh, style: cellStyle });
6414
+ width++;
6415
+ }
6100
6416
  } else if (w > 0 && width + w <= maxWidth) {
6101
- cells.push({ ch, style, width: w });
6417
+ const visualCol = scrollVisualCol + width;
6418
+ const ccBg = ccAt(visualCol);
6419
+ const cellStyle = ccBg != null ? { ...style, bg: ccBg } : style;
6420
+ cells.push({ ch: displayCh, style: cellStyle, width: w });
6102
6421
  width += w;
6103
6422
  }
6104
6423
  i += charLen;
6105
6424
  }
6106
- // Fill rest of line with cursorline background
6107
- if (cursorLineBg) {
6108
- const padStyle = { ...(colorscheme?.defaultStyle ?? {}), bg: cursorLineBg };
6109
- while (width < maxWidth) { cells.push({ ch: " ", style: padStyle }); width++; }
6425
+ // Trailing fill: cursor-line bg and color-column always apply (Go bufwindow.go:807-826)
6426
+ while (width < maxWidth) {
6427
+ const visualCol = scrollVisualCol + width;
6428
+ let padStyle = cursorLineBg
6429
+ ? { ...(colorscheme?.defaultStyle ?? {}), bg: cursorLineBg }
6430
+ : (colorscheme?.defaultStyle ?? {});
6431
+ if (colorColumnBg != null && visualCol === colorcolumn) {
6432
+ padStyle = { ...padStyle, bg: colorColumnBg };
6433
+ }
6434
+ cells.push({ ch: " ", style: padStyle });
6435
+ width++;
6110
6436
  }
6111
6437
  return cells;
6112
6438
  }
@@ -6373,6 +6699,8 @@ async function main() {
6373
6699
  if (DEFAULT_SETTINGS.savecursor) {
6374
6700
  context.cursorStates = await loadCursorStates(config.configDir);
6375
6701
  }
6702
+ // Backup prompt available before App starts (stdin still in cooked mode).
6703
+ context._termPrompt = process.stdout.isTTY ? termPromptLine : null;
6376
6704
  loadBuffers.context = context;
6377
6705
  const buffers = await loadBuffers(files.map((file) =>
6378
6706
  isHttpUrl(file) ? file : resolve(file)