buninu 0.4.12 → 0.4.13
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/apps/bunmsh/CHANGELOG.md +36 -0
- package/apps/bunmsh/README.md +7 -0
- package/apps/bunmsh/package.json +1 -1
- package/apps/bunmsh/src/fancy-ls.js +29 -14
- package/apps/bunmsh/src/line-edit.js +43 -0
- package/apps/bunmsh/src/main.js +15 -1
- package/apps/bunmsh/src/mouse.js +29 -4
- package/apps/bunmsh/src/shell.js +9 -1
- package/apps/bunmsh/test/shell1.test.js +70 -0
- package/apps/jsmdcui/CHANGELOG.md +10 -0
- package/apps/jsmdcui/README.md +2 -0
- package/apps/jsmdcui/package.json +1 -1
- package/apps/jsmdcui/runtime/help/commands.md +4 -1
- package/apps/jsmdcui/runtime/help/help.md +2 -0
- package/apps/jsmdcui/src/index.js +40 -13
- package/package.json +1 -1
package/apps/bunmsh/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-visible changes to bunmsh are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.3.9] - 2026-09-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Make pathname expansion follow directory symbolic links used as intermediate
|
|
10
|
+
path components, matching traditional shell behaviour. `Bun.Glob` disables
|
|
11
|
+
that traversal by default, so patterns such as `./*/*.js` silently skipped
|
|
12
|
+
a matching directory symlink, and sysfs patterns such as
|
|
13
|
+
`/sys/class/net/*/device` failed to pass through class-device links. bunmsh
|
|
14
|
+
now enables `followSymlinks` while scanning; a symlink matched as the final
|
|
15
|
+
component is still passed to the command as a symlink.
|
|
16
|
+
|
|
17
|
+
- Stop the fallback `ls`/`lsfancy` from printing every glob-expanded file as
|
|
18
|
+
a directory section. `builtin ls L*` previously expanded the glob
|
|
19
|
+
correctly, but multiple operands unconditionally received `filename:`
|
|
20
|
+
headings and blank separators. Only directories whose contents are being
|
|
21
|
+
listed now receive section headings; multiple ordinary files form one
|
|
22
|
+
continuous listing and retain their full operand paths rather than being
|
|
23
|
+
reduced to basenames.
|
|
24
|
+
|
|
25
|
+
- Honor `--` in `ls`/`lsfancy`, so names such as `-odd` after it are operands
|
|
26
|
+
rather than option clusters. Also follow a command-line symlink to a
|
|
27
|
+
directory for ordinary and recursive listings, as traditional `ls` does;
|
|
28
|
+
`-d`, `-l`, and `-F` continue to inspect the symlink itself.
|
|
29
|
+
|
|
30
|
+
## [0.3.8] - 2026-09-21
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- Make `Ctrl-U` and `Ctrl-K` toggle their edits in the interactive line
|
|
35
|
+
editor. `Ctrl-U` saves the text removed before the cursor and restores it
|
|
36
|
+
when pressed again at the beginning of the line; `Ctrl-K` does the same for
|
|
37
|
+
text after the cursor when pressed again at the end. Each shortcut keeps an
|
|
38
|
+
independent saved value, and control bytes inside bracketed paste remain
|
|
39
|
+
literal pasted content rather than triggering either action.
|
|
40
|
+
|
|
5
41
|
## [0.3.7] - 2026-09-20
|
|
6
42
|
|
|
7
43
|
### Fixed
|
package/apps/bunmsh/README.md
CHANGED
|
@@ -476,6 +476,13 @@ Bun.e, Bun.file("/tm # -> a path, completed inside the string
|
|
|
476
476
|
|
|
477
477
|
- `Ctrl-D`: On an empty input line, sends EOF and exits the interactive shell.
|
|
478
478
|
|
|
479
|
+
- `Ctrl-U`: Removes and saves everything before the cursor. Press it again
|
|
480
|
+
while the cursor is at the beginning to restore that saved prefix.
|
|
481
|
+
|
|
482
|
+
- `Ctrl-K`: Removes and saves everything after the cursor. Press it again
|
|
483
|
+
while the cursor is at the end to restore that saved suffix. Its saved text
|
|
484
|
+
is independent from `Ctrl-U`.
|
|
485
|
+
|
|
479
486
|
- `Ctrl-T`: Calls `builtin tab` without adding a command to history. It creates
|
|
480
487
|
a tab when only one exists, or switches to the next tab otherwise. Any
|
|
481
488
|
command text currently being edited is preserved.
|
package/apps/bunmsh/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readlinkSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif", "heic"]);
|
|
5
5
|
const MUSIC = new Set(["mp3", "wav", "flac", "ogg", "m4a", "aac", "opus", "mid", "midi"]);
|
|
@@ -120,9 +120,11 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
|
|
|
120
120
|
recursive: false, time: false, reverse: false, size: false, one: false, classify: false,
|
|
121
121
|
};
|
|
122
122
|
const operands = [];
|
|
123
|
+
let parseOptions = true;
|
|
123
124
|
for (const argument of argv.slice(1)) {
|
|
124
|
-
if (
|
|
125
|
-
if (
|
|
125
|
+
if (parseOptions && argument === "--") { parseOptions = false; continue; }
|
|
126
|
+
if (parseOptions && (argument === "--color=auto" || argument === "--color")) continue;
|
|
127
|
+
if (parseOptions && /^-[^-]/.test(argument)) {
|
|
126
128
|
for (const flag of argument.slice(1)) {
|
|
127
129
|
if (flag === "a") options.all = true;
|
|
128
130
|
else if (flag === "A") options.almostAll = true;
|
|
@@ -142,15 +144,30 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
|
|
|
142
144
|
if (!operands.length) operands.push(".");
|
|
143
145
|
let status = 0, stdout = "", stderr = "";
|
|
144
146
|
const visited = new Set();
|
|
145
|
-
|
|
147
|
+
let previousWasDirectory = false;
|
|
148
|
+
const show = (operand, heading = operands.length > 1, separate = true) => {
|
|
146
149
|
const path = isAbsolute(operand) ? operand : resolve(state.cwd, operand);
|
|
147
150
|
try {
|
|
148
151
|
const stats = lstatSync(path);
|
|
152
|
+
let listingDirectory = stats.isDirectory() && !options.directory;
|
|
153
|
+
// GNU/POSIX-style ls follows a command-line symlink to a directory for
|
|
154
|
+
// an ordinary listing and for -R. -d, -l, and -F request information
|
|
155
|
+
// about the link itself instead. A broken link simply cannot qualify.
|
|
156
|
+
if (stats.isSymbolicLink() && !options.directory && !options.long && !options.classify) {
|
|
157
|
+
try { listingDirectory = statSync(path).isDirectory(); } catch {}
|
|
158
|
+
}
|
|
159
|
+
// Multiple file operands are one listing, not a series of directory
|
|
160
|
+
// sections. Only actual directory contents receive `name:` headings
|
|
161
|
+
// and blank section separators.
|
|
162
|
+
if (separate && stdout && (listingDirectory || previousWasDirectory)) stdout += "\n";
|
|
149
163
|
let entries;
|
|
150
|
-
if (!
|
|
151
|
-
|
|
164
|
+
if (!listingDirectory)
|
|
165
|
+
// Preserve the operand spelling just like traditional ls. A glob such
|
|
166
|
+
// as ./*/*.js has already expanded to paths, and reducing each one to
|
|
167
|
+
// basename would lose the information that distinguishes its parent.
|
|
168
|
+
entries = [{ name: operand, stats, path }];
|
|
152
169
|
else entries = listDirectory(path, options);
|
|
153
|
-
if (heading) stdout += `${operand}:\n`;
|
|
170
|
+
if (heading && listingDirectory) stdout += `${operand}:\n`;
|
|
154
171
|
const rendered = entries.map((entry) => displayEntry(entry, options));
|
|
155
172
|
if (options.long) {
|
|
156
173
|
for (let i = 0; i < entries.length; i++) {
|
|
@@ -164,23 +181,21 @@ export function fancyLs(argv, state, terminal = Boolean(process.stdout.isTTY)) {
|
|
|
164
181
|
} else stdout += terminal && !options.one
|
|
165
182
|
? columns(rendered, process.stdout.columns ?? 80)
|
|
166
183
|
: `${rendered.join("\n")}${rendered.length ? "\n" : ""}`;
|
|
167
|
-
if (options.recursive &&
|
|
184
|
+
if (options.recursive && listingDirectory) {
|
|
168
185
|
for (const entry of entries) if (entry.stats.isDirectory() && entry.name !== "." && entry.name !== "..") {
|
|
169
186
|
const child = resolve(path, entry.name);
|
|
170
187
|
if (visited.has(child)) continue;
|
|
171
188
|
visited.add(child);
|
|
172
189
|
stdout += "\n";
|
|
173
|
-
show(`${operand.replace(/\/$/, "")}/${entry.name}`, true);
|
|
190
|
+
show(`${operand.replace(/\/$/, "")}/${entry.name}`, true, false);
|
|
174
191
|
}
|
|
175
192
|
}
|
|
193
|
+
previousWasDirectory = listingDirectory;
|
|
176
194
|
} catch (error) {
|
|
177
195
|
status = 1;
|
|
178
196
|
stderr += `bunmsh: ${argv[0]}: ${operand}: ${error.message}\n`;
|
|
179
197
|
}
|
|
180
198
|
};
|
|
181
|
-
for (
|
|
182
|
-
if (i) stdout += "\n";
|
|
183
|
-
show(operands[i]);
|
|
184
|
-
}
|
|
199
|
+
for (const operand of operands) show(operand);
|
|
185
200
|
return { status, stdout, stderr };
|
|
186
201
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function toggleSavedText(line, cursor, saved, side) {
|
|
2
|
+
line = String(line ?? "");
|
|
3
|
+
cursor = Math.max(0, Math.min(line.length, Number.isFinite(cursor) ? cursor : line.length));
|
|
4
|
+
saved = String(saved ?? "");
|
|
5
|
+
|
|
6
|
+
if (side === "head") {
|
|
7
|
+
if (cursor > 0) {
|
|
8
|
+
return {
|
|
9
|
+
line: line.slice(cursor),
|
|
10
|
+
cursor: 0,
|
|
11
|
+
saved: line.slice(0, cursor),
|
|
12
|
+
changed: true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (saved) {
|
|
16
|
+
return {
|
|
17
|
+
line: saved + line,
|
|
18
|
+
cursor: saved.length,
|
|
19
|
+
saved: "",
|
|
20
|
+
changed: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
} else if (side === "tail") {
|
|
24
|
+
if (cursor < line.length) {
|
|
25
|
+
return {
|
|
26
|
+
line: line.slice(0, cursor),
|
|
27
|
+
cursor,
|
|
28
|
+
saved: line.slice(cursor),
|
|
29
|
+
changed: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (saved) {
|
|
33
|
+
return {
|
|
34
|
+
line: line + saved,
|
|
35
|
+
cursor,
|
|
36
|
+
saved: "",
|
|
37
|
+
changed: true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { line, cursor, saved, changed: false };
|
|
43
|
+
}
|
package/apps/bunmsh/src/main.js
CHANGED
|
@@ -29,6 +29,7 @@ import { importedHistory, readlineHistory, saveBunmshHistory } from "./history.j
|
|
|
29
29
|
import pkg from "../package.json" with { type:"json" }
|
|
30
30
|
import { MOUSE_OFF, MOUSE_ON, PASTE_OFF, PASTE_ON, mouseInput } from "./mouse.js";
|
|
31
31
|
import { homeRelativePath } from "./environment.js";
|
|
32
|
+
import { toggleSavedText } from "./line-edit.js";
|
|
32
33
|
|
|
33
34
|
const VERSION = `
|
|
34
35
|
${pkg.name}: ${pkg.description}
|
|
@@ -223,6 +224,8 @@ async function interactive(state) {
|
|
|
223
224
|
let lastTabClick = null;
|
|
224
225
|
let mouseCommandRunning = false;
|
|
225
226
|
let foregroundCommand = false;
|
|
227
|
+
let savedHead = "";
|
|
228
|
+
let savedTail = "";
|
|
226
229
|
let promptAbort = null;
|
|
227
230
|
// Set by onPaste (below) when a bracketed paste contains a newline, and
|
|
228
231
|
// consumed by the main loop's readline.question() catch block right after
|
|
@@ -332,6 +335,17 @@ async function interactive(state) {
|
|
|
332
335
|
const key = { name: delta > 0 ? "right" : "left" };
|
|
333
336
|
for (let n = 0; n < Math.abs(delta); n++) readline.write(null, key);
|
|
334
337
|
};
|
|
338
|
+
const toggleLineText = (side) => {
|
|
339
|
+
if (!readline || foregroundCommand) return;
|
|
340
|
+
const saved = side === "head" ? savedHead : savedTail;
|
|
341
|
+
const result = toggleSavedText(readline.line, readline.cursor, saved, side);
|
|
342
|
+
if (side === "head") savedHead = result.saved;
|
|
343
|
+
else savedTail = result.saved;
|
|
344
|
+
if (!result.changed) return;
|
|
345
|
+
readline.line = result.line;
|
|
346
|
+
readline.cursor = result.cursor;
|
|
347
|
+
readline.prompt(true);
|
|
348
|
+
};
|
|
335
349
|
const filteredInput = terminal ? mouseInput((mouse) => {
|
|
336
350
|
if (!mouse.press || (mouse.button & 3) !== 0 || (mouse.button & 32)) return;
|
|
337
351
|
pendingClick = { ...mouse, at: Date.now() };
|
|
@@ -384,7 +398,7 @@ async function interactive(state) {
|
|
|
384
398
|
else if (shortcut === "lsfancy") runFancyShortcut();
|
|
385
399
|
else if (shortcut === "lsfancy-parent") runFancyShortcut([".."]);
|
|
386
400
|
else if (shortcut === "tab-close") runTabShortcut(["x"]);
|
|
387
|
-
}, onPaste) : process.stdin;
|
|
401
|
+
}, onPaste, toggleLineText) : process.stdin;
|
|
388
402
|
const completer = (line) => {
|
|
389
403
|
commandIndex.refreshIfChanged(state);
|
|
390
404
|
if (javascriptLine(line)) {
|
package/apps/bunmsh/src/mouse.js
CHANGED
|
@@ -21,7 +21,13 @@ function partialMarkerSuffixLength(text, marker) {
|
|
|
21
21
|
return 0;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function mouseInput(
|
|
24
|
+
export function mouseInput(
|
|
25
|
+
onMouse,
|
|
26
|
+
onCursorPosition,
|
|
27
|
+
onShortcut = () => {},
|
|
28
|
+
onPaste = () => {},
|
|
29
|
+
onEditShortcut = () => {},
|
|
30
|
+
) {
|
|
25
31
|
const decoder = new StringDecoder("utf8");
|
|
26
32
|
let pending = "";
|
|
27
33
|
// Bracketed paste (\x1b[200~ ... \x1b[201~): everything between the
|
|
@@ -48,6 +54,25 @@ export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onP
|
|
|
48
54
|
source = source.replace(/\x1bc/g, () => { onShortcut("tab-close"); return ""; });
|
|
49
55
|
}
|
|
50
56
|
let output = "";
|
|
57
|
+
const flushOutput = () => {
|
|
58
|
+
if (!output) return;
|
|
59
|
+
this.push(output);
|
|
60
|
+
output = "";
|
|
61
|
+
};
|
|
62
|
+
const appendPlainText = (text) => {
|
|
63
|
+
let start = 0;
|
|
64
|
+
for (let index = 0; index < text.length; index++) {
|
|
65
|
+
const side = text[index] === "\x15" ? "head"
|
|
66
|
+
: text[index] === "\x0b" ? "tail"
|
|
67
|
+
: null;
|
|
68
|
+
if (!side) continue;
|
|
69
|
+
output += text.slice(start, index);
|
|
70
|
+
flushOutput();
|
|
71
|
+
onEditShortcut(side);
|
|
72
|
+
start = index + 1;
|
|
73
|
+
}
|
|
74
|
+
output += text.slice(start);
|
|
75
|
+
};
|
|
51
76
|
while (source) {
|
|
52
77
|
if (pasting) {
|
|
53
78
|
const end = source.indexOf(PASTE_END);
|
|
@@ -66,8 +91,8 @@ export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onP
|
|
|
66
91
|
continue;
|
|
67
92
|
}
|
|
68
93
|
const escape = source.indexOf("\x1b[");
|
|
69
|
-
if (escape < 0) {
|
|
70
|
-
|
|
94
|
+
if (escape < 0) { appendPlainText(source); break; }
|
|
95
|
+
appendPlainText(source.slice(0, escape));
|
|
71
96
|
source = source.slice(escape);
|
|
72
97
|
if (source.startsWith(PASTE_START)) {
|
|
73
98
|
pasting = true;
|
|
@@ -93,7 +118,7 @@ export function mouseInput(onMouse, onCursorPosition, onShortcut = () => {}, onP
|
|
|
93
118
|
output += source.slice(0, 2);
|
|
94
119
|
source = source.slice(2);
|
|
95
120
|
}
|
|
96
|
-
|
|
121
|
+
flushOutput();
|
|
97
122
|
done();
|
|
98
123
|
},
|
|
99
124
|
});
|
package/apps/bunmsh/src/shell.js
CHANGED
|
@@ -787,7 +787,15 @@ async function pathnameFields(field, state) {
|
|
|
787
787
|
return ch;
|
|
788
788
|
}).join("");
|
|
789
789
|
const glob = new Bun.Glob(pattern);
|
|
790
|
-
|
|
790
|
+
// Shell pathname expansion follows a directory symlink used as an
|
|
791
|
+
// intermediate path component. Bun.Glob defaults this off, which makes
|
|
792
|
+
// patterns such as /sys/class/*/device silently miss sysfs class links.
|
|
793
|
+
const matches = [...glob.scanSync({
|
|
794
|
+
cwd: state.cwd,
|
|
795
|
+
dot: false,
|
|
796
|
+
onlyFiles: false,
|
|
797
|
+
followSymlinks: true,
|
|
798
|
+
})].sort();
|
|
791
799
|
return matches.length ? matches : [value];
|
|
792
800
|
}
|
|
793
801
|
|
|
@@ -53,6 +53,7 @@ import { fancyLs } from "../src/fancy-ls.js";
|
|
|
53
53
|
import { MOUSE_OFF, MOUSE_ON, mouseInput } from "../src/mouse.js";
|
|
54
54
|
import { canonicalEnvironment, environmentValue, homeRelativePath } from "../src/environment.js";
|
|
55
55
|
import { findIsRegularBuiltin } from "../src/find.js";
|
|
56
|
+
import { toggleSavedText } from "../src/line-edit.js";
|
|
56
57
|
|
|
57
58
|
async function run(source, options = {}) {
|
|
58
59
|
const state = createState({
|
|
@@ -181,6 +182,36 @@ describe("completion", () => {
|
|
|
181
182
|
]);
|
|
182
183
|
});
|
|
183
184
|
|
|
185
|
+
test("toggles independently saved Ctrl-U heads and Ctrl-K tails", () => {
|
|
186
|
+
const cutHead = toggleSavedText("headtail", 4, "", "head");
|
|
187
|
+
expect(cutHead).toEqual({ line: "tail", cursor: 0, saved: "head", changed: true });
|
|
188
|
+
expect(toggleSavedText(cutHead.line, cutHead.cursor, cutHead.saved, "head"))
|
|
189
|
+
.toEqual({ line: "headtail", cursor: 4, saved: "", changed: true });
|
|
190
|
+
|
|
191
|
+
const cutTail = toggleSavedText("headtail", 4, "", "tail");
|
|
192
|
+
expect(cutTail).toEqual({ line: "head", cursor: 4, saved: "tail", changed: true });
|
|
193
|
+
expect(toggleSavedText(cutTail.line, cutTail.cursor, cutTail.saved, "tail"))
|
|
194
|
+
.toEqual({ line: "headtail", cursor: 4, saved: "", changed: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("intercepts Ctrl-U and Ctrl-K outside bracketed paste", async () => {
|
|
198
|
+
const edits = [];
|
|
199
|
+
const pasted = [];
|
|
200
|
+
let forwarded = "";
|
|
201
|
+
const input = mouseInput(() => {}, () => {}, () => {},
|
|
202
|
+
(text) => pasted.push(text),
|
|
203
|
+
(side) => edits.push({ side, forwarded }));
|
|
204
|
+
input.on("data", (chunk) => { forwarded += chunk.toString(); });
|
|
205
|
+
input.end("abc\x15def\x0b\x1b[200~x\x15y\x0bz\x1b[201~");
|
|
206
|
+
await new Promise((resolve) => input.once("end", resolve));
|
|
207
|
+
expect(edits).toEqual([
|
|
208
|
+
{ side: "head", forwarded: "abc" },
|
|
209
|
+
{ side: "tail", forwarded: "abcdef" },
|
|
210
|
+
]);
|
|
211
|
+
expect(forwarded).toBe("abcdef");
|
|
212
|
+
expect(pasted).toEqual(["x\x15y\x0bz"]);
|
|
213
|
+
});
|
|
214
|
+
|
|
184
215
|
test("imports Bash and Fish history by default and can be disabled", async () => {
|
|
185
216
|
expect(parseBashHistory("#1720000000\necho bash\n\nshared\n"))
|
|
186
217
|
.toEqual(["echo bash", "shared"]);
|
|
@@ -575,6 +606,7 @@ describe("execution", () => {
|
|
|
575
606
|
mkdirSync(join(cwd, "targetdir"));
|
|
576
607
|
symlinkSync("target.txt", join(cwd, "link_ok"));
|
|
577
608
|
symlinkSync("targetdir", join(cwd, "link_dir"));
|
|
609
|
+
await Bun.write(join(cwd, "targetdir", "inside.js"), "export {};\n");
|
|
578
610
|
symlinkSync("/nonexistent/path", join(cwd, "link_broken"));
|
|
579
611
|
symlinkSync("loop_b", join(cwd, "loop_a"));
|
|
580
612
|
symlinkSync("loop_a", join(cwd, "loop_b"));
|
|
@@ -597,6 +629,15 @@ describe("execution", () => {
|
|
|
597
629
|
const plain = await run("builtin lsfancy", { cwd });
|
|
598
630
|
expect(plain.stdout).toContain("🔗 link_ok");
|
|
599
631
|
expect(plain.stdout).toContain("🚫 link_broken");
|
|
632
|
+
|
|
633
|
+
// A command-line link to a directory is followed for an ordinary
|
|
634
|
+
// listing, but -l, -d, and -F inspect/classify the link itself.
|
|
635
|
+
const followed = await run("builtin lsfancy link_dir", { cwd });
|
|
636
|
+
expect(followed.stdout).toContain("inside.js");
|
|
637
|
+
const linkLong = await run("builtin lsfancy -l link_dir", { cwd });
|
|
638
|
+
expect(linkLong.stdout).toContain("link_dir -> targetdir");
|
|
639
|
+
expect((await run("builtin lsfancy -d link_dir", { cwd })).stdout).toContain("🔗 link_dir");
|
|
640
|
+
expect((await run("builtin lsfancy -F link_dir", { cwd })).stdout).toContain("link_dir@");
|
|
600
641
|
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
|
601
642
|
});
|
|
602
643
|
|
|
@@ -624,6 +665,35 @@ describe("execution", () => {
|
|
|
624
665
|
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
|
625
666
|
});
|
|
626
667
|
|
|
668
|
+
test("builtin ls lists glob-expanded files as files, not directory sections", async () => {
|
|
669
|
+
const cwd = mkdtempSync(join(tmpdir(), "bunmsh-ls-glob-"));
|
|
670
|
+
try {
|
|
671
|
+
await Bun.write(join(cwd, "first.sh"), "#!/bin/sh\n");
|
|
672
|
+
await Bun.write(join(cwd, "final.sh"), "#!/bin/sh\n");
|
|
673
|
+
await Bun.write(join(cwd, "other.txt"), "other\n");
|
|
674
|
+
const output = await run("builtin ls f*.sh", { cwd });
|
|
675
|
+
expect(output).toMatchObject({ status: 0, stderr: "" });
|
|
676
|
+
expect(output.stdout).toContain("first.sh");
|
|
677
|
+
expect(output.stdout).toContain("final.sh");
|
|
678
|
+
expect(output.stdout).not.toContain("other.txt");
|
|
679
|
+
expect(output.stdout).not.toContain("first.sh:");
|
|
680
|
+
expect(output.stdout).not.toContain("final.sh:");
|
|
681
|
+
expect(output.stdout).not.toContain("\n\n");
|
|
682
|
+
|
|
683
|
+
await Bun.write(join(cwd, "-odd"), "odd\n");
|
|
684
|
+
const afterDoubleDash = await run("builtin ls -- -odd", { cwd });
|
|
685
|
+
expect(afterDoubleDash).toMatchObject({ status: 0, stderr: "" });
|
|
686
|
+
expect(afterDoubleDash.stdout).toContain("-odd");
|
|
687
|
+
|
|
688
|
+
mkdirSync(join(cwd, ".hidden-target"));
|
|
689
|
+
await Bun.write(join(cwd, ".hidden-target", "through-link.js"), "export {};\n");
|
|
690
|
+
symlinkSync(".hidden-target", join(cwd, "class-link"));
|
|
691
|
+
const throughLink = await run("builtin ls ./*/*.js", { cwd });
|
|
692
|
+
expect(throughLink).toMatchObject({ status: 0, stderr: "" });
|
|
693
|
+
expect(throughLink.stdout).toContain("./class-link/through-link.js");
|
|
694
|
+
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
|
695
|
+
});
|
|
696
|
+
|
|
627
697
|
test("fallback find filters paths and supports both -exec modes", async () => {
|
|
628
698
|
const cwd = mkdtempSync(join(tmpdir(), "bunmsh-find-"));
|
|
629
699
|
try {
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-visible changes to jsmdcui are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.19.2] - 2026-09-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Quickly pressing `Ctrl-W` twice now switches to the next tab like `Alt-T`
|
|
10
|
+
from either an editor or terminal pane. A single press retains its existing
|
|
11
|
+
next-pane behavior. At the rightmost tab, when every tab contains only
|
|
12
|
+
terminal panes, the double press creates a new editor tab instead of cycling
|
|
13
|
+
back, ensuring keyboard-only access to editing and other non-terminal work.
|
|
14
|
+
|
|
5
15
|
## [0.19.0] - 2026-08-24
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/apps/jsmdcui/README.md
CHANGED
|
@@ -793,6 +793,8 @@ selection, search, and copy remain available.
|
|
|
793
793
|
| `Ctrl-F` | Search; use `Ctrl-N` and `Ctrl-P` for the next and previous match. |
|
|
794
794
|
| `Ctrl-G` | Open the editor help. |
|
|
795
795
|
| `Alt-G` | Show or hide the shortcut bar. |
|
|
796
|
+
| `Ctrl-W` | Switch to the next pane in the current tab. |
|
|
797
|
+
| `Ctrl-W` twice quickly | Switch to the next tab like `Alt-T`. At the rightmost tab, if every tab contains only terminal panes, create a new editor tab instead of cycling back. |
|
|
796
798
|
| `Ctrl-Q` or `Alt-Q` | Close the current UI. |
|
|
797
799
|
|
|
798
800
|
To preview another color theme, press `Ctrl-E` or click on `€`, type `theme `, then use `Tab`
|
|
@@ -151,7 +151,10 @@ quotes here but these are not necessary when entering the command in micro.
|
|
|
151
151
|
|
|
152
152
|
* `term ['exec']`: Open a terminal emulator running the given executable. If no
|
|
153
153
|
executable is given, this will open the default shell in the terminal
|
|
154
|
-
emulator.
|
|
154
|
+
emulator. `Ctrl-W` switches panes in both editor and terminal panes. Press
|
|
155
|
+
it twice quickly to cycle to the next tab like `Alt-T`; at the rightmost
|
|
156
|
+
tab, if every tab is a terminal, this creates a new editor tab instead of
|
|
157
|
+
cycling back.
|
|
155
158
|
|
|
156
159
|
---
|
|
157
160
|
|
|
@@ -793,6 +793,8 @@ selection, search, and copy remain available.
|
|
|
793
793
|
| `Ctrl-F` | Search; use `Ctrl-N` and `Ctrl-P` for the next and previous match. |
|
|
794
794
|
| `Ctrl-G` | Open the editor help. |
|
|
795
795
|
| `Alt-G` | Show or hide the shortcut bar. |
|
|
796
|
+
| `Ctrl-W` | Switch to the next pane in the current tab. |
|
|
797
|
+
| `Ctrl-W` twice quickly | Switch to the next tab like `Alt-T`. At the rightmost tab, if every tab contains only terminal panes, create a new editor tab instead of cycling back. |
|
|
796
798
|
| `Ctrl-Q` or `Alt-Q` | Close the current UI. |
|
|
797
799
|
|
|
798
800
|
To preview another color theme, press `Ctrl-E` or click on `€`, type `theme `, then use `Tab`
|
|
@@ -2796,6 +2796,8 @@ class App {
|
|
|
2796
2796
|
this.tabRects = [];
|
|
2797
2797
|
this._escBuf = null; // pending lone ESC bytes waiting for alt-key combo
|
|
2798
2798
|
this._escTimer = null;
|
|
2799
|
+
this._ctrlWTime = 0;
|
|
2800
|
+
this._ctrlWTimer = null;
|
|
2799
2801
|
this._suggestionsRow = null;
|
|
2800
2802
|
this._suggestionRects = [];
|
|
2801
2803
|
this._acHScroll = 0;
|
|
@@ -3790,7 +3792,7 @@ class App {
|
|
|
3790
3792
|
const titleStyle = { ...defaultStyle, reverse: true };
|
|
3791
3793
|
const scrollMsg = vt && vt.scrollOffset > 0
|
|
3792
3794
|
? ` -- SCROLLBACK (${vt.scrollOffset}/${vt.scrollback.length}) wheel↑↓ to browse, any key to return`
|
|
3793
|
-
: (isActive ? " [Esc
|
|
3795
|
+
: (isActive ? " [Esc=close,Ctrl-W/*2=Next Pane/Tab]" : "");
|
|
3794
3796
|
putText(this.screen, pane.x, pane.y, ` Terminal${scrollMsg}`.padEnd(pane.w), titleStyle, pane.w);
|
|
3795
3797
|
if (!vt) return;
|
|
3796
3798
|
const renderRows = pane.h - 1;
|
|
@@ -4228,14 +4230,12 @@ class App {
|
|
|
4228
4230
|
this.render();
|
|
4229
4231
|
return;
|
|
4230
4232
|
}
|
|
4231
|
-
// Ctrl-W: switch pane focus without closing
|
|
4233
|
+
// Ctrl-W: switch pane focus without closing. A quick second Ctrl-W
|
|
4234
|
+
// cycles tabs, with an editor-tab escape hatch when every tab is a
|
|
4235
|
+
// terminal and the current one is already the rightmost tab.
|
|
4232
4236
|
if (text === "\x17") {
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
const idx = panes.indexOf(this.tab.activePane);
|
|
4236
|
-
this.tab.activePane = panes[(idx + 1) % panes.length];
|
|
4237
|
-
this.render();
|
|
4238
|
-
}
|
|
4237
|
+
await this.handleCtrlW();
|
|
4238
|
+
this.render();
|
|
4239
4239
|
return;
|
|
4240
4240
|
}
|
|
4241
4241
|
|
|
@@ -4525,11 +4525,7 @@ class App {
|
|
|
4525
4525
|
await runAction("AddTab", this);
|
|
4526
4526
|
break;
|
|
4527
4527
|
case "ctrl-w": { //switchPane
|
|
4528
|
-
|
|
4529
|
-
if (panes.length > 1) {
|
|
4530
|
-
const idx = panes.indexOf(this.tab.activePane);
|
|
4531
|
-
this.tab.activePane = panes[(idx + 1) % panes.length];
|
|
4532
|
-
}
|
|
4528
|
+
await this.handleCtrlW();
|
|
4533
4529
|
break;
|
|
4534
4530
|
}
|
|
4535
4531
|
case "ctrl-y": //redo
|
|
@@ -5403,6 +5399,37 @@ class App {
|
|
|
5403
5399
|
return this.setActiveTab((this.activeTabIdx + 1) % this.tabs.length);
|
|
5404
5400
|
}
|
|
5405
5401
|
|
|
5402
|
+
_clearCtrlW() {
|
|
5403
|
+
if (this._ctrlWTimer) clearTimeout(this._ctrlWTimer);
|
|
5404
|
+
this._ctrlWTimer = null;
|
|
5405
|
+
this._ctrlWTime = 0;
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5408
|
+
async handleCtrlW() {
|
|
5409
|
+
const now = Date.now();
|
|
5410
|
+
const isDouble = this._ctrlWTime > 0 && now - this._ctrlWTime < 400;
|
|
5411
|
+
this._clearCtrlW();
|
|
5412
|
+
|
|
5413
|
+
if (isDouble) {
|
|
5414
|
+
const atRightmostTab = this.activeTabIdx === this.tabs.length - 1;
|
|
5415
|
+
const allTabsAreTerminal = this.tabs.every((tab) =>
|
|
5416
|
+
tab.panes().length > 0 && tab.panes().every((pane) => pane.type === "term")
|
|
5417
|
+
);
|
|
5418
|
+
if (atRightmostTab && allTabsAreTerminal) await this.addTab();
|
|
5419
|
+
else this.nextTab();
|
|
5420
|
+
return;
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
this._ctrlWTime = now;
|
|
5424
|
+
this._ctrlWTimer = setTimeout(() => this._clearCtrlW(), 400);
|
|
5425
|
+
|
|
5426
|
+
const panes = this.tab.panes();
|
|
5427
|
+
if (panes.length > 1) {
|
|
5428
|
+
const idx = panes.indexOf(this.tab.activePane);
|
|
5429
|
+
this.tab.activePane = panes[(idx + 1) % panes.length];
|
|
5430
|
+
}
|
|
5431
|
+
}
|
|
5432
|
+
|
|
5406
5433
|
async handlePrompt(text) {
|
|
5407
5434
|
const key = parseKey(text);
|
|
5408
5435
|
|
package/package.json
CHANGED