basepad 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/catalogue/mailpit.js +82 -0
- package/dist/catalogue/postgres.js +632 -0
- package/dist/catalogue/redis.js +135 -0
- package/dist/catalogue.js +13 -0
- package/dist/claude-hooks.js +33 -0
- package/dist/cli.js +182 -0
- package/dist/commands.js +989 -0
- package/dist/config.js +217 -0
- package/dist/env-files.js +123 -0
- package/dist/forward.js +53 -0
- package/dist/hosts.js +79 -0
- package/dist/index.js +11 -0
- package/dist/init.js +88 -0
- package/dist/internal/exec.js +58 -0
- package/dist/internal/ports.js +63 -0
- package/dist/internal/processes.js +42 -0
- package/dist/internal/service-user.js +53 -0
- package/dist/internal/table.js +7 -0
- package/dist/launcher.js +53 -0
- package/dist/layout.js +234 -0
- package/dist/mission-control/act.js +91 -0
- package/dist/mission-control/feed.js +254 -0
- package/dist/mission-control/frame.js +799 -0
- package/dist/mission-control/launch.js +362 -0
- package/dist/mission-control/model.js +325 -0
- package/dist/mission-control/run.js +211 -0
- package/dist/mission-control/work.js +162 -0
- package/dist/setup.js +280 -0
- package/dist/supervisor.js +477 -0
- package/dist/tls.js +99 -0
- package/dist/tools.js +32 -0
- package/dist/versions.js +19 -0
- package/dist/worktrees.js +186 -0
- package/package.json +50 -5
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { createElement as h } from "react";
|
|
3
|
+
import { partsOf, printed, selectedOf, streamLines, } from "./model.js";
|
|
4
|
+
/**
|
|
5
|
+
* The colours for a terminal: none when `NO_COLOR` is set to anything but empty
|
|
6
|
+
* (https://no-color.org), which chalk ignores; else by chalk's colour `level` for the terminal.
|
|
7
|
+
*/
|
|
8
|
+
export function colorsFor(noColor, level) {
|
|
9
|
+
if (noColor !== undefined && noColor !== "")
|
|
10
|
+
return "none";
|
|
11
|
+
return level >= 2 ? "rgb" : level === 1 ? "16" : "none";
|
|
12
|
+
}
|
|
13
|
+
/** The smallest terminal the layout fits; below it the frame asks for more room. */
|
|
14
|
+
export const MIN_COLS = 72;
|
|
15
|
+
export const MIN_ROWS = 16;
|
|
16
|
+
/** Below this many rows the frame is compact: no blank rows, tighter keys, fewer alert lines. */
|
|
17
|
+
const SHORT_ROWS = 24;
|
|
18
|
+
export function frame(input) {
|
|
19
|
+
const { snapshot, view, cols, rows, now, color } = input;
|
|
20
|
+
const c = { ...palette(color), motion: motionOf(input) };
|
|
21
|
+
if (cols < MIN_COLS || rows < MIN_ROWS) {
|
|
22
|
+
return centred(cols, rows, c.mute, `Mission control needs ${MIN_COLS}×${MIN_ROWS}; this terminal is ${cols}×${rows}. Enlarge it, or press q.`);
|
|
23
|
+
}
|
|
24
|
+
const env = selectedOf(snapshot, view);
|
|
25
|
+
if (env === undefined)
|
|
26
|
+
return centred(cols, rows, c.mute, "Reading the supervisor…");
|
|
27
|
+
const { top, bottom, body, side, right, table, short } = arrange(snapshot, view, env, cols, rows, now, c);
|
|
28
|
+
return h(Box, { flexDirection: "column", width: cols, height: rows }, ...top, h(Box, { key: "body", flexDirection: "row", height: body }, sidebar(snapshot, env, side, body, short, c), gap("g3"), h(Box, { flexDirection: "column", width: right }, partsPanel(env, view, snapshot, right, table, short, c), view.help
|
|
29
|
+
? help(right, body - table, c)
|
|
30
|
+
: stream(env, view, snapshot, right, body - table, c))), ...bottom);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where the frame's pieces go: the rows above the body (header, alerts) and below it (work under
|
|
34
|
+
* way, the notice or a question, the keys), the body's height, the two columns' widths and the
|
|
35
|
+
* parts panel's height.
|
|
36
|
+
*/
|
|
37
|
+
function arrange(snapshot, view, env, cols, rows, now, c) {
|
|
38
|
+
const short = rows < SHORT_ROWS;
|
|
39
|
+
const alerts = alertLines(snapshot, cols, short ? 1 : 3, c);
|
|
40
|
+
const top = [
|
|
41
|
+
header(snapshot, cols, now, c),
|
|
42
|
+
short ? null : gap("g1"),
|
|
43
|
+
...alerts,
|
|
44
|
+
alerts.length > 0 && !short ? gap("g2") : null,
|
|
45
|
+
].filter((e) => e !== null);
|
|
46
|
+
const said = status(snapshot, view, cols, short, c);
|
|
47
|
+
const bottom = [
|
|
48
|
+
...(said.length === 0 && !short ? [gap("g4")] : said),
|
|
49
|
+
...footer(cols, short, snapshot.environments.length, c),
|
|
50
|
+
];
|
|
51
|
+
const body = rows - top.length - bottom.length;
|
|
52
|
+
const side = Math.min(34, Math.max(24, Math.floor(cols * 0.2)));
|
|
53
|
+
const right = cols - side - 1;
|
|
54
|
+
// The stream keeps at least a third of the body, so a long parts list never hides the logs.
|
|
55
|
+
const keep = Math.max(short ? 4 : 6, Math.floor(body / 3));
|
|
56
|
+
const most = Math.max(short ? 3 : 6, body - keep);
|
|
57
|
+
// As many rows as the parts that fit take, so none is left blank (see `partsShown`).
|
|
58
|
+
const table = env.problem !== undefined
|
|
59
|
+
? Math.min(6, most)
|
|
60
|
+
: partsOf(env).length === 0
|
|
61
|
+
? 3
|
|
62
|
+
: Math.max(3, Math.min(most, 2 + partsShown(env, right - 4, most - 2, short, view).length));
|
|
63
|
+
return { top, bottom, body, side, right, table, short };
|
|
64
|
+
}
|
|
65
|
+
/** How many log lines the stream shows: what a page up or down moves by. */
|
|
66
|
+
export function pageOf(snapshot, view, cols, rows) {
|
|
67
|
+
const env = selectedOf(snapshot, view);
|
|
68
|
+
if (env === undefined || cols < MIN_COLS || rows < MIN_ROWS)
|
|
69
|
+
return 10;
|
|
70
|
+
const { body, table } = arrange(snapshot, view, env, cols, rows, "", palette("none"));
|
|
71
|
+
return Math.max(1, body - table - 2);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Colour carries meaning: green runs, amber is on its way or warns, red failed, grey is idle.
|
|
75
|
+
* Without colour every one is undefined, and glyphs and words carry the meaning.
|
|
76
|
+
*/
|
|
77
|
+
const COLORS = {
|
|
78
|
+
frame: "#2f344d",
|
|
79
|
+
focus: "#7aa2f7",
|
|
80
|
+
text: "#c0caf5",
|
|
81
|
+
soft: "#a9b1d6",
|
|
82
|
+
mute: "#737aa2",
|
|
83
|
+
dim: "#565f89",
|
|
84
|
+
ok: "#9ece6a",
|
|
85
|
+
warn: "#e0af68",
|
|
86
|
+
bad: "#f7768e",
|
|
87
|
+
accent: "#7aa2f7",
|
|
88
|
+
keyBg: "#283457",
|
|
89
|
+
parts: ["#7aa2f7", "#bb9af7", "#7dcfff", "#ff9e64", "#9d7cd8", "#73daca"],
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The terminal's own named colours, for one of 16: its theme sets them, so each is legible on its
|
|
93
|
+
* background, dark or light. Text keeps the terminal's own foreground.
|
|
94
|
+
*/
|
|
95
|
+
const NAMED = {
|
|
96
|
+
frame: "gray",
|
|
97
|
+
focus: "cyan",
|
|
98
|
+
text: undefined,
|
|
99
|
+
soft: undefined,
|
|
100
|
+
mute: "gray",
|
|
101
|
+
dim: "gray",
|
|
102
|
+
ok: "green",
|
|
103
|
+
warn: "yellow",
|
|
104
|
+
bad: "red",
|
|
105
|
+
accent: "cyan",
|
|
106
|
+
keyBg: "gray",
|
|
107
|
+
parts: ["blueBright", "magenta", "cyan", "magentaBright", "cyanBright", "blue"],
|
|
108
|
+
};
|
|
109
|
+
export function palette(colors) {
|
|
110
|
+
if (colors === "rgb")
|
|
111
|
+
return COLORS;
|
|
112
|
+
if (colors === "16")
|
|
113
|
+
return NAMED;
|
|
114
|
+
const none = Object.fromEntries(Object.keys(COLORS).map((key) => [key, undefined]));
|
|
115
|
+
return { ...none, parts: [undefined] };
|
|
116
|
+
}
|
|
117
|
+
const STATES = {
|
|
118
|
+
running: { glyph: "●", word: "running", tone: "ok" },
|
|
119
|
+
starting: { glyph: "◉", word: "starting", tone: "warn" },
|
|
120
|
+
failed: { glyph: "✖", word: "failed", tone: "bad" },
|
|
121
|
+
stopped: { glyph: "–", word: "stopped", tone: "dim" },
|
|
122
|
+
idle: { glyph: "○", word: "idle", tone: "dim" },
|
|
123
|
+
};
|
|
124
|
+
function partColor(env, id, c) {
|
|
125
|
+
const i = partsOf(env).findIndex((part) => part.id === id);
|
|
126
|
+
return c.parts[Math.max(0, i) % c.parts.length];
|
|
127
|
+
}
|
|
128
|
+
// ── motion ────────────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
/** A starting part's dot breathes: amber, toward dim, and back, once in this long. */
|
|
130
|
+
const BREATH_MS = 1400;
|
|
131
|
+
/** How long a part that changed state stays lit, fading. */
|
|
132
|
+
const FLASH_MS = 1200;
|
|
133
|
+
/** How long a new log line takes to settle to its colour. */
|
|
134
|
+
const FADE_IN_MS = 350;
|
|
135
|
+
/** How long a notice shows before it fades, and how long it fades. */
|
|
136
|
+
export const NOTICE_MS = 4000;
|
|
137
|
+
export const NOTICE_FADE_MS = 1000;
|
|
138
|
+
/** The background a lit row fades to: the palette's own. */
|
|
139
|
+
const BACKGROUND = "#1a1b26";
|
|
140
|
+
function motionOf({ clock = 0, changed = new Map(), noticeAge, color }) {
|
|
141
|
+
return {
|
|
142
|
+
rgb: color === "rgb",
|
|
143
|
+
clock,
|
|
144
|
+
changed,
|
|
145
|
+
...(noticeAge === undefined ? {} : { noticeAge }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** Whether anything in the frame moves now: the view repaints fast only while it does. */
|
|
149
|
+
export function animating({ snapshot, clock, changed, noticeAge, }) {
|
|
150
|
+
const parts = snapshot.environments.flatMap((env) => [...env.apps, ...env.services]);
|
|
151
|
+
if (parts.some((part) => part.state === "starting"))
|
|
152
|
+
return true;
|
|
153
|
+
for (const at of changed?.values() ?? [])
|
|
154
|
+
if (clock - at < FLASH_MS)
|
|
155
|
+
return true;
|
|
156
|
+
for (const lines of snapshot.logs.values()) {
|
|
157
|
+
const at = lines.at(-1)?.at;
|
|
158
|
+
if (at !== undefined && clock - at < FADE_IN_MS)
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return (noticeAge !== undefined && noticeAge >= NOTICE_MS && noticeAge < NOTICE_MS + NOTICE_FADE_MS);
|
|
162
|
+
}
|
|
163
|
+
/** `from` moved toward `to` by `t` (0 to 1): two `#rrggbb` colours. */
|
|
164
|
+
function mix(from, to, t) {
|
|
165
|
+
const k = Math.max(0, Math.min(1, t));
|
|
166
|
+
const channel = (hex, i) => Number.parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);
|
|
167
|
+
return `#${[0, 1, 2]
|
|
168
|
+
.map((i) => Math.round(channel(from, i) + (channel(to, i) - channel(from, i)) * k))
|
|
169
|
+
.map((n) => n.toString(16).padStart(2, "0"))
|
|
170
|
+
.join("")}`;
|
|
171
|
+
}
|
|
172
|
+
/** A starting part's colour, breathing; its own where colours are few. */
|
|
173
|
+
function breathe(color, c) {
|
|
174
|
+
const m = c.motion;
|
|
175
|
+
if (m === undefined || !m.rgb || color === undefined || c.dim === undefined)
|
|
176
|
+
return color;
|
|
177
|
+
const t = (1 - Math.cos((2 * Math.PI * m.clock) / BREATH_MS)) / 2;
|
|
178
|
+
return mix(color, c.dim, t * 0.8);
|
|
179
|
+
}
|
|
180
|
+
/** The background of a part's row: lit in its new state's colour just after it changed. */
|
|
181
|
+
function glow(id, color, c) {
|
|
182
|
+
const m = c.motion;
|
|
183
|
+
const at = m?.changed.get(id);
|
|
184
|
+
if (m === undefined || !m.rgb || at === undefined || color === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
const age = m.clock - at;
|
|
187
|
+
if (age < 0 || age >= FLASH_MS)
|
|
188
|
+
return undefined;
|
|
189
|
+
return mix(mix(color, BACKGROUND, 0.6), BACKGROUND, age / FLASH_MS);
|
|
190
|
+
}
|
|
191
|
+
/** A log line's colour: dim as it arrives, settling to its own. */
|
|
192
|
+
function settled(line, color, c) {
|
|
193
|
+
const m = c.motion;
|
|
194
|
+
if (m === undefined ||
|
|
195
|
+
!m.rgb ||
|
|
196
|
+
line.at === undefined ||
|
|
197
|
+
color === undefined ||
|
|
198
|
+
c.dim === undefined)
|
|
199
|
+
return color;
|
|
200
|
+
const age = m.clock - line.at;
|
|
201
|
+
return age >= FADE_IN_MS ? color : mix(c.dim, color, Math.max(0, age) / FADE_IN_MS);
|
|
202
|
+
}
|
|
203
|
+
/** The notice's colour: its own, then fading to dim after a few seconds. */
|
|
204
|
+
function fading(color, c) {
|
|
205
|
+
const age = c.motion?.noticeAge;
|
|
206
|
+
if (!c.motion?.rgb || age === undefined || color === undefined || c.dim === undefined)
|
|
207
|
+
return color;
|
|
208
|
+
return age <= NOTICE_MS ? color : mix(color, c.dim, (age - NOTICE_MS) / NOTICE_FADE_MS);
|
|
209
|
+
}
|
|
210
|
+
const run = (text, color, more = {}) => ({
|
|
211
|
+
text,
|
|
212
|
+
color,
|
|
213
|
+
...more,
|
|
214
|
+
});
|
|
215
|
+
const widthOf = (runs) => runs.reduce((n, r) => n + [...r.text].length, 0);
|
|
216
|
+
/** One row of runs, cut at `width` with `…`: only log lines and chrome are ever cut, never names. */
|
|
217
|
+
function line(key, width, runs) {
|
|
218
|
+
return h(Box, { key, width, height: 1, overflow: "hidden", flexShrink: 0 }, h(Text, { wrap: "truncate-end" }, ...runs.map((r, i) => h(Text, { key: i, color: r.color, backgroundColor: r.bg, bold: r.bold, inverse: r.inverse }, r.text))));
|
|
219
|
+
}
|
|
220
|
+
const gap = (key) => h(Box, { key, height: 1, width: 1, flexShrink: 0 });
|
|
221
|
+
/** `text` padded to `n` columns; longer text is kept whole. */
|
|
222
|
+
const pad = (text, n) => text + " ".repeat(Math.max(0, n - [...text].length));
|
|
223
|
+
/**
|
|
224
|
+
* `text` in pieces of at most `width` columns, so a long name wraps instead of being cut: after a
|
|
225
|
+
* `/`, `-`, `.` or space where one is near the end of a piece, else at the width.
|
|
226
|
+
*/
|
|
227
|
+
function wrap(text, width) {
|
|
228
|
+
const out = [];
|
|
229
|
+
let rest = [...text];
|
|
230
|
+
while (rest.length > width) {
|
|
231
|
+
const piece = rest.slice(0, width);
|
|
232
|
+
const soft = Math.max(...["/", "-", " ", "."].map((c) => piece.lastIndexOf(c)));
|
|
233
|
+
const cut = soft >= width / 2 ? soft + 1 : width;
|
|
234
|
+
out.push(rest.slice(0, cut).join(""));
|
|
235
|
+
rest = rest.slice(cut);
|
|
236
|
+
}
|
|
237
|
+
out.push(rest.join(""));
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* `runs` in rows of at most `width` columns, broken between words, so a question or a notice is
|
|
242
|
+
* read whole rather than cut. A word longer than a row is broken within it.
|
|
243
|
+
*/
|
|
244
|
+
function flow(runs, width) {
|
|
245
|
+
const rows = [[]];
|
|
246
|
+
let used = 0;
|
|
247
|
+
for (const r of runs) {
|
|
248
|
+
for (const word of r.text.split(/(?<= )/)) {
|
|
249
|
+
let rest = [...word];
|
|
250
|
+
while (rest.length > 0) {
|
|
251
|
+
const room = width - used;
|
|
252
|
+
const fits = rest.length <= room || rest.join("").trimEnd().length <= room;
|
|
253
|
+
if (!fits && used > 0) {
|
|
254
|
+
rows.push([]);
|
|
255
|
+
used = 0;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const piece = fits ? rest : rest.slice(0, room);
|
|
259
|
+
rows[rows.length - 1].push({ ...r, text: piece.join("") });
|
|
260
|
+
used += piece.length;
|
|
261
|
+
rest = rest.slice(piece.length);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return rows;
|
|
266
|
+
}
|
|
267
|
+
function centred(cols, rows, color, text) {
|
|
268
|
+
return h(Box, { width: cols, height: rows, alignItems: "center", justifyContent: "center" }, h(Box, { width: Math.min(cols - 4, 60) }, h(Text, { color }, text)));
|
|
269
|
+
}
|
|
270
|
+
/** The columns a panel `width` wide has for its title: its border's corners and a space each side take 6. */
|
|
271
|
+
const titleRoom = (width) => width - 6;
|
|
272
|
+
/**
|
|
273
|
+
* A rounded panel with its title set in the top border, `╭─ title ──── right ─╮`: Ink draws no
|
|
274
|
+
* titles, so the top line is drawn here. Its content gets `width - 4` columns, `height - 2` rows;
|
|
275
|
+
* its title, `titleRoom(width)`.
|
|
276
|
+
*/
|
|
277
|
+
function panel(key, width, height, border, title, right, children) {
|
|
278
|
+
// The title is cut inside the border, so its corner always draws.
|
|
279
|
+
const t = title.length > 0 ? clip([run(" "), ...title, run(" ")], titleRoom(width) + 2) : [];
|
|
280
|
+
let r = right.length > 0 ? [run(" "), ...right, run(" ")] : [];
|
|
281
|
+
if (widthOf(t) + widthOf(r) > width - 4)
|
|
282
|
+
r = [];
|
|
283
|
+
const fill = Math.max(0, width - 4 - widthOf(t) - widthOf(r));
|
|
284
|
+
const top = [run("╭─", border), ...t, run("─".repeat(fill), border), ...r, run("─╮", border)];
|
|
285
|
+
return h(Box, { key, flexDirection: "column", width, height, flexShrink: 0 }, line("top", width, top), h(Box, {
|
|
286
|
+
borderStyle: "round",
|
|
287
|
+
borderTop: false,
|
|
288
|
+
borderColor: border,
|
|
289
|
+
width,
|
|
290
|
+
height: height - 1,
|
|
291
|
+
paddingX: 1,
|
|
292
|
+
flexDirection: "column",
|
|
293
|
+
overflow: "hidden",
|
|
294
|
+
}, ...children));
|
|
295
|
+
}
|
|
296
|
+
// ── what the snapshot says ────────────────────────────────────────────────────────────────────
|
|
297
|
+
/** An environment's state at a glance: failed beats starting beats running; none running is idle. */
|
|
298
|
+
function rollup(env) {
|
|
299
|
+
if (env.problem !== undefined)
|
|
300
|
+
return { state: "problem", note: "config does not load" };
|
|
301
|
+
const { apps } = env;
|
|
302
|
+
const failed = apps.filter((a) => a.state === "failed");
|
|
303
|
+
if (failed.length > 0)
|
|
304
|
+
return { state: "failed", note: `${failed.map((a) => a.name).join(", ")} failed` };
|
|
305
|
+
if (apps.some((a) => a.state === "starting"))
|
|
306
|
+
return { state: "starting", note: "starting" };
|
|
307
|
+
const running = apps.filter((a) => a.state === "running").length;
|
|
308
|
+
if (apps.length === 0)
|
|
309
|
+
return { state: "idle", note: "no apps" };
|
|
310
|
+
if (running > 0)
|
|
311
|
+
return { state: "running", note: `${running}/${apps.length} apps running` };
|
|
312
|
+
if (apps.some((a) => a.state === "idle"))
|
|
313
|
+
return { state: "idle", note: "idle until opened" };
|
|
314
|
+
return { state: "stopped", note: "stopped" };
|
|
315
|
+
}
|
|
316
|
+
function lookOf(state, c) {
|
|
317
|
+
if (state === "problem")
|
|
318
|
+
return { glyph: "▲", word: "problem", color: c.warn };
|
|
319
|
+
const s = STATES[state];
|
|
320
|
+
return {
|
|
321
|
+
glyph: s.glyph,
|
|
322
|
+
word: s.word,
|
|
323
|
+
color: state === "starting" ? breathe(c[s.tone], c) : c[s.tone],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** How a log line reads: an error, a warning, or plain. */
|
|
327
|
+
function tone(text, c) {
|
|
328
|
+
if (/\b(ERROR|FATAL|PANIC)\b|Error\b|^\s+at |exception|^Node\.js v/.test(text))
|
|
329
|
+
return c.bad;
|
|
330
|
+
if (/\bWARN(ING)?\b/.test(text))
|
|
331
|
+
return c.warn;
|
|
332
|
+
return c.text;
|
|
333
|
+
}
|
|
334
|
+
/** A line without the date, zone and pid a server stamps on it itself (Postgres does). */
|
|
335
|
+
const clean = (text) => text.replace(/^\s*\[?\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d(?:[.,]\d+)?(?:Z|[+-]\d\d:?\d\d| [A-Z]{2,5})?\]?\s+(?:\[\d+\]\s+)?/, "");
|
|
336
|
+
/** How far back a failed part's output is searched for the line that names its error. */
|
|
337
|
+
const LAST_OUTPUT = 20;
|
|
338
|
+
/**
|
|
339
|
+
* A failed part's last output: of its last lines, the newest that names an error, else its last
|
|
340
|
+
* line that is not blank, whatever it says. Empty only when it printed nothing.
|
|
341
|
+
*/
|
|
342
|
+
function lastError(lines = []) {
|
|
343
|
+
const recent = lines
|
|
344
|
+
.slice(-LAST_OUTPUT)
|
|
345
|
+
.map((l) => l.text.trim())
|
|
346
|
+
.filter((text) => text !== "");
|
|
347
|
+
return recent.findLast((text) => /Error\b|ERROR|FATAL/.test(text)) ?? recent.at(-1) ?? "";
|
|
348
|
+
}
|
|
349
|
+
/** The newest `n` lines of the given parts, merged in arrival order. */
|
|
350
|
+
function merged(logs, ids, n) {
|
|
351
|
+
const all = [];
|
|
352
|
+
for (const id of ids)
|
|
353
|
+
all.push(...(logs.get(id) ?? []).filter(printed).slice(-n));
|
|
354
|
+
return all.sort((a, b) => a.seq - b.seq).slice(-n);
|
|
355
|
+
}
|
|
356
|
+
// ── the pieces ────────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
function header(snapshot, cols, now, c) {
|
|
358
|
+
const apps = snapshot.environments.flatMap((env) => env.apps);
|
|
359
|
+
const count = (state) => apps.filter((a) => a.state === state).length;
|
|
360
|
+
const wide = cols >= 120;
|
|
361
|
+
const failed = count("failed");
|
|
362
|
+
const left = [
|
|
363
|
+
run(" ◉ ", c.accent, { bold: true }),
|
|
364
|
+
run("basepad", c.text, { bold: true }),
|
|
365
|
+
run(wide ? " mission control" : "", c.mute),
|
|
366
|
+
run(" "),
|
|
367
|
+
run(snapshot.project, c.accent, { bold: true }),
|
|
368
|
+
];
|
|
369
|
+
const envs = snapshot.environments.length;
|
|
370
|
+
const right = [
|
|
371
|
+
run(wide ? `${envs} environment${envs === 1 ? "" : "s"} ` : "", c.mute),
|
|
372
|
+
run("● ", c.ok),
|
|
373
|
+
run(`${count("running")} running `, c.soft),
|
|
374
|
+
...(failed > 0 ? [run("✖ ", c.bad), run(`${failed} failed `, c.bad, { bold: true })] : []),
|
|
375
|
+
// Idle and stopped only where there are any: what runs or failed is always said.
|
|
376
|
+
...(count("idle") > 0 ? [run("○ ", c.dim), run(`${count("idle")} idle `, c.soft)] : []),
|
|
377
|
+
...(count("stopped") > 0
|
|
378
|
+
? [run("– ", c.dim), run(`${count("stopped")} stopped `, c.soft)]
|
|
379
|
+
: []),
|
|
380
|
+
run("⇣ ", c.mute),
|
|
381
|
+
run(`${snapshot.rate}${wide ? " lines/s" : "/s"} `, c.soft),
|
|
382
|
+
run(now, c.text, { bold: true }),
|
|
383
|
+
run(" "),
|
|
384
|
+
];
|
|
385
|
+
const fill = Math.max(1, cols - widthOf(left) - widthOf(right));
|
|
386
|
+
return line("header", cols, [...left, run(" ".repeat(fill)), ...right]);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* A line for each failed app in any environment, so a crash is seen whatever is selected: at most
|
|
390
|
+
* `most`, the last of which counts the rest.
|
|
391
|
+
*/
|
|
392
|
+
function alertLines(snapshot, cols, most, c) {
|
|
393
|
+
const failed = snapshot.environments.flatMap((env) => env.apps.filter((a) => a.state === "failed").map((a) => ({ env, a })));
|
|
394
|
+
const shown = failed.length > most ? failed.slice(0, most) : failed;
|
|
395
|
+
return shown.map(({ env, a }, i) => {
|
|
396
|
+
const why = lastError(snapshot.logs.get(a.id));
|
|
397
|
+
const more = i === shown.length - 1 ? failed.length - shown.length : 0;
|
|
398
|
+
return line(`alert ${a.id}`, cols, [
|
|
399
|
+
run(" ✖ ", c.bad, { bold: true }),
|
|
400
|
+
run(`${env.label} › ${a.name}`, c.bad, { bold: true }),
|
|
401
|
+
run(` exited (${a.error ?? "failed"})`, c.bad),
|
|
402
|
+
run(more > 0 ? ` +${more} more failed` : "", c.bad, { bold: true }),
|
|
403
|
+
run(why === "" ? "" : ` ${why}`, c.soft),
|
|
404
|
+
]);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The rows above the keys: each worktree being added or removed with its command's last line, then
|
|
409
|
+
* a removal's question, or what the last key did. Questions and notices wrap rather than cut.
|
|
410
|
+
*/
|
|
411
|
+
function status(snapshot, view, cols, short, c) {
|
|
412
|
+
const work = (snapshot.work ?? [])
|
|
413
|
+
.slice(0, short ? 1 : 2)
|
|
414
|
+
.map((w, i) => line(`work ${i}`, cols, [
|
|
415
|
+
run(` ${STATES.starting.glyph} `, breathe(c.warn, c), { bold: true }),
|
|
416
|
+
run(w.title, c.warn, { bold: true }),
|
|
417
|
+
run(w.line === undefined ? "" : ` ${w.line}`, c.soft),
|
|
418
|
+
]));
|
|
419
|
+
const { confirm } = view;
|
|
420
|
+
let said = [];
|
|
421
|
+
if (confirm?.step === "remove") {
|
|
422
|
+
said = [
|
|
423
|
+
run("Remove worktree ", c.text, { bold: true }),
|
|
424
|
+
run(confirm.suffix, c.bad, { bold: true }),
|
|
425
|
+
run("? Its apps, databases and git worktree go; its branch stays. ", c.text),
|
|
426
|
+
run("y", c.bad, { bold: true }),
|
|
427
|
+
// Kept together, so a row never starts with a lone "it".
|
|
428
|
+
run(" remove · any other key keeps\u00a0it", c.mute),
|
|
429
|
+
];
|
|
430
|
+
}
|
|
431
|
+
else if (confirm?.step === "discard") {
|
|
432
|
+
said = [
|
|
433
|
+
run(confirm.suffix, c.bad, { bold: true }),
|
|
434
|
+
run(" has uncommitted changes, which removing it loses. ", c.bad, { bold: true }),
|
|
435
|
+
run("Type ", c.text),
|
|
436
|
+
run(confirm.suffix, c.text, { bold: true }),
|
|
437
|
+
run(" and enter to remove it anyway: ", c.text),
|
|
438
|
+
run(`${confirm.typed ?? ""}▏`, c.text, { bold: true }),
|
|
439
|
+
run(" esc\u00a0keeps\u00a0it", c.mute),
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
else if (view.notice !== undefined) {
|
|
443
|
+
said = [run(view.notice, fading(c.accent, c))];
|
|
444
|
+
}
|
|
445
|
+
const rows = said.length === 0 ? [] : flow(said, cols - 2).slice(0, short ? 2 : 3);
|
|
446
|
+
return [...work, ...rows.map((runs, i) => line(`said ${i}`, cols, [run(" "), ...runs]))];
|
|
447
|
+
}
|
|
448
|
+
/** The keys used most, below the view: the rest are under `?` (`HELP`). */
|
|
449
|
+
function keysOf(environments) {
|
|
450
|
+
return [
|
|
451
|
+
["↑↓", "select"],
|
|
452
|
+
...(environments > 1 ? [["tab", "environment"]] : []),
|
|
453
|
+
["enter", "open"],
|
|
454
|
+
["space", "start/stop"],
|
|
455
|
+
["r", "restart"],
|
|
456
|
+
["/", "search"],
|
|
457
|
+
["?", "all keys"],
|
|
458
|
+
["q", "close"],
|
|
459
|
+
];
|
|
460
|
+
}
|
|
461
|
+
/** Every key, with what it does: what `?` shows in place of the logs. */
|
|
462
|
+
const HELP = [
|
|
463
|
+
["↑↓", "select an app or service; above the first, all of them"],
|
|
464
|
+
["tab ⇧tab", "next or previous environment (← → too)"],
|
|
465
|
+
["1–9", "go to an environment, by its number"],
|
|
466
|
+
["enter", "open the selected app, else the first"],
|
|
467
|
+
["space", "start or stop it"],
|
|
468
|
+
["r", "restart it, else the whole environment"],
|
|
469
|
+
["c", "copy its address"],
|
|
470
|
+
["/", "search the logs"],
|
|
471
|
+
["n N", "older and newer match"],
|
|
472
|
+
["PgUp PgDn", "scroll the logs; Home and End for the oldest and live"],
|
|
473
|
+
["a", "add a worktree"],
|
|
474
|
+
["d", "remove the selected worktree"],
|
|
475
|
+
["esc", "back a step"],
|
|
476
|
+
["q", "close mission control; everything keeps running"],
|
|
477
|
+
];
|
|
478
|
+
/** The keys, in rows: each key's label stays with its key. Short, they sit closer together. */
|
|
479
|
+
function footer(cols, short, environments, c) {
|
|
480
|
+
const rows = [[run(" ")]];
|
|
481
|
+
const space = short ? " " : " ";
|
|
482
|
+
for (const [k, label] of keysOf(environments)) {
|
|
483
|
+
const key = short ? k : ` ${k} `;
|
|
484
|
+
const item = [run(key, c.text, { bg: c.keyBg, bold: true }), run(` ${label}${space}`, c.mute)];
|
|
485
|
+
let last = rows[rows.length - 1];
|
|
486
|
+
if (widthOf(last) + widthOf(item) - space.length >= cols && last.length > 1) {
|
|
487
|
+
last = [run(" ")];
|
|
488
|
+
rows.push(last);
|
|
489
|
+
}
|
|
490
|
+
last.push(...item);
|
|
491
|
+
}
|
|
492
|
+
// A row's trailing gap would push it past the edge and cut it with `…`.
|
|
493
|
+
return rows.map((runs, i) => {
|
|
494
|
+
const end = runs[runs.length - 1];
|
|
495
|
+
const trimmed = [...runs.slice(0, -1), { ...end, text: end.text.trimEnd() }];
|
|
496
|
+
return line(`keys ${i}`, cols, trimmed);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Environments down the left: state glyph and name, the branch whole, the state in words. When
|
|
501
|
+
* they do not all fit, a window of them that holds the selected one, with how many more above and
|
|
502
|
+
* below.
|
|
503
|
+
*/
|
|
504
|
+
function sidebar(snapshot, selected, width, height, short, c) {
|
|
505
|
+
const inner = width - 4;
|
|
506
|
+
const removing = new Set((snapshot.work ?? []).map((w) => w.namespace));
|
|
507
|
+
// Each is numbered, as `1`–`9` select it; the rows below its name line up past the number.
|
|
508
|
+
const digits = String(snapshot.environments.length).length;
|
|
509
|
+
const indent = run(" ".repeat(digits + 3));
|
|
510
|
+
const blocks = snapshot.environments.map((env, i) => {
|
|
511
|
+
const on = env === selected;
|
|
512
|
+
const s = rollup(env);
|
|
513
|
+
const look = lookOf(s.state, c);
|
|
514
|
+
const bar = on ? run("▌", c.accent) : run(" ");
|
|
515
|
+
const k = env.namespace;
|
|
516
|
+
const branch = env.branch ?? "(detached)";
|
|
517
|
+
const rows = [
|
|
518
|
+
line(`${k} name`, inner, [
|
|
519
|
+
bar,
|
|
520
|
+
run(`${String(i + 1).padStart(digits)} `, c.dim),
|
|
521
|
+
run(`${look.glyph} `, look.color),
|
|
522
|
+
run(env.label, on ? c.text : c.soft, { bold: on }),
|
|
523
|
+
]),
|
|
524
|
+
// Its branch, where it is not its name already (main on main).
|
|
525
|
+
...(branch === env.label ? [] : wrap(branch, inner - indent.text.length - 1)).map((piece, j) => line(`${k} branch ${j}`, inner, [bar, indent, run(piece, on ? c.soft : c.mute)])),
|
|
526
|
+
// Its state, where it is not simply running: the header counts those.
|
|
527
|
+
...(removing.has(k)
|
|
528
|
+
? [line(`${k} note`, inner, [bar, indent, run("being removed…", c.warn)])]
|
|
529
|
+
: s.state === "running"
|
|
530
|
+
? []
|
|
531
|
+
: [line(`${k} note`, inner, [bar, indent, run(s.note, look.color)])]),
|
|
532
|
+
];
|
|
533
|
+
if (!short)
|
|
534
|
+
rows.push(gap(`${k} gap`));
|
|
535
|
+
return rows;
|
|
536
|
+
});
|
|
537
|
+
const at = snapshot.environments.indexOf(selected);
|
|
538
|
+
const rows = windowed(blocks, at, height - 2, (key, text) => line(key, inner, [run(text, c.mute)]));
|
|
539
|
+
return panel("sidebar", width, height, c.frame, [run("ENVIRONMENTS", c.mute, { bold: true })], [], rows);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Blocks of rows (an environment, a part) in `room` rows: all when they fit, else a window that
|
|
543
|
+
* holds block `at`, and a row above and below, made by `mark`, saying how many more there are.
|
|
544
|
+
*/
|
|
545
|
+
function windowed(blocks, at, room, mark) {
|
|
546
|
+
const size = (from, to) => blocks.slice(from, to + 1).reduce((n, b) => n + b.length, 0);
|
|
547
|
+
const marks = (from, to) => (from > 0 ? 1 : 0) + (to < blocks.length - 1 ? 1 : 0);
|
|
548
|
+
// From the first block, moved down until block `at` fits; then down as far as room allows.
|
|
549
|
+
let from = 0;
|
|
550
|
+
while (from < at && size(from, at) + marks(from, at) > room)
|
|
551
|
+
from++;
|
|
552
|
+
let to = Math.max(at, 0);
|
|
553
|
+
while (to + 1 < blocks.length && size(from, to + 1) + marks(from, to + 1) <= room)
|
|
554
|
+
to++;
|
|
555
|
+
return [
|
|
556
|
+
...(from > 0 ? [mark("above", `↑ ${from} more`)] : []),
|
|
557
|
+
...blocks.slice(from, to + 1).flat(),
|
|
558
|
+
...(to < blocks.length - 1 ? [mark("below", `↓ ${blocks.length - 1 - to} more`)] : []),
|
|
559
|
+
];
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The rows of an environment's parts that show in `room` rows: all of them; else without their
|
|
563
|
+
* headings; else a window that holds the focused part.
|
|
564
|
+
*/
|
|
565
|
+
function partsShown(env, inner, room, short, view, snapshot, c = palette("none")) {
|
|
566
|
+
const all = partBlocks(env, inner, short, view, snapshot, c);
|
|
567
|
+
const fits = all.reduce((n, b) => n + b.length, 0) <= room;
|
|
568
|
+
const blocks = fits ? all : partBlocks(env, inner, true, view, snapshot, c);
|
|
569
|
+
const at = partsOf(env).findIndex((part) => part.id === view.focus);
|
|
570
|
+
return windowed(blocks, Math.max(0, at), room, (key, text) => ({
|
|
571
|
+
key,
|
|
572
|
+
runs: [run(text, c.mute)],
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* An environment's parts, a block of rows each, `inner` columns wide: apps, then services, each
|
|
577
|
+
* group under a heading unless `short`. A part's address is never cut: where it does not fit
|
|
578
|
+
* beside its name, it goes on rows of its own below it, wrapped where it must be.
|
|
579
|
+
*/
|
|
580
|
+
function partBlocks(env, inner, short, view, snapshot, c = palette("none")) {
|
|
581
|
+
// Narrow, names are not lined up past their own width, and the state word gives way, so an
|
|
582
|
+
// address fits beside its name; the glyph still says the state.
|
|
583
|
+
const nameWidth = Math.max(inner < 60 ? 0 : 8, ...partsOf(env).map((p) => [...p.name].length)) + 2;
|
|
584
|
+
const words = inner >= 96;
|
|
585
|
+
// A part that just changed state is lit for a moment in its new state's colour.
|
|
586
|
+
const block = (part) => {
|
|
587
|
+
const bg = glow(part.id, lookOf(part.state, c).color, c);
|
|
588
|
+
const rows = plainBlock(part);
|
|
589
|
+
if (bg === undefined)
|
|
590
|
+
return rows;
|
|
591
|
+
return rows.map((row) => ({ ...row, runs: row.runs.map((r) => ({ ...r, bg: r.bg ?? bg })) }));
|
|
592
|
+
};
|
|
593
|
+
const plainBlock = (part) => {
|
|
594
|
+
const look = lookOf(part.state, c);
|
|
595
|
+
const on = view?.focus === part.id;
|
|
596
|
+
const lead = [
|
|
597
|
+
run(on ? "› " : " ", c.accent, { bold: true }),
|
|
598
|
+
run(`${look.glyph} `, look.color),
|
|
599
|
+
run(pad(part.name, nameWidth), partColor(env, part.id, c), { bold: true }),
|
|
600
|
+
...(words ? [run(pad(look.word, 10), look.color)] : []),
|
|
601
|
+
];
|
|
602
|
+
if (part.state === "failed") {
|
|
603
|
+
const why = lastError(snapshot?.logs.get(part.id));
|
|
604
|
+
const error = [run(part.error ?? "exited", c.bad), run(` ${why}`, c.soft)];
|
|
605
|
+
return [{ key: part.id, runs: [...lead, ...error] }];
|
|
606
|
+
}
|
|
607
|
+
const color = part.state === "running" ? c.text : c.soft;
|
|
608
|
+
// Its port and pid only matter on a closer look: they show when it is selected.
|
|
609
|
+
const detail = part.state === "idle"
|
|
610
|
+
? run(" starts on its first request", c.dim)
|
|
611
|
+
: run(part.note === undefined || !on ? "" : ` ${part.note}`, c.dim);
|
|
612
|
+
const address = part.address ?? "";
|
|
613
|
+
const whole = [...lead, run(address, color)];
|
|
614
|
+
if (widthOf(whole) <= inner) {
|
|
615
|
+
// The detail shows where it fits whole.
|
|
616
|
+
const fits = widthOf(whole) + widthOf([detail]) <= inner;
|
|
617
|
+
return [{ key: part.id, runs: fits ? [...whole, detail] : whole }];
|
|
618
|
+
}
|
|
619
|
+
const indent = " ";
|
|
620
|
+
return [
|
|
621
|
+
{ key: part.id, runs: [...lead, { ...detail, text: detail.text.trimStart() }] },
|
|
622
|
+
...wrap(address, inner - indent.length).map((piece, i) => ({
|
|
623
|
+
key: `${part.id} ${i}`,
|
|
624
|
+
runs: [run(indent), run(piece, color)],
|
|
625
|
+
})),
|
|
626
|
+
];
|
|
627
|
+
};
|
|
628
|
+
const group = (heading, parts) => parts.map((part, i) => [
|
|
629
|
+
...(i > 0 || short ? [] : [{ key: heading, runs: [run(heading, c.dim, { bold: true })] }]),
|
|
630
|
+
...block(part),
|
|
631
|
+
]);
|
|
632
|
+
return [...group("APPS", env.apps), ...group("SERVICES", env.services)];
|
|
633
|
+
}
|
|
634
|
+
/** The selected environment's apps and services: state, address, and a detail. */
|
|
635
|
+
function partsPanel(env, view, snapshot, width, height, short, c) {
|
|
636
|
+
const inner = width - 4;
|
|
637
|
+
const branch = env.branch ?? "(detached)";
|
|
638
|
+
let rows = [];
|
|
639
|
+
if (env.problem !== undefined) {
|
|
640
|
+
for (const [i, piece] of wrap(env.problem, inner)
|
|
641
|
+
.slice(0, height - 2)
|
|
642
|
+
.entries()) {
|
|
643
|
+
rows.push(line(`problem ${i}`, inner, [run(piece, c.warn)]));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
else if (partsOf(env).length === 0) {
|
|
647
|
+
rows.push(line("none", inner, [run("No apps or services in its config.", c.mute)]));
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
rows = partsShown(env, inner, height - 2, short, view, snapshot, c).map(({ key, runs }) => runs.length === 0 ? gap(key) : line(key, inner, runs));
|
|
651
|
+
}
|
|
652
|
+
return panel("parts", width, height, c.frame,
|
|
653
|
+
// The branch, a name, shows whole or not at all: the sidebar has it whole.
|
|
654
|
+
[
|
|
655
|
+
run(env.label, c.text, { bold: true }),
|
|
656
|
+
...(branch !== env.label && widthOf([run(`${env.label} · ${branch}`)]) <= width - 6
|
|
657
|
+
? [run(` · ${branch}`, c.soft)]
|
|
658
|
+
: []),
|
|
659
|
+
], [run(env.dir, c.dim)], rows);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* The search in the stream's title: the query being typed, or the one found, then how to go on,
|
|
663
|
+
* which gives way first (`HINT`).
|
|
664
|
+
*/
|
|
665
|
+
function searchTitle(view, c) {
|
|
666
|
+
const { search } = view;
|
|
667
|
+
if (search === undefined)
|
|
668
|
+
return [];
|
|
669
|
+
if (search.typing) {
|
|
670
|
+
// The developer's own input outranks every hint and the mode; too long, its end shows, as in
|
|
671
|
+
// an input field.
|
|
672
|
+
const typed = (text) => [
|
|
673
|
+
run(" / ", c.accent, { bold: true }),
|
|
674
|
+
run(`${text}▏`, c.text, { bold: true }),
|
|
675
|
+
];
|
|
676
|
+
const chars = [...search.query];
|
|
677
|
+
return [
|
|
678
|
+
{
|
|
679
|
+
runs: typed(search.query),
|
|
680
|
+
shrink: (cols) => typed(`…${chars.slice(chars.length - Math.max(0, cols - 6)).join("")}`),
|
|
681
|
+
},
|
|
682
|
+
{ runs: [run(" enter find · esc cancel", c.mute)], gives: HINT },
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
return [
|
|
686
|
+
{ runs: [run(` “${search.query}”`, c.accent, { bold: true })], gives: QUERY },
|
|
687
|
+
{ runs: [run(" n older · N newer", c.mute)], gives: HINT },
|
|
688
|
+
];
|
|
689
|
+
}
|
|
690
|
+
/** In the stream's title, the order pieces give way in: hints, then names, never cut; a query found. */
|
|
691
|
+
const NEWER = 1;
|
|
692
|
+
const HINT = 2;
|
|
693
|
+
const LIVE_HINT = 3;
|
|
694
|
+
const NAME = 4;
|
|
695
|
+
const QUERY = 5;
|
|
696
|
+
/** `pieces` as runs in at most `room` columns: those that give way first are left out first. */
|
|
697
|
+
function fitted(pieces, room) {
|
|
698
|
+
let kept = pieces;
|
|
699
|
+
const order = pieces
|
|
700
|
+
.filter((p) => p.gives !== undefined)
|
|
701
|
+
.sort((a, b) => (a.gives ?? 0) - (b.gives ?? 0));
|
|
702
|
+
for (const piece of order) {
|
|
703
|
+
if (widthOf(kept.flatMap((p) => p.runs)) <= room)
|
|
704
|
+
break;
|
|
705
|
+
kept = kept.filter((p) => p !== piece);
|
|
706
|
+
}
|
|
707
|
+
const over = widthOf(kept.flatMap((p) => p.runs)) - room;
|
|
708
|
+
return kept.flatMap((p) => over > 0 && p.shrink !== undefined ? p.shrink(widthOf(p.runs) - over) : p.runs);
|
|
709
|
+
}
|
|
710
|
+
/** `text` with each match of `query` (ignoring case) drawn in reverse. */
|
|
711
|
+
function marked(text, query, color) {
|
|
712
|
+
if (query === "")
|
|
713
|
+
return [run(text, color)];
|
|
714
|
+
const runs = [];
|
|
715
|
+
const lower = text.toLowerCase();
|
|
716
|
+
const q = query.toLowerCase();
|
|
717
|
+
let from = 0;
|
|
718
|
+
for (let at = lower.indexOf(q); at >= 0; at = lower.indexOf(q, at + q.length)) {
|
|
719
|
+
runs.push(run(text.slice(from, at), color), run(text.slice(at, at + q.length), color, { inverse: true }));
|
|
720
|
+
from = at + q.length;
|
|
721
|
+
}
|
|
722
|
+
runs.push(run(text.slice(from), color));
|
|
723
|
+
return runs;
|
|
724
|
+
}
|
|
725
|
+
/** `runs` cut to at most `width` columns with `…`. */
|
|
726
|
+
function clip(runs, width) {
|
|
727
|
+
const out = [];
|
|
728
|
+
let left = width;
|
|
729
|
+
for (const r of runs) {
|
|
730
|
+
const chars = [...r.text];
|
|
731
|
+
if (chars.length <= left) {
|
|
732
|
+
out.push(r);
|
|
733
|
+
left -= chars.length;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
// A run that starts at the limit is left out, not cut to a lone `…`.
|
|
737
|
+
if (left > 0)
|
|
738
|
+
out.push({ ...r, text: `${chars.slice(0, left - 1).join("")}…` });
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
/** One merged, colour-coded stream of the environment's parts, or of the focused one alone. */
|
|
744
|
+
function stream(env, view, snapshot, width, height, c) {
|
|
745
|
+
const parts = partsOf(env);
|
|
746
|
+
const focused = parts.find((part) => part.id === view.focus);
|
|
747
|
+
const ids = focused === undefined ? parts.map((part) => part.id) : [focused.id];
|
|
748
|
+
const inner = width - 4;
|
|
749
|
+
const n = Math.max(0, height - 2);
|
|
750
|
+
const nameWidth = Math.max(...parts.map((p) => [...p.name].length), 3) + 2;
|
|
751
|
+
// Live, only the newest lines of each part are merged; paused, all of them, up to its line.
|
|
752
|
+
// Paused, the page ends at its line, or fills with the lines after it near the oldest.
|
|
753
|
+
const paused = view.until;
|
|
754
|
+
const all = paused === undefined ? [] : streamLines(snapshot, view);
|
|
755
|
+
const end = Math.max(all.filter((l) => l.seq <= paused).length, Math.min(n, all.length));
|
|
756
|
+
const shown = paused === undefined ? merged(snapshot.logs, ids, n) : all.slice(Math.max(0, end - n), end);
|
|
757
|
+
const search = view.search !== undefined && !view.search.typing ? view.search.query : "";
|
|
758
|
+
const rows = shown.map((l) => {
|
|
759
|
+
const color = partColor(env, l.id, c);
|
|
760
|
+
const name = parts.find((part) => part.id === l.id)?.name ?? l.id;
|
|
761
|
+
const match = search !== "" && l.seq === paused;
|
|
762
|
+
return line(`${l.seq}`, inner, [
|
|
763
|
+
run(`${l.time} `, c.dim),
|
|
764
|
+
run(match ? "› " : " ", c.warn, { bold: true }),
|
|
765
|
+
...(focused === undefined ? [run("▎", color), run(pad(name, nameWidth), color)] : []),
|
|
766
|
+
...marked(clean(l.text), search, settled(l, tone(l.text, c), c)),
|
|
767
|
+
]);
|
|
768
|
+
});
|
|
769
|
+
if (rows.length === 0) {
|
|
770
|
+
const idle = env.apps.length > 0 && env.apps.every((p) => p.state === "idle");
|
|
771
|
+
rows.push(line("empty", inner, [
|
|
772
|
+
run(idle ? "No output yet: its apps start on their first request." : "Waiting for output…", c.dim),
|
|
773
|
+
]));
|
|
774
|
+
}
|
|
775
|
+
// The focused part's name may be left out: the parts panel above marks it, and the border's colour.
|
|
776
|
+
const mode = {
|
|
777
|
+
runs: [
|
|
778
|
+
run(focused === undefined ? " all" : ` ${focused.name} only`, focused ? c.accent : c.soft),
|
|
779
|
+
],
|
|
780
|
+
gives: NAME,
|
|
781
|
+
};
|
|
782
|
+
// Paused, the title says how to go live.
|
|
783
|
+
const live = paused === undefined
|
|
784
|
+
? []
|
|
785
|
+
: [
|
|
786
|
+
{ runs: [run(" ‖ paused", c.warn, { bold: true })] },
|
|
787
|
+
{ runs: [run(` · ${all.length - end} newer`, c.soft)], gives: NEWER },
|
|
788
|
+
{ runs: [run(" · end for live", c.soft)], gives: LIVE_HINT },
|
|
789
|
+
];
|
|
790
|
+
const title = fitted([{ runs: [run("LOGS", c.mute, { bold: true })] }, mode, ...live, ...searchTitle(view, c)], titleRoom(width));
|
|
791
|
+
return panel("stream", width, height, focused === undefined ? c.frame : c.focus, title, [], rows);
|
|
792
|
+
}
|
|
793
|
+
/** Every key with what it does, in place of the logs while `?` is on. */
|
|
794
|
+
function help(width, height, c) {
|
|
795
|
+
const inner = width - 4;
|
|
796
|
+
const keyWidth = Math.max(...HELP.map(([k]) => [...k].length)) + 3;
|
|
797
|
+
const rows = HELP.slice(0, Math.max(0, height - 2)).map(([k, what]) => line(`help ${k}`, inner, [run(pad(k, keyWidth), c.text, { bold: true }), run(what, c.soft)]));
|
|
798
|
+
return panel("help", width, height, c.focus, [run("KEYS", c.mute, { bold: true }), run(" ? or esc to go back", c.dim)], [], rows);
|
|
799
|
+
}
|