@neosh/api 0.3.0 → 0.4.1
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/package.json +1 -1
- package/src/generated/ApiCall.ts +114 -0
- package/src/generated/ApiOk.ts +10 -1
- package/src/generated/AscpMessage.ts +2 -0
- package/src/generated/AuthRef.ts +6 -1
- package/src/generated/ChecksState.ts +14 -0
- package/src/generated/CredentialSource.ts +1 -0
- package/src/generated/Direction.ts +6 -0
- package/src/generated/Dock.ts +10 -4
- package/src/generated/FloatConfig.ts +33 -0
- package/src/generated/ModelInfo.ts +16 -0
- package/src/generated/NodeCapabilities.ts +11 -0
- package/src/generated/PaneChild.ts +18 -0
- package/src/generated/PaneId.ts +15 -0
- package/src/generated/PaneNode.ts +17 -0
- package/src/generated/PluginEvent.ts +1 -0
- package/src/generated/PullRequest.ts +30 -0
- package/src/generated/PullState.ts +11 -0
- package/src/generated/RemoteProject.ts +18 -0
- package/src/generated/ScrollAmount.ts +16 -0
- package/src/generated/SplitDir.ts +12 -0
- package/src/generated/TabId.ts +10 -0
- package/src/generated/TabInfo.ts +27 -0
- package/src/generated/UiEvent.ts +4 -0
- package/src/generated/WindowLayout.ts +11 -0
- package/src/index.ts +444 -9
- package/src/ui.ts +403 -70
package/src/ui.ts
CHANGED
|
@@ -513,7 +513,11 @@ export async function picker<T>(
|
|
|
513
513
|
items: PickerItem<T>[],
|
|
514
514
|
opts: PickerOptions<T> = {},
|
|
515
515
|
): Promise<T | null> {
|
|
516
|
-
|
|
516
|
+
// What the list would like. What it *gets* is settled by `measure` below, once the frontend has
|
|
517
|
+
// said how much of the screen there was — a picker asking for twelve rows on a sixteen-row
|
|
518
|
+
// terminal is not a twelve-row picker, and every number this widget computes from `rows` was
|
|
519
|
+
// wrong by the difference until it asked.
|
|
520
|
+
let rows = Math.max(1, opts.height ?? 12);
|
|
517
521
|
const width = Math.max(20, opts.width ?? 64);
|
|
518
522
|
const filtering = opts.filter !== false;
|
|
519
523
|
|
|
@@ -530,13 +534,13 @@ export async function picker<T>(
|
|
|
530
534
|
anchor: opts.anchor ?? { kind: "screen" },
|
|
531
535
|
offset: opts.offset,
|
|
532
536
|
width: { kind: "fixed", n: width },
|
|
533
|
-
// Exactly the rows that get drawn: the list, plus a filter line when there is one
|
|
534
|
-
// when there is one
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
},
|
|
537
|
+
// Exactly the rows that get drawn: the list, plus a filter line when there is one and a title
|
|
538
|
+
// when there is one. The key strip is not among them any more — it is on the bottom border,
|
|
539
|
+
// where it costs no row and cannot be the row that does not fit. It was a row, and on a
|
|
540
|
+
// sixteen-row terminal it was reliably the one clipped: the strip that says how to get out.
|
|
541
|
+
height: { kind: "fixed", n: rows + (filtering ? 1 : 0) + (opts.title ? 1 : 0) },
|
|
539
542
|
border: "rounded",
|
|
543
|
+
footer: hints === "" ? undefined : ` ${hints} `,
|
|
540
544
|
focusable: true,
|
|
541
545
|
closeOnBlur: true,
|
|
542
546
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -625,7 +629,7 @@ export async function picker<T>(
|
|
|
625
629
|
if (closed) return;
|
|
626
630
|
// Keep the cursor on screen without recentring on every keystroke.
|
|
627
631
|
if (cursor < top) top = cursor;
|
|
628
|
-
if (cursor >= top +
|
|
632
|
+
if (cursor >= top + rows) top = cursor - rows + 1;
|
|
629
633
|
|
|
630
634
|
const lines: string[] = [];
|
|
631
635
|
if (opts.title) lines.push(opts.title);
|
|
@@ -636,7 +640,7 @@ export async function picker<T>(
|
|
|
636
640
|
// dock. Measuring against anything else puts the last visible character on both lines — the
|
|
637
641
|
// continuation says `/ finds` under a row that already ended in `/`.
|
|
638
642
|
const inner = Math.max(8, width);
|
|
639
|
-
let window = visible.slice(top, top +
|
|
643
|
+
let window = visible.slice(top, top + rows);
|
|
640
644
|
// One gutter for the whole list, or none at all. Giving it only to the rows that asked for an
|
|
641
645
|
// icon would step every other label one column left, which reads as a list that cannot decide
|
|
642
646
|
// where its left margin is.
|
|
@@ -672,7 +676,7 @@ export async function picker<T>(
|
|
|
672
676
|
// float's height: the window was sized for `height` lines and growing past it would push the
|
|
673
677
|
// key strip off the bottom, which is the row that says how to get out.
|
|
674
678
|
if (rest.length > 0) {
|
|
675
|
-
const room = Math.max(1,
|
|
679
|
+
const room = Math.max(1, rows - rest.length);
|
|
676
680
|
if (cursor < top) top = cursor;
|
|
677
681
|
if (cursor >= top + room) top = cursor - room + 1;
|
|
678
682
|
window = visible.slice(top, top + room);
|
|
@@ -691,23 +695,6 @@ export async function picker<T>(
|
|
|
691
695
|
if (!isCursor) continue;
|
|
692
696
|
for (const line of rest) lines.push(` ${line}`);
|
|
693
697
|
}
|
|
694
|
-
// Pushed onto the last row rather than floated: the float is sized for it, and a strip that
|
|
695
|
-
// moved up as the list shortened would be a strip you have to look for.
|
|
696
|
-
//
|
|
697
|
-
// The placeholder takes a row of the list's own space, so an empty list has used one of the
|
|
698
|
-
// `height` rows and not none. Counting it as none put the strip one row past the bottom of a
|
|
699
|
-
// float sized for exactly `height`, where it was silently clipped — leaving the empty state,
|
|
700
|
-
// the one state where you most want to be told what the keys do, as the only one with no keys
|
|
701
|
-
// on it.
|
|
702
|
-
// Lines, not rows: the row under the cursor is more than one of them when it has unfolded, and
|
|
703
|
-
// counting rows here would leave the strip that many lines low — off the bottom of a float
|
|
704
|
-
// sized for exactly `height`.
|
|
705
|
-
const listRows = Math.max(1, lines.length - firstListLine);
|
|
706
|
-
const hintLine = hints === "" ? -1 : lines.length + Math.max(0, height - listRows);
|
|
707
|
-
if (hintLine >= 0) {
|
|
708
|
-
while (lines.length < hintLine) lines.push("");
|
|
709
|
-
lines.push(` ${hints}`);
|
|
710
|
-
}
|
|
711
698
|
// Marks are collected against their row and handed over with the text, in one call. Set one at
|
|
712
699
|
// a time they were a sequence the frontend could draw the middle of: the moment the clear had
|
|
713
700
|
// landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white.
|
|
@@ -716,9 +703,6 @@ export async function picker<T>(
|
|
|
716
703
|
drawn[line]?.marks!.push({ col, opts: o });
|
|
717
704
|
};
|
|
718
705
|
|
|
719
|
-
if (hintLine >= 0) {
|
|
720
|
-
mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") });
|
|
721
|
-
}
|
|
722
706
|
const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0);
|
|
723
707
|
for (let i = 0; i < window.length; i++) {
|
|
724
708
|
const row = window[i]!;
|
|
@@ -869,10 +853,10 @@ export async function picker<T>(
|
|
|
869
853
|
cursor = Math.max(0, cursor - 1);
|
|
870
854
|
break;
|
|
871
855
|
case "page_down":
|
|
872
|
-
cursor = Math.min(last(), cursor +
|
|
856
|
+
cursor = Math.min(last(), cursor + rows);
|
|
873
857
|
break;
|
|
874
858
|
case "page_up":
|
|
875
|
-
cursor = Math.max(0, cursor -
|
|
859
|
+
cursor = Math.max(0, cursor - rows);
|
|
876
860
|
break;
|
|
877
861
|
case "first":
|
|
878
862
|
cursor = 0;
|
|
@@ -923,6 +907,33 @@ export async function picker<T>(
|
|
|
923
907
|
}, { desc: "picker key" }),
|
|
924
908
|
);
|
|
925
909
|
|
|
910
|
+
/**
|
|
911
|
+
* How many list rows there turned out to be room for.
|
|
912
|
+
*
|
|
913
|
+
* A float is *asked* for a height and given whatever the screen has, so `opts.height` is a wish
|
|
914
|
+
* and this is the answer. Everything above counts in `rows` — where the window starts, what a
|
|
915
|
+
* page step is, how far the cursor may go before the list scrolls — and while that number was the
|
|
916
|
+
* wish, a picker on a short terminal scrolled to keep the cursor on a row that was not drawn.
|
|
917
|
+
*
|
|
918
|
+
* Read once when the panel opens and again whenever the frontend says this window changed size,
|
|
919
|
+
* rather than on every keystroke: a round trip per character typed is latency on the one path in
|
|
920
|
+
* this widget that has to feel immediate.
|
|
921
|
+
*/
|
|
922
|
+
async function measure(): Promise<void> {
|
|
923
|
+
if (closed) return;
|
|
924
|
+
const v = await neosh.win.viewport(win).catch(() => null);
|
|
925
|
+
if (!v) return;
|
|
926
|
+
const next = Math.max(1, v.height - (filtering ? 1 : 0) - (opts.title ? 1 : 0));
|
|
927
|
+
if (next === rows) return;
|
|
928
|
+
rows = next;
|
|
929
|
+
await render();
|
|
930
|
+
}
|
|
931
|
+
disposers.push(
|
|
932
|
+
neosh.event.on("neosh.viewport", (e) => {
|
|
933
|
+
if ((e as { win?: WindowId }).win === win) void measure();
|
|
934
|
+
}),
|
|
935
|
+
);
|
|
936
|
+
|
|
926
937
|
await neosh.focus.push(win);
|
|
927
938
|
disposers.push(await neosh.keymap.capture(win, command));
|
|
928
939
|
// `<C-c>` is bound globally to `interrupt`, which would arm "press again to quit" while a picker
|
|
@@ -935,6 +946,7 @@ export async function picker<T>(
|
|
|
935
946
|
if (typeof opts.query === "function") query = opts.query();
|
|
936
947
|
await refetch();
|
|
937
948
|
await render();
|
|
949
|
+
await measure();
|
|
938
950
|
if (opts.onHighlight) {
|
|
939
951
|
const row = visible[cursor];
|
|
940
952
|
if (row) opts.onHighlight(row.item, row.index);
|
|
@@ -957,6 +969,145 @@ function dropSegment(text: string): string {
|
|
|
957
969
|
return at < 0 ? "" : trimmed.slice(0, at + 1);
|
|
958
970
|
}
|
|
959
971
|
|
|
972
|
+
// ---------------------------------------------------------------------------
|
|
973
|
+
// Reading something too long for the screen
|
|
974
|
+
// ---------------------------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
export interface PagerOptions {
|
|
977
|
+
/** On the top border. */
|
|
978
|
+
title?: string;
|
|
979
|
+
/**
|
|
980
|
+
* On the bottom border, replacing the default legend.
|
|
981
|
+
*
|
|
982
|
+
* The default already names the scroll keys and the way out, read out of nothing — they are fixed
|
|
983
|
+
* defaults on `neosh.scroll` and `^Z` is where a rebinding shows up. Pass your own only to add a
|
|
984
|
+
* verb of your own to it.
|
|
985
|
+
*/
|
|
986
|
+
footer?: string;
|
|
987
|
+
/** Columns of content. The border is drawn outside it. */
|
|
988
|
+
width?: number;
|
|
989
|
+
/**
|
|
990
|
+
* The most rows of content to ask for. Fewer if the content is shorter, fewer still if the screen
|
|
991
|
+
* is — which is the case this whole widget is about, and is why it is a ceiling and not a size.
|
|
992
|
+
*/
|
|
993
|
+
height?: number;
|
|
994
|
+
/**
|
|
995
|
+
* The buffer kind, so a third party can bind keys in *this* panel and find it with `win.ofKind`.
|
|
996
|
+
*
|
|
997
|
+
* Defaults to {@link KIND_PAGER}, which every pager in the workspace shares. Pass your own when
|
|
998
|
+
* the panel is a thing in its own right — a diff, a status — and somebody might reasonably want
|
|
999
|
+
* keys on that and not on every pager there is.
|
|
1000
|
+
*/
|
|
1001
|
+
kind?: string;
|
|
1002
|
+
/** Marks to lay over the rows, in the same shape {@link Neosh.buf.render} takes. */
|
|
1003
|
+
marks?: DrawnRow["marks"][];
|
|
1004
|
+
z?: number;
|
|
1005
|
+
/**
|
|
1006
|
+
* Handed this panel's own close, as soon as it is on screen.
|
|
1007
|
+
*
|
|
1008
|
+
* For the caller that has to be able to put it away itself — a key that toggles, a panel that
|
|
1009
|
+
* closes when the thing it is about goes. Without it the only way out is a key, and a `^Z` that
|
|
1010
|
+
* opens a sheet and then opens a second one behind it is the bug this exists to prevent.
|
|
1011
|
+
*/
|
|
1012
|
+
onOpen?: (close: () => Promise<void>) => void;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** The kind a pager's buffer gets when the caller does not name one. */
|
|
1016
|
+
export const KIND_PAGER = "neosh.pager";
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Show rows you can read but not edit, in a float that scrolls when it does not fit.
|
|
1020
|
+
*
|
|
1021
|
+
* The shape almost every read-only panel in the workspace turned out to want, and the thing each of
|
|
1022
|
+
* them was missing: a float is given whatever height the screen has, so a status of sixty changed
|
|
1023
|
+
* files or a diff of four hundred lines was thirty rows on screen and the rest drawn nowhere, with
|
|
1024
|
+
* no key pointed at it and nothing to say it was there. Every one of them had independently written
|
|
1025
|
+
* "open a float, put lines in it" and independently stopped before the part where the terminal is
|
|
1026
|
+
* short.
|
|
1027
|
+
*
|
|
1028
|
+
* What you get: the reader's motions (`j`/`k`, `^D`/`^U`, `^F`/`^B`, `gg`/`G`, arrows, paging keys)
|
|
1029
|
+
* and the wheel, because the float sets `scroll` and those are ordinary bindings on
|
|
1030
|
+
* `neosh.scroll` — so `^Z` lists them and `init.ts` moves them. A bar down the right border while
|
|
1031
|
+
* anything is hidden. A legend on the bottom border, which cannot scroll away and is not the first
|
|
1032
|
+
* row clipped. `<Esc>`, `q`, `<CR>` and `^C` close it.
|
|
1033
|
+
*
|
|
1034
|
+
* Resolves when it closes.
|
|
1035
|
+
*/
|
|
1036
|
+
export async function pager(
|
|
1037
|
+
neosh: Neosh,
|
|
1038
|
+
lines: string[],
|
|
1039
|
+
opts: PagerOptions = {},
|
|
1040
|
+
): Promise<void> {
|
|
1041
|
+
const buf = await neosh.buf.create({
|
|
1042
|
+
name: `[${opts.title?.trim() || "pager"}]`,
|
|
1043
|
+
scratch: true,
|
|
1044
|
+
kind: opts.kind ?? KIND_PAGER,
|
|
1045
|
+
});
|
|
1046
|
+
const ns = await neosh.ns.create("neosh.ui.pager");
|
|
1047
|
+
await neosh.buf.render(
|
|
1048
|
+
buf,
|
|
1049
|
+
ns,
|
|
1050
|
+
0,
|
|
1051
|
+
-1,
|
|
1052
|
+
lines.map((text, i) => ({ text, marks: opts.marks?.[i] ?? [] })),
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
const win = await neosh.float.open(buf, {
|
|
1056
|
+
anchor: { kind: "screen" },
|
|
1057
|
+
width: opts.width === undefined ? { kind: "auto" } : { kind: "fixed", n: opts.width },
|
|
1058
|
+
// A ceiling, never a size. `fixed` here would ask for rows the screen does not have and get
|
|
1059
|
+
// clipped to them anyway — the difference being that `max` also shrinks to fit content that is
|
|
1060
|
+
// shorter, so a two-line status is a two-line panel rather than a mostly empty box.
|
|
1061
|
+
height: { kind: "max", n: opts.height ?? 30 },
|
|
1062
|
+
border: "rounded",
|
|
1063
|
+
title: opts.title,
|
|
1064
|
+
footer: opts.footer ?? " j k move ^D ^U half gg G ends esc close ",
|
|
1065
|
+
focusable: true,
|
|
1066
|
+
closeOnBlur: true,
|
|
1067
|
+
scroll: true,
|
|
1068
|
+
z: opts.z ?? 200,
|
|
1069
|
+
});
|
|
1070
|
+
await neosh.focus.push(win);
|
|
1071
|
+
|
|
1072
|
+
let settle: () => void = () => {};
|
|
1073
|
+
const done = new Promise<void>((resolve) => {
|
|
1074
|
+
settle = resolve;
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const command = `neosh.ui.pager.key.${++pickerSeq}`;
|
|
1078
|
+
const disposers: Disposable[] = [];
|
|
1079
|
+
let closed = false;
|
|
1080
|
+
const close = async () => {
|
|
1081
|
+
if (closed) return;
|
|
1082
|
+
closed = true;
|
|
1083
|
+
for (const d of disposers) d.dispose();
|
|
1084
|
+
await neosh.focus.pop().catch(() => {});
|
|
1085
|
+
await neosh.win.close(win).catch(() => {});
|
|
1086
|
+
settle();
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
disposers.push(
|
|
1090
|
+
await neosh.cmd.register(command, (_args, key) => {
|
|
1091
|
+
// Only what nothing else claimed reaches here, and the scroll keys are claimed — they resolve
|
|
1092
|
+
// at `neosh.scroll`, which is nearer than this capture is late. So what is left is dismissal.
|
|
1093
|
+
// Which is *named*: a panel you scroll is a panel you spend time in, and "any key closes it"
|
|
1094
|
+
// turns a mistyped letter into losing your place.
|
|
1095
|
+
if (!key || closesPager(key)) void close();
|
|
1096
|
+
}, { desc: "close this panel" }),
|
|
1097
|
+
);
|
|
1098
|
+
disposers.push(await neosh.keymap.capture(win, command));
|
|
1099
|
+
opts.onOpen?.(close);
|
|
1100
|
+
return done;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** `<Esc>`, `q`, `<CR>` and `^C` — the four keys every panel in this workspace closes on. */
|
|
1104
|
+
function closesPager(key: KeyContext): boolean {
|
|
1105
|
+
const { code, mods } = key.key;
|
|
1106
|
+
if (code.kind === "esc" || code.kind === "enter") return true;
|
|
1107
|
+
if (code.kind !== "char") return false;
|
|
1108
|
+
return mods.ctrl ? code.c.toLowerCase() === "c" : code.c.toLowerCase() === "q";
|
|
1109
|
+
}
|
|
1110
|
+
|
|
960
1111
|
// ---------------------------------------------------------------------------
|
|
961
1112
|
// Derived widgets
|
|
962
1113
|
// ---------------------------------------------------------------------------
|
|
@@ -1013,6 +1164,18 @@ export async function confirm(
|
|
|
1013
1164
|
`y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`,
|
|
1014
1165
|
width - 2,
|
|
1015
1166
|
);
|
|
1167
|
+
/**
|
|
1168
|
+
* How many of the detail lines there is room for.
|
|
1169
|
+
*
|
|
1170
|
+
* Settled once the frontend has said how tall this dialog turned out to be — a dialog is asked
|
|
1171
|
+
* for a height and given what the screen has, and the rows it loses are the ones at the bottom.
|
|
1172
|
+
* Which for a question with the answers under the detail meant the answers, so a long `detail` on
|
|
1173
|
+
* a short terminal was a dialog you could not see either option in.
|
|
1174
|
+
*
|
|
1175
|
+
* The detail is what gives way, because the detail is the part you can do without: the question
|
|
1176
|
+
* and the two answers are the dialog. What went is said rather than silently dropped.
|
|
1177
|
+
*/
|
|
1178
|
+
let room = detail.length;
|
|
1016
1179
|
|
|
1017
1180
|
// On the answer that changes nothing, when the other one cannot be taken back.
|
|
1018
1181
|
let cursor = opts.dangerous ? 1 : 0;
|
|
@@ -1025,14 +1188,17 @@ export async function confirm(
|
|
|
1025
1188
|
|
|
1026
1189
|
/** Where each part of the dialog starts, so the marks do not have to count rows twice. */
|
|
1027
1190
|
const detailAt = asked.length + 1;
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1191
|
+
/** Every row but the detail: the question, the blanks around it, and the two answers. */
|
|
1192
|
+
const fixedRows = asked.length + 1 + answers.length;
|
|
1030
1193
|
|
|
1031
1194
|
const win = await neosh.float.open(buf, {
|
|
1032
1195
|
anchor: { kind: "screen" },
|
|
1033
1196
|
width: { kind: "fixed", n: width },
|
|
1034
|
-
height: { kind: "fixed", n:
|
|
1197
|
+
height: { kind: "fixed", n: fixedRows + (detail.length === 0 ? 0 : detail.length + 1) },
|
|
1035
1198
|
border: "rounded",
|
|
1199
|
+
// The key strip, on the edge. It was the last row of the buffer, which made it both the row a
|
|
1200
|
+
// long `detail` pushed off the bottom and a row of height every dialog had to pay for.
|
|
1201
|
+
footer: ` ${strip} `,
|
|
1036
1202
|
focusable: true,
|
|
1037
1203
|
closeOnBlur: true,
|
|
1038
1204
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -1048,15 +1214,20 @@ export async function confirm(
|
|
|
1048
1214
|
});
|
|
1049
1215
|
|
|
1050
1216
|
const render = async () => {
|
|
1217
|
+
const shownDetail = room >= detail.length
|
|
1218
|
+
? detail
|
|
1219
|
+
: // One row of the budget goes to saying what is not being shown, so a dialog that has had to
|
|
1220
|
+
// cut something never looks like a dialog that had nothing more to say.
|
|
1221
|
+
[...detail.slice(0, Math.max(0, room - 1)), `… and ${detail.length - Math.max(0, room - 1)} more lines`];
|
|
1222
|
+
const detailRows = shownDetail.length;
|
|
1223
|
+
const answersAt = detailAt + (detailRows === 0 ? 0 : detailRows + 1);
|
|
1051
1224
|
const lines: string[] = asked.map((l) => ` ${l}`);
|
|
1052
1225
|
lines.push("");
|
|
1053
|
-
if (
|
|
1054
|
-
lines.push(...
|
|
1226
|
+
if (detailRows > 0) {
|
|
1227
|
+
lines.push(...shownDetail.map((l) => ` ${l}`));
|
|
1055
1228
|
lines.push("");
|
|
1056
1229
|
}
|
|
1057
1230
|
answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`));
|
|
1058
|
-
lines.push("");
|
|
1059
|
-
lines.push(` ${strip}`);
|
|
1060
1231
|
// Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the
|
|
1061
1232
|
// frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also
|
|
1062
1233
|
// takes care of the older half of the same bug, that a mark whose line was replaced under it
|
|
@@ -1069,7 +1240,7 @@ export async function confirm(
|
|
|
1069
1240
|
for (let i = 0; i < asked.length; i++) {
|
|
1070
1241
|
mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") });
|
|
1071
1242
|
}
|
|
1072
|
-
for (let i = 0; i <
|
|
1243
|
+
for (let i = 0; i < detailRows; i++) {
|
|
1073
1244
|
mark(detailAt + i, 0, {
|
|
1074
1245
|
hlGroup: "Comment",
|
|
1075
1246
|
endCol: byteLength(lines[detailAt + i] ?? ""),
|
|
@@ -1090,7 +1261,6 @@ export async function confirm(
|
|
|
1090
1261
|
});
|
|
1091
1262
|
}
|
|
1092
1263
|
}
|
|
1093
|
-
mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") });
|
|
1094
1264
|
await neosh.buf.render(buf, ns, 0, -1, drawn);
|
|
1095
1265
|
// The caret marks the answer, since there is nothing here to type into.
|
|
1096
1266
|
await neosh.win.setCursor(win, answersAt + cursor, 0);
|
|
@@ -1161,6 +1331,23 @@ export async function confirm(
|
|
|
1161
1331
|
disposers.push(await neosh.keymap.capture(win, command));
|
|
1162
1332
|
await bindWidgetKeys(neosh, win, command, keys);
|
|
1163
1333
|
await render();
|
|
1334
|
+
// And then how much of it there was room for. Asked after the first draw, because a window the
|
|
1335
|
+
// frontend has never laid out has no answer to give.
|
|
1336
|
+
const measure = async () => {
|
|
1337
|
+
if (closed) return;
|
|
1338
|
+
const v = await neosh.win.viewport(win).catch(() => null);
|
|
1339
|
+
if (!v) return;
|
|
1340
|
+
const next = Math.max(0, v.height - fixedRows - 1);
|
|
1341
|
+
if (next === room) return;
|
|
1342
|
+
room = next;
|
|
1343
|
+
await render();
|
|
1344
|
+
};
|
|
1345
|
+
disposers.push(
|
|
1346
|
+
neosh.event.on("neosh.viewport", (e) => {
|
|
1347
|
+
if ((e as { win?: WindowId }).win === win) void measure();
|
|
1348
|
+
}),
|
|
1349
|
+
);
|
|
1350
|
+
await measure();
|
|
1164
1351
|
return done;
|
|
1165
1352
|
}
|
|
1166
1353
|
|
|
@@ -1352,6 +1539,10 @@ export async function pathPicker(
|
|
|
1352
1539
|
title: string,
|
|
1353
1540
|
opts: { initial?: string; width?: number; height?: number } = {},
|
|
1354
1541
|
): Promise<string | null> {
|
|
1542
|
+
// The last refusal said out loud, so a folder the OS is hiding is reported once rather than on
|
|
1543
|
+
// every keystroke into it. `source` runs per character typed, and a notice per character is the
|
|
1544
|
+
// same message forty times for one cause.
|
|
1545
|
+
let saidDenied: string | null = null;
|
|
1355
1546
|
return picker<string>(neosh, [], {
|
|
1356
1547
|
title,
|
|
1357
1548
|
width: Math.max(40, opts.width ?? 72),
|
|
@@ -1359,8 +1550,19 @@ export async function pathPicker(
|
|
|
1359
1550
|
query: opts.initial ?? "",
|
|
1360
1551
|
placeholder: "no directory matches — <CR> takes what you typed",
|
|
1361
1552
|
source: async (query) => {
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1553
|
+
const answer = await neosh.path
|
|
1554
|
+
.complete(query)
|
|
1555
|
+
.catch(() => ({ paths: [] as string[], denied: undefined }));
|
|
1556
|
+
// An empty list because nobody was allowed to look is not an empty directory, and the
|
|
1557
|
+
// placeholder under this field can only say the second one. Said the way the remote branch
|
|
1558
|
+
// of the same question says it — a notice, because it is feedback for a key you pressed.
|
|
1559
|
+
if (answer.denied && answer.denied !== saidDenied) {
|
|
1560
|
+
saidDenied = answer.denied;
|
|
1561
|
+
neosh.notify(answer.denied, "warn");
|
|
1562
|
+
} else if (!answer.denied) {
|
|
1563
|
+
saidDenied = null;
|
|
1564
|
+
}
|
|
1565
|
+
return answer.paths.map((path) => ({ label: path, value: path }));
|
|
1364
1566
|
},
|
|
1365
1567
|
// What you typed, when it is not one of the offered rows. A completion list that refuses a path
|
|
1366
1568
|
// it did not think of is a list that gets in the way.
|
|
@@ -1386,6 +1588,9 @@ export async function prompt(
|
|
|
1386
1588
|
width: { kind: "fixed", n: width },
|
|
1387
1589
|
height: { kind: "fixed", n: 2 },
|
|
1388
1590
|
border: "rounded",
|
|
1591
|
+
// A field with no legend on it is a field you guess at. On the border, so it costs neither of
|
|
1592
|
+
// the two rows this has.
|
|
1593
|
+
footer: " ↵ accept ^W a word ^U all esc cancel ",
|
|
1389
1594
|
focusable: true,
|
|
1390
1595
|
closeOnBlur: true,
|
|
1391
1596
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -2209,9 +2414,11 @@ export async function railPicker<G, T>(
|
|
|
2209
2414
|
const win = await neosh.float.open(buf, {
|
|
2210
2415
|
anchor: { kind: "screen" },
|
|
2211
2416
|
width: { kind: "fixed", n: total },
|
|
2212
|
-
// title, filter, rule, body, rule,
|
|
2213
|
-
|
|
2417
|
+
// title, filter, rule, body, rule. The key strip is not among them: it is on the bottom border,
|
|
2418
|
+
// where it costs no row of body and cannot be the row a short terminal clips.
|
|
2419
|
+
height: { kind: "fixed", n: height + 4 },
|
|
2214
2420
|
border: "rounded",
|
|
2421
|
+
footer: ` ${opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close"} `,
|
|
2215
2422
|
focusable: true,
|
|
2216
2423
|
closeOnBlur: true,
|
|
2217
2424
|
// Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
|
|
@@ -2321,8 +2528,21 @@ export async function railPicker<G, T>(
|
|
|
2321
2528
|
all = got;
|
|
2322
2529
|
rebuild();
|
|
2323
2530
|
cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1)));
|
|
2324
|
-
// Land on something
|
|
2325
|
-
|
|
2531
|
+
// Land on something you can actually press `↵` on: not a section header, and not a row the
|
|
2532
|
+
// caller disabled. `movePane` has always stepped over disabled rows, so the only way to be
|
|
2533
|
+
// standing on one was to open here — and the first row of a list is where a picker opens by
|
|
2534
|
+
// default. A pane whose top row is "this needs a newer CLI" then greeted every `↵` with
|
|
2535
|
+
// nothing happening, which reads as the picker being broken rather than the row being refused.
|
|
2536
|
+
const landable = (at: number) => {
|
|
2537
|
+
const row = rows[at];
|
|
2538
|
+
return row?.kind === "item" && !row.item.disabled;
|
|
2539
|
+
};
|
|
2540
|
+
if (!landable(cursor)) {
|
|
2541
|
+
const next = rows.findIndex((_, at) => landable(at));
|
|
2542
|
+
// Every row refused is still a list worth opening — you came to read why. Falling back to
|
|
2543
|
+
// the first item rather than to nothing keeps the detail line of *something* on screen.
|
|
2544
|
+
cursor = next >= 0 ? next : rows.findIndex((r) => r.kind === "item");
|
|
2545
|
+
}
|
|
2326
2546
|
if (cursor < 0) cursor = 0;
|
|
2327
2547
|
paneTop = 0;
|
|
2328
2548
|
};
|
|
@@ -2400,7 +2620,11 @@ export async function railPicker<G, T>(
|
|
|
2400
2620
|
? takeWords(detail, Math.max(0, room))
|
|
2401
2621
|
: [clipToWidth(detail, Math.max(0, room)), ""];
|
|
2402
2622
|
const label = padToWidth(labelHead, labelWidth);
|
|
2403
|
-
|
|
2623
|
+
// One column narrower than the cell it sits in, so there is always a space before the detail.
|
|
2624
|
+
// Clipped to the full width, a badge that happens to fill it runs straight into the sentence
|
|
2625
|
+
// after it and the two read as one word.
|
|
2626
|
+
const badgeCell =
|
|
2627
|
+
badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth - 1), badgeWidth);
|
|
2404
2628
|
const text = `${head}${label}${badgeCell}${detailHead}`;
|
|
2405
2629
|
|
|
2406
2630
|
const labelAt = byteLength(head);
|
|
@@ -2528,12 +2752,6 @@ export async function railPicker<G, T>(
|
|
|
2528
2752
|
line: lines.length - 1,
|
|
2529
2753
|
mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" },
|
|
2530
2754
|
});
|
|
2531
|
-
const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close";
|
|
2532
|
-
lines.push(` ${hints}`);
|
|
2533
|
-
marks.push({
|
|
2534
|
-
line: lines.length - 1,
|
|
2535
|
-
mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" },
|
|
2536
|
-
});
|
|
2537
2755
|
|
|
2538
2756
|
// The marks were already collected against their rows; they now travel with them. One call
|
|
2539
2757
|
// rather than one per mark, so there is no frame in which the text has arrived and the colour
|
|
@@ -2751,16 +2969,47 @@ export interface ActionItem {
|
|
|
2751
2969
|
/** For the hint strip and `^Z`. */
|
|
2752
2970
|
label: string;
|
|
2753
2971
|
command: string;
|
|
2754
|
-
/**
|
|
2972
|
+
/**
|
|
2973
|
+
* Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`.
|
|
2974
|
+
*
|
|
2975
|
+
* `custom:<section id>` narrows it to the rows of **one** section: `custom:git` is a verb about
|
|
2976
|
+
* the git block and nothing else. Bare `custom` means every contributed row in the panel, which
|
|
2977
|
+
* is right for a verb about contributions in general and wrong for the usual case — two plugins
|
|
2978
|
+
* each with a block in the column and each wanting `<Tab>` on their own rows were, without this,
|
|
2979
|
+
* one key that one of them won and the other silently lost. A panel binds the key once and sends
|
|
2980
|
+
* it to whichever action matches the row the cursor is actually on.
|
|
2981
|
+
*/
|
|
2755
2982
|
on?: string;
|
|
2756
2983
|
}
|
|
2757
2984
|
|
|
2985
|
+
/** One coloured run of a badge. A part with no `hl` is the panel's own colour — a separator. */
|
|
2986
|
+
export interface BadgePart {
|
|
2987
|
+
text: string;
|
|
2988
|
+
hl?: string;
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* A mark after a row's name: one coloured run, or several.
|
|
2993
|
+
*
|
|
2994
|
+
* Several, because a badge is often several facts rather than one — `↓2` is not the same news as
|
|
2995
|
+
* `~7`, and a strip of git stats drawn in one colour is a string you have to read rather than a
|
|
2996
|
+
* row you can glance at. The single form stays because most badges *are* one fact.
|
|
2997
|
+
*
|
|
2998
|
+
* `short` is the same badge with less in it, for a row too narrow to carry all of it — the fewest
|
|
2999
|
+
* parts still worth drawing, not a truncation. A panel is what knows how many columns there are;
|
|
3000
|
+
* which fact to give up is the badge's to say, exactly as it is for a status segment. Absent means
|
|
3001
|
+
* there is no shorter true version, and the full badge stands.
|
|
3002
|
+
*/
|
|
3003
|
+
export type BadgeSpec =
|
|
3004
|
+
| { text: string; hl?: string }
|
|
3005
|
+
| { parts: BadgePart[]; short?: BadgePart[] };
|
|
3006
|
+
|
|
2758
3007
|
/** A mark on a row a panel already draws, keyed by what the row is about. */
|
|
2759
3008
|
export interface DecorationItem {
|
|
2760
3009
|
/** The row it is about, in the panel's own terms: `{ project: cwd }`, `{ task: id }`. */
|
|
2761
3010
|
target: Record<string, string>;
|
|
2762
|
-
/** A short mark after the name
|
|
2763
|
-
badge?:
|
|
3011
|
+
/** A short mark after the name. The name is clipped to make room. */
|
|
3012
|
+
badge?: BadgeSpec;
|
|
2764
3013
|
/** The row's highlight, when the panel has no opinion of its own. */
|
|
2765
3014
|
hl?: string;
|
|
2766
3015
|
/** The right-hand column, on a row that is not busy. */
|
|
@@ -2769,11 +3018,43 @@ export interface DecorationItem {
|
|
|
2769
3018
|
|
|
2770
3019
|
/** Every decoration on one target, merged. */
|
|
2771
3020
|
export interface Decoration {
|
|
2772
|
-
|
|
3021
|
+
/** `text` is every part run together — what a caller measuring the badge reads. */
|
|
3022
|
+
badge?: { text: string; hl?: string; parts: BadgePart[]; short: BadgePart[] };
|
|
2773
3023
|
hl?: string;
|
|
2774
3024
|
right?: { text: string; hl?: string };
|
|
2775
3025
|
}
|
|
2776
3026
|
|
|
3027
|
+
/** A badge in either form, as the run of parts the rest of this file works in. */
|
|
3028
|
+
function badgeParts(badge: BadgeSpec | undefined): BadgePart[] {
|
|
3029
|
+
if (!badge || typeof badge !== "object") return [];
|
|
3030
|
+
const many = (badge as { parts?: unknown }).parts;
|
|
3031
|
+
if (Array.isArray(many)) {
|
|
3032
|
+
return many.filter((p): p is BadgePart =>
|
|
3033
|
+
Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" &&
|
|
3034
|
+
(p as BadgePart).text !== ""
|
|
3035
|
+
);
|
|
3036
|
+
}
|
|
3037
|
+
const one = badge as { text?: unknown; hl?: unknown };
|
|
3038
|
+
if (typeof one.text !== "string" || one.text === "") return [];
|
|
3039
|
+
return [{ text: one.text, hl: typeof one.hl === "string" ? one.hl : undefined }];
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
/**
|
|
3043
|
+
* A badge's short form, falling back to the whole of it.
|
|
3044
|
+
*
|
|
3045
|
+
* The fallback is what lets several decorations merge without anybody declaring one: a plugin with
|
|
3046
|
+
* nothing to give up keeps all of its parts in both forms, and {@link fitBadge} notices that the
|
|
3047
|
+
* two are the same width and leaves the badge alone.
|
|
3048
|
+
*/
|
|
3049
|
+
function badgeShort(badge: BadgeSpec | undefined): BadgePart[] {
|
|
3050
|
+
const declared = (badge as { short?: unknown } | undefined)?.short;
|
|
3051
|
+
if (!Array.isArray(declared)) return badgeParts(badge);
|
|
3052
|
+
return declared.filter((p): p is BadgePart =>
|
|
3053
|
+
Boolean(p) && typeof p === "object" && typeof (p as BadgePart).text === "string" &&
|
|
3054
|
+
(p as BadgePart).text !== ""
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
|
|
2777
3058
|
/** The key a decoration's `target` files under: `project:/w/x`, `task:17`. */
|
|
2778
3059
|
export function decorationKey(target: unknown): string | null {
|
|
2779
3060
|
if (!target || typeof target !== "object") return null;
|
|
@@ -2797,11 +3078,16 @@ export function mergeDecorations(
|
|
|
2797
3078
|
const key = decorationKey(c.item?.target);
|
|
2798
3079
|
if (key === null) continue;
|
|
2799
3080
|
const d = out.get(key) ?? {};
|
|
2800
|
-
const
|
|
2801
|
-
if (
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
3081
|
+
const parts = badgeParts(c.item.badge);
|
|
3082
|
+
if (parts.length > 0) {
|
|
3083
|
+
const short = badgeShort(c.item.badge);
|
|
3084
|
+
const merged = d.badge ? [...d.badge.parts, { text: " " }, ...parts] : parts;
|
|
3085
|
+
d.badge = {
|
|
3086
|
+
text: merged.map((p) => p.text).join(""),
|
|
3087
|
+
hl: d.badge?.hl ?? parts[0]?.hl,
|
|
3088
|
+
parts: merged,
|
|
3089
|
+
short: d.badge ? [...d.badge.short, { text: " " }, ...short] : short,
|
|
3090
|
+
};
|
|
2805
3091
|
}
|
|
2806
3092
|
if (d.hl === undefined && typeof c.item.hl === "string") d.hl = c.item.hl;
|
|
2807
3093
|
if (d.right === undefined && typeof c.item.right?.text === "string") {
|
|
@@ -2812,9 +3098,41 @@ export function mergeDecorations(
|
|
|
2812
3098
|
return out;
|
|
2813
3099
|
}
|
|
2814
3100
|
|
|
2815
|
-
/**
|
|
3101
|
+
/**
|
|
3102
|
+
* How many columns a decoration's badge will take, for a row builder to leave free.
|
|
3103
|
+
*
|
|
3104
|
+
* Columns rather than bytes, which is what a row builder is subtracting this from: `●3` is four
|
|
3105
|
+
* bytes and two columns, and a strip of arrows is three bytes each — measured in bytes, a badge
|
|
3106
|
+
* that says `↓2 ↑5 ~7` takes eleven columns off a name to occupy nine.
|
|
3107
|
+
*/
|
|
2816
3108
|
export function badgeWidth(d: Decoration | undefined): number {
|
|
2817
|
-
return d?.badge ?
|
|
3109
|
+
return d?.badge ? width(` ${d.badge.text}`) : 0;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/**
|
|
3113
|
+
* The badge as this row can actually afford it: the whole of it, or its short form.
|
|
3114
|
+
*
|
|
3115
|
+
* **The badge yields before the name does.** A row's name is what the row *is* — a project you
|
|
3116
|
+
* cannot read the name of is not a row you can use — and a badge is news about it. So the full
|
|
3117
|
+
* strip is drawn whenever the name fits beside it, and a name that would be clipped by the badge
|
|
3118
|
+
* gets the short form instead. Only if it still does not fit is the name clipped, which is the
|
|
3119
|
+
* panel's own business and unchanged.
|
|
3120
|
+
*
|
|
3121
|
+
* `room` is every column the name and the badge have between them, `name` the columns the name
|
|
3122
|
+
* wants. A badge whose short form saves nothing is left alone, which is what makes the fallback in
|
|
3123
|
+
* {@link badgeShort} safe: a plugin that declared no short form never has one chosen for it.
|
|
3124
|
+
*/
|
|
3125
|
+
export function fitBadge(
|
|
3126
|
+
d: Decoration | undefined,
|
|
3127
|
+
room: number,
|
|
3128
|
+
name: number,
|
|
3129
|
+
): Decoration | undefined {
|
|
3130
|
+
if (!d?.badge) return d;
|
|
3131
|
+
const full = badgeWidth(d);
|
|
3132
|
+
if (name + full <= room) return d;
|
|
3133
|
+
const short = { ...d.badge, text: d.badge.short.map((p) => p.text).join("") };
|
|
3134
|
+
if (width(` ${short.text}`) >= full) return d;
|
|
3135
|
+
return { ...d, badge: { ...short, parts: d.badge.short } };
|
|
2818
3136
|
}
|
|
2819
3137
|
|
|
2820
3138
|
/**
|
|
@@ -2833,12 +3151,18 @@ export function decorateRow<T>(
|
|
|
2833
3151
|
if (!d) return row;
|
|
2834
3152
|
if (d.badge) {
|
|
2835
3153
|
const mark = ` ${d.badge.text}`;
|
|
2836
|
-
|
|
3154
|
+
// Past the space this puts between the name and the badge: it is the panel's colour, not the
|
|
3155
|
+
// badge's, and a span over it would be one colour claiming a column it does not fill.
|
|
3156
|
+
let at = byteLength(row.text) + 1;
|
|
2837
3157
|
row.text = `${row.text}${mark}`;
|
|
2838
3158
|
if (row.full !== undefined) row.full = `${row.full}${mark}`;
|
|
2839
|
-
|
|
2840
|
-
|
|
3159
|
+
const spans = [...(row.spans ?? [])];
|
|
3160
|
+
for (const part of d.badge.parts) {
|
|
3161
|
+
const to = at + byteLength(part.text);
|
|
3162
|
+
if (part.hl) spans.push({ from: at, to, hl: part.hl });
|
|
3163
|
+
at = to;
|
|
2841
3164
|
}
|
|
3165
|
+
if (spans.length > 0) row.spans = spans;
|
|
2842
3166
|
}
|
|
2843
3167
|
if (d.hl && row.hl === undefined) row.hl = d.hl;
|
|
2844
3168
|
if (d.right && !busy) row.right = { text: `${d.right.text} `, hl: d.right.hl };
|
|
@@ -2900,7 +3224,16 @@ export function placeSections<S extends string>(
|
|
|
2900
3224
|
*/
|
|
2901
3225
|
export function sectionRows<T>(
|
|
2902
3226
|
c: Contribution & { item: SectionItem },
|
|
2903
|
-
opts: {
|
|
3227
|
+
opts: {
|
|
3228
|
+
width: number;
|
|
3229
|
+
/**
|
|
3230
|
+
* What a landable contributed row *is*, in the panel's own vocabulary.
|
|
3231
|
+
*
|
|
3232
|
+
* The section's id comes third so a panel can tell one contributor's rows from another's —
|
|
3233
|
+
* which is what {@link ActionItem.on}'s `custom:<id>` form is matched against.
|
|
3234
|
+
*/
|
|
3235
|
+
custom: (command: string, args: string[], section: string) => T;
|
|
3236
|
+
},
|
|
2904
3237
|
): ListRow<T>[] {
|
|
2905
3238
|
const rows: ListRow<T>[] = [];
|
|
2906
3239
|
const contributed = Array.isArray(c.item?.rows) ? c.item.rows : [];
|
|
@@ -2940,7 +3273,7 @@ export function sectionRows<T>(
|
|
|
2940
3273
|
spans: spans.length > 0 ? spans : undefined,
|
|
2941
3274
|
right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined,
|
|
2942
3275
|
inert: typeof r.command !== "string",
|
|
2943
|
-
value: typeof r.command === "string" ? opts.custom(r.command, args) : undefined,
|
|
3276
|
+
value: typeof r.command === "string" ? opts.custom(r.command, args, c.id) : undefined,
|
|
2944
3277
|
});
|
|
2945
3278
|
}
|
|
2946
3279
|
return rows;
|