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/CHANGELOG.md +15 -0
- package/README.md +23 -0
- package/hlw.md +1 -0
- package/package.json +1 -1
- package/runtime/help/cdp.md +119 -0
- package/runtime/jsplugins/cdp/cdp-server.js +1161 -0
- package/runtime/jsplugins/cdp/cdp.js +192 -0
- package/src/buffer/backup.js +160 -0
- package/src/index.js +218 -30
- package/src/platform/commands.js +1 -4
- package/src/plugins/js-bridge.js +5 -5
- package/tests/backup.test.js +133 -0
- package/tests/wv-client.js +96 -0
- package/todo.txt +6 -1
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("-")
|
|
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.
|
|
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
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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 ${
|
|
1118
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
1126
|
-
this.Path =
|
|
1127
|
-
this.AbsPath =
|
|
1128
|
-
this.name = basename(
|
|
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 ${
|
|
1136
|
-
if (
|
|
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() {
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
6137
|
-
//
|
|
6138
|
-
const scrollVisualCol =
|
|
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
|
|
package/src/platform/commands.js
CHANGED
|
@@ -118,10 +118,7 @@ export async function runBytes(command, options = {}) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export function hasCommand(name) {
|
|
121
|
-
|
|
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) {
|
package/src/plugins/js-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|