create-rezi 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +30 -10
- package/dist/index.js +31 -16
- package/dist/index.js.map +1 -1
- package/dist/scaffold.d.ts +2 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +35 -36
- package/dist/scaffold.js.map +1 -1
- package/package.json +7 -4
- package/templates/cli-tool/README.md +42 -0
- package/templates/{file-browser → cli-tool}/package.json +3 -2
- package/templates/cli-tool/src/main.ts +1037 -0
- package/templates/dashboard/README.md +30 -7
- package/templates/dashboard/package.json +3 -2
- package/templates/dashboard/src/main.ts +1675 -198
- package/templates/stress-test/README.md +81 -0
- package/templates/{streaming-viewer → stress-test}/package.json +3 -2
- package/templates/stress-test/src/main.ts +1615 -0
- package/dist/__tests__/scaffold.test.d.ts +0 -2
- package/dist/__tests__/scaffold.test.d.ts.map +0 -1
- package/dist/__tests__/scaffold.test.js +0 -29
- package/dist/__tests__/scaffold.test.js.map +0 -1
- package/templates/file-browser/README.md +0 -18
- package/templates/file-browser/src/main.ts +0 -258
- package/templates/form-app/README.md +0 -18
- package/templates/form-app/package.json +0 -23
- package/templates/form-app/src/main.ts +0 -222
- package/templates/streaming-viewer/README.md +0 -17
- package/templates/streaming-viewer/src/main.ts +0 -176
- package/templates/streaming-viewer/tsconfig.json +0 -14
- /package/templates/{file-browser → cli-tool}/tsconfig.json +0 -0
- /package/templates/{form-app → stress-test}/tsconfig.json +0 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
import { exit } from "node:process";
|
|
2
|
+
import type {
|
|
3
|
+
BadgeVariant,
|
|
4
|
+
LogEntry,
|
|
5
|
+
RouteDefinition,
|
|
6
|
+
RouteRenderContext,
|
|
7
|
+
RouterApi,
|
|
8
|
+
TextStyle,
|
|
9
|
+
ThemeDefinition,
|
|
10
|
+
VNode,
|
|
11
|
+
} from "@rezi-ui/core";
|
|
12
|
+
import { createApp, darkTheme, lightTheme, nordTheme, ui } from "@rezi-ui/core";
|
|
13
|
+
import { createNodeBackend } from "@rezi-ui/node";
|
|
14
|
+
|
|
15
|
+
type ThemeName = "nord" | "dark" | "light";
|
|
16
|
+
type EnvironmentName = "development" | "staging" | "production";
|
|
17
|
+
type TopLevelRouteId = "home" | "logs" | "settings";
|
|
18
|
+
|
|
19
|
+
type AppState = {
|
|
20
|
+
nowMs: number;
|
|
21
|
+
tick: number;
|
|
22
|
+
logs: readonly LogEntry[];
|
|
23
|
+
logsScrollTop: number;
|
|
24
|
+
expandedLogIds: readonly string[];
|
|
25
|
+
autoRefresh: boolean;
|
|
26
|
+
includeDebug: boolean;
|
|
27
|
+
operatorName: string;
|
|
28
|
+
environment: EnvironmentName;
|
|
29
|
+
themeName: ThemeName;
|
|
30
|
+
commandTimeoutSec: number;
|
|
31
|
+
viewportCols: number;
|
|
32
|
+
viewportRows: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const PRODUCT_NAME = "__APP_NAME__";
|
|
36
|
+
const PRODUCT_TAGLINE = "Task-oriented multi-screen workflow with first-party routing";
|
|
37
|
+
const UI_FPS_CAP = 30;
|
|
38
|
+
const PANEL_PADDING_X = 1;
|
|
39
|
+
const PANEL_PADDING_Y = 0;
|
|
40
|
+
const LOG_SEED_COUNT = 24;
|
|
41
|
+
const LOG_HISTORY_LIMIT = 200;
|
|
42
|
+
const LOG_TICK_MS = 900;
|
|
43
|
+
const STACKED_LAYOUT_COLS = 118;
|
|
44
|
+
const COMPACT_LAYOUT_COLS = 96;
|
|
45
|
+
const COMPACT_LAYOUT_ROWS = 28;
|
|
46
|
+
const HISTORY_MAX_ITEMS_COMPACT = 5;
|
|
47
|
+
const HISTORY_MAX_ITEMS_DEFAULT = 9;
|
|
48
|
+
|
|
49
|
+
const THEME_BY_NAME: Record<ThemeName, ThemeDefinition> = {
|
|
50
|
+
nord: nordTheme,
|
|
51
|
+
dark: darkTheme,
|
|
52
|
+
light: lightTheme,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const LOG_MESSAGES: readonly string[] = Object.freeze([
|
|
56
|
+
"Indexed 14 workspace files",
|
|
57
|
+
"Loaded plugin metadata",
|
|
58
|
+
"Synced runtime configuration",
|
|
59
|
+
"Queued incremental diagnostics",
|
|
60
|
+
"Completed background health check",
|
|
61
|
+
"Applied command palette cache",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const LOG_SOURCES: readonly string[] = Object.freeze([
|
|
65
|
+
"core",
|
|
66
|
+
"scheduler",
|
|
67
|
+
"file-watcher",
|
|
68
|
+
"runtime",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const LOG_LEVELS: readonly LogEntry["level"][] = Object.freeze(["info", "warn", "error", "debug"]);
|
|
72
|
+
|
|
73
|
+
const ENV_OPTIONS: readonly Readonly<{ value: EnvironmentName; label: string }>[] = Object.freeze([
|
|
74
|
+
{ value: "development", label: "Development" },
|
|
75
|
+
{ value: "staging", label: "Staging" },
|
|
76
|
+
{ value: "production", label: "Production" },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const THEME_OPTIONS: readonly Readonly<{ value: ThemeName; label: string }>[] = Object.freeze([
|
|
80
|
+
{ value: "nord", label: "Nord" },
|
|
81
|
+
{ value: "dark", label: "Dark" },
|
|
82
|
+
{ value: "light", label: "Light" },
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
type ViewStyles = Readonly<{
|
|
86
|
+
rootStyle: TextStyle;
|
|
87
|
+
panelStyle: TextStyle;
|
|
88
|
+
stripStyle: TextStyle;
|
|
89
|
+
sectionLabelStyle: TextStyle;
|
|
90
|
+
metaStyle: TextStyle;
|
|
91
|
+
quietStyle: TextStyle;
|
|
92
|
+
}>;
|
|
93
|
+
|
|
94
|
+
function themeLabel(themeName: ThemeName): string {
|
|
95
|
+
return THEME_OPTIONS.find((option) => option.value === themeName)?.label ?? themeName;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function themeBadgeVariant(themeName: ThemeName): BadgeVariant {
|
|
99
|
+
if (themeName === "light") return "success";
|
|
100
|
+
if (themeName === "dark") return "default";
|
|
101
|
+
return "info";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function environmentBadgeVariant(environment: EnvironmentName): BadgeVariant {
|
|
105
|
+
if (environment === "production") return "warning";
|
|
106
|
+
if (environment === "staging") return "info";
|
|
107
|
+
return "success";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function levelBadgeVariant(level: LogEntry["level"]): BadgeVariant {
|
|
111
|
+
if (level === "error") return "error";
|
|
112
|
+
if (level === "warn") return "warning";
|
|
113
|
+
if (level === "debug") return "info";
|
|
114
|
+
return "default";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getViewStyles(themeName: ThemeName): ViewStyles {
|
|
118
|
+
const colors = THEME_BY_NAME[themeName].colors;
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
rootStyle: { bg: colors.bg.base, fg: colors.fg.primary },
|
|
121
|
+
panelStyle: { bg: colors.bg.elevated, fg: colors.fg.primary },
|
|
122
|
+
stripStyle: { bg: colors.bg.subtle, fg: colors.fg.primary },
|
|
123
|
+
sectionLabelStyle: { fg: colors.fg.secondary, bold: true },
|
|
124
|
+
metaStyle: { fg: colors.fg.secondary, dim: true },
|
|
125
|
+
quietStyle: { fg: colors.fg.muted, dim: true },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function panel(
|
|
130
|
+
title: string,
|
|
131
|
+
children: readonly VNode[],
|
|
132
|
+
style: TextStyle,
|
|
133
|
+
opts: Readonly<{ flex?: number; minWidth?: number }> = Object.freeze({}),
|
|
134
|
+
): VNode {
|
|
135
|
+
return ui.box(
|
|
136
|
+
{
|
|
137
|
+
title,
|
|
138
|
+
border: "rounded",
|
|
139
|
+
px: PANEL_PADDING_X,
|
|
140
|
+
py: PANEL_PADDING_Y,
|
|
141
|
+
style,
|
|
142
|
+
...(opts.flex === undefined ? {} : { flex: opts.flex }),
|
|
143
|
+
...(opts.minWidth === undefined ? {} : { minWidth: opts.minWidth }),
|
|
144
|
+
},
|
|
145
|
+
children,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function applyTheme(context: RouteRenderContext<AppState>, themeName: ThemeName): void {
|
|
150
|
+
context.update((prev) => Object.freeze({ ...prev, themeName }));
|
|
151
|
+
app.setTheme(THEME_BY_NAME[themeName]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatTime(timestampMs: number): string {
|
|
155
|
+
return new Date(timestampMs).toLocaleTimeString("en-US", { hour12: false });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isThemeName(value: string): value is ThemeName {
|
|
159
|
+
return value === "nord" || value === "dark" || value === "light";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isEnvironment(value: string): value is EnvironmentName {
|
|
163
|
+
return value === "development" || value === "staging" || value === "production";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function routeLabelFromId(routeId: string): string {
|
|
167
|
+
if (routeId === "home") return "Home";
|
|
168
|
+
if (routeId === "logs") return "Logs";
|
|
169
|
+
if (routeId === "settings") return "Settings";
|
|
170
|
+
if (routeId === "detail") return "Detail";
|
|
171
|
+
return routeId[0] ? routeId[0].toUpperCase() + routeId.slice(1) : routeId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function trimMiddle(value: string, maxChars: number): string {
|
|
175
|
+
if (value.length <= maxChars) return value;
|
|
176
|
+
if (maxChars <= 3) return value.slice(0, maxChars);
|
|
177
|
+
const sideWidth = Math.max(1, Math.floor((maxChars - 1) / 2));
|
|
178
|
+
return `${value.slice(0, sideWidth)}…${value.slice(value.length - sideWidth)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatHistoryTrail(
|
|
182
|
+
router: RouterApi,
|
|
183
|
+
viewportCols: number,
|
|
184
|
+
compact: boolean,
|
|
185
|
+
): Readonly<{ summary: string; count: number }> {
|
|
186
|
+
const entries = router.history();
|
|
187
|
+
const labels: string[] = [];
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
const label = routeLabelFromId(entry.id);
|
|
190
|
+
if (labels[labels.length - 1] === label) continue;
|
|
191
|
+
labels.push(label);
|
|
192
|
+
}
|
|
193
|
+
const maxItems = compact ? HISTORY_MAX_ITEMS_COMPACT : HISTORY_MAX_ITEMS_DEFAULT;
|
|
194
|
+
const tail = labels.slice(-maxItems);
|
|
195
|
+
const prefix = labels.length > tail.length ? ["…"] : [];
|
|
196
|
+
const text = [...prefix, ...tail].join(" > ");
|
|
197
|
+
const maxChars = Math.max(24, viewportCols - 20);
|
|
198
|
+
return Object.freeze({
|
|
199
|
+
summary: trimMiddle(text, maxChars),
|
|
200
|
+
count: entries.length,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function navigateTopLevel(router: RouterApi, routeId: TopLevelRouteId): void {
|
|
205
|
+
const current = router.currentRoute();
|
|
206
|
+
if (current.id === routeId) return;
|
|
207
|
+
if (current.id === "detail") {
|
|
208
|
+
router.navigate(routeId);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
router.replace(routeId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isStackedLayout(state: Readonly<AppState>): boolean {
|
|
215
|
+
return state.viewportCols < STACKED_LAYOUT_COLS;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isCompactLayout(state: Readonly<AppState>): boolean {
|
|
219
|
+
return state.viewportCols < COMPACT_LAYOUT_COLS || state.viewportRows < COMPACT_LAYOUT_ROWS;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildLogEntry(
|
|
223
|
+
tick: number,
|
|
224
|
+
environment: EnvironmentName,
|
|
225
|
+
includeDebug: boolean,
|
|
226
|
+
timestamp = Date.now(),
|
|
227
|
+
): LogEntry {
|
|
228
|
+
const source = LOG_SOURCES[tick % LOG_SOURCES.length] ?? "core";
|
|
229
|
+
const message = LOG_MESSAGES[tick % LOG_MESSAGES.length] ?? "Updated background task";
|
|
230
|
+
const baseLevel = LOG_LEVELS[tick % LOG_LEVELS.length] ?? "info";
|
|
231
|
+
const level = includeDebug || baseLevel !== "debug" ? baseLevel : "info";
|
|
232
|
+
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
id: `log-${String(tick)}`,
|
|
235
|
+
timestamp,
|
|
236
|
+
level,
|
|
237
|
+
source,
|
|
238
|
+
message: `${message} (${environment})`,
|
|
239
|
+
details:
|
|
240
|
+
`tick=${String(tick)}\n` +
|
|
241
|
+
`environment=${environment}\n` +
|
|
242
|
+
`source=${source}\n` +
|
|
243
|
+
`level=${level}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function initialLogs(
|
|
248
|
+
seedCount = LOG_SEED_COUNT,
|
|
249
|
+
environment: EnvironmentName = "staging",
|
|
250
|
+
): readonly LogEntry[] {
|
|
251
|
+
const nowMs = Date.now();
|
|
252
|
+
const seed: LogEntry[] = [];
|
|
253
|
+
for (let i = 0; i < seedCount; i++) {
|
|
254
|
+
const tick = i + 1;
|
|
255
|
+
const timestamp = nowMs - (seedCount - tick) * 1200;
|
|
256
|
+
seed.push(buildLogEntry(tick, environment, true, timestamp));
|
|
257
|
+
}
|
|
258
|
+
return Object.freeze(seed);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function appendTickLog(prev: Readonly<AppState>): AppState {
|
|
262
|
+
const nextTick = prev.tick + 1;
|
|
263
|
+
const nextEntry = buildLogEntry(nextTick, prev.environment, prev.includeDebug);
|
|
264
|
+
const nextLogs = Object.freeze([...prev.logs, nextEntry].slice(-LOG_HISTORY_LIMIT));
|
|
265
|
+
const nextExpanded = Object.freeze(
|
|
266
|
+
prev.expandedLogIds.filter((entryId) => nextLogs.some((entry) => entry.id === entryId)),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
return Object.freeze({
|
|
270
|
+
...prev,
|
|
271
|
+
nowMs: Date.now(),
|
|
272
|
+
tick: nextTick,
|
|
273
|
+
logs: nextLogs,
|
|
274
|
+
expandedLogIds: nextExpanded,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function updateExpanded(
|
|
279
|
+
previous: readonly string[],
|
|
280
|
+
entryId: string,
|
|
281
|
+
expanded: boolean,
|
|
282
|
+
): readonly string[] {
|
|
283
|
+
if (expanded) {
|
|
284
|
+
if (previous.includes(entryId)) return previous;
|
|
285
|
+
return Object.freeze([...previous, entryId]);
|
|
286
|
+
}
|
|
287
|
+
return Object.freeze(previous.filter((id) => id !== entryId));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const initialState: AppState = Object.freeze({
|
|
291
|
+
nowMs: Date.now(),
|
|
292
|
+
tick: LOG_SEED_COUNT,
|
|
293
|
+
logs: initialLogs(LOG_SEED_COUNT),
|
|
294
|
+
logsScrollTop: 0,
|
|
295
|
+
expandedLogIds: Object.freeze([]),
|
|
296
|
+
autoRefresh: true,
|
|
297
|
+
includeDebug: true,
|
|
298
|
+
operatorName: "operator",
|
|
299
|
+
environment: "staging",
|
|
300
|
+
themeName: "nord",
|
|
301
|
+
commandTimeoutSec: 30,
|
|
302
|
+
viewportCols: 120,
|
|
303
|
+
viewportRows: 36,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// biome-ignore lint/style/useConst: app is assigned after route declarations for route-screen closures.
|
|
307
|
+
let app!: ReturnType<typeof createApp<AppState>>;
|
|
308
|
+
let isStopping = false;
|
|
309
|
+
|
|
310
|
+
function renderShell(
|
|
311
|
+
title: string,
|
|
312
|
+
context: RouteRenderContext<AppState>,
|
|
313
|
+
body: VNode,
|
|
314
|
+
bodyTitle = title,
|
|
315
|
+
): VNode {
|
|
316
|
+
const state = context.state;
|
|
317
|
+
const styles = getViewStyles(state.themeName);
|
|
318
|
+
const compact = isCompactLayout(state);
|
|
319
|
+
const stacked = isStackedLayout(state);
|
|
320
|
+
const history = formatHistoryTrail(context.router, state.viewportCols, compact);
|
|
321
|
+
const currentRouteId = context.router.currentRoute().id;
|
|
322
|
+
|
|
323
|
+
return ui.column({ p: 1, gap: 1, items: "stretch", style: styles.rootStyle }, [
|
|
324
|
+
ui.box(
|
|
325
|
+
{ border: "rounded", px: PANEL_PADDING_X, py: PANEL_PADDING_Y, style: styles.stripStyle },
|
|
326
|
+
[
|
|
327
|
+
ui.column({ gap: 1 }, [
|
|
328
|
+
ui.row({ items: "center", gap: 1, wrap: true }, [
|
|
329
|
+
ui.text(`${PRODUCT_NAME} · ${title}`, { variant: "heading" }),
|
|
330
|
+
ui.badge(`Env ${state.environment.toUpperCase()}`, {
|
|
331
|
+
variant: environmentBadgeVariant(state.environment),
|
|
332
|
+
}),
|
|
333
|
+
ui.tag(`Theme ${themeLabel(state.themeName)}`, {
|
|
334
|
+
variant: themeBadgeVariant(state.themeName),
|
|
335
|
+
}),
|
|
336
|
+
ui.status(state.autoRefresh ? "online" : "away", {
|
|
337
|
+
label: state.autoRefresh ? "Streaming" : "Paused",
|
|
338
|
+
}),
|
|
339
|
+
ui.badge(`${String(state.viewportCols)}×${String(state.viewportRows)}`, {
|
|
340
|
+
variant: "default",
|
|
341
|
+
}),
|
|
342
|
+
ui.badge(`tick:${String(state.tick)}`, { variant: "default" }),
|
|
343
|
+
]),
|
|
344
|
+
...(compact
|
|
345
|
+
? []
|
|
346
|
+
: [
|
|
347
|
+
ui.text(PRODUCT_TAGLINE, { style: styles.sectionLabelStyle }),
|
|
348
|
+
ui.text(`Operator ${state.operatorName} · ${formatTime(state.nowMs)}`, {
|
|
349
|
+
style: styles.metaStyle,
|
|
350
|
+
}),
|
|
351
|
+
]),
|
|
352
|
+
]),
|
|
353
|
+
],
|
|
354
|
+
),
|
|
355
|
+
ui.box(
|
|
356
|
+
{ border: "rounded", px: PANEL_PADDING_X, py: PANEL_PADDING_Y, style: styles.stripStyle },
|
|
357
|
+
[
|
|
358
|
+
ui.column({ gap: 1 }, [
|
|
359
|
+
ui.routerTabs(context.router, topLevelRoutes, {
|
|
360
|
+
id: "app-route-tabs",
|
|
361
|
+
variant: "pills",
|
|
362
|
+
}),
|
|
363
|
+
...(compact
|
|
364
|
+
? []
|
|
365
|
+
: [
|
|
366
|
+
ui.routerBreadcrumb(context.router, routes, {
|
|
367
|
+
id: "app-route-breadcrumb",
|
|
368
|
+
separator: " > ",
|
|
369
|
+
}),
|
|
370
|
+
]),
|
|
371
|
+
ui.text(`History (${String(history.count)}): ${history.summary}`, {
|
|
372
|
+
style: styles.metaStyle,
|
|
373
|
+
}),
|
|
374
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
375
|
+
ui.kbd("f1"),
|
|
376
|
+
ui.text("Home", { style: styles.quietStyle }),
|
|
377
|
+
ui.kbd("f2"),
|
|
378
|
+
ui.text("Logs", { style: styles.quietStyle }),
|
|
379
|
+
ui.kbd("f3"),
|
|
380
|
+
ui.text("Settings", { style: styles.quietStyle }),
|
|
381
|
+
]),
|
|
382
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
383
|
+
ui.kbd("alt+1"),
|
|
384
|
+
ui.text("Home", { style: styles.quietStyle }),
|
|
385
|
+
ui.kbd("alt+2"),
|
|
386
|
+
ui.text("Logs", { style: styles.quietStyle }),
|
|
387
|
+
ui.kbd("alt+3"),
|
|
388
|
+
ui.text("Settings", { style: styles.quietStyle }),
|
|
389
|
+
]),
|
|
390
|
+
...(currentRouteId === "detail"
|
|
391
|
+
? [
|
|
392
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
393
|
+
ui.kbd("esc"),
|
|
394
|
+
ui.text("Back from detail", { style: styles.quietStyle }),
|
|
395
|
+
]),
|
|
396
|
+
]
|
|
397
|
+
: []),
|
|
398
|
+
...(stacked
|
|
399
|
+
? [
|
|
400
|
+
ui.text("Adaptive layout: stacked panels enabled", {
|
|
401
|
+
style: styles.quietStyle,
|
|
402
|
+
}),
|
|
403
|
+
]
|
|
404
|
+
: []),
|
|
405
|
+
]),
|
|
406
|
+
],
|
|
407
|
+
),
|
|
408
|
+
panel(bodyTitle, [body], styles.panelStyle, { flex: 1 }),
|
|
409
|
+
ui.text("Shortcuts: F1/F2/F3 or Alt+1/2/3 · Esc from Detail goes back · q quits", {
|
|
410
|
+
style: styles.quietStyle,
|
|
411
|
+
}),
|
|
412
|
+
]);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function renderHome(
|
|
416
|
+
_params: Readonly<Record<string, string>>,
|
|
417
|
+
context: RouteRenderContext<AppState>,
|
|
418
|
+
): VNode {
|
|
419
|
+
const state = context.state;
|
|
420
|
+
const styles = getViewStyles(state.themeName);
|
|
421
|
+
const stacked = isStackedLayout(state);
|
|
422
|
+
const latest = state.logs[state.logs.length - 1];
|
|
423
|
+
const hasLatest = latest !== undefined;
|
|
424
|
+
const telemetryPanels = [
|
|
425
|
+
panel(
|
|
426
|
+
"Live stream",
|
|
427
|
+
[
|
|
428
|
+
ui.column({ gap: 1 }, [
|
|
429
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
430
|
+
ui.status(state.autoRefresh ? "online" : "away", {
|
|
431
|
+
label: state.autoRefresh ? "Live stream enabled" : "Live stream paused",
|
|
432
|
+
}),
|
|
433
|
+
ui.badge(`logs:${String(state.logs.length)}`, { variant: "info" }),
|
|
434
|
+
ui.badge(`tick:${String(state.tick)}`, { variant: "info" }),
|
|
435
|
+
ui.badge(`history:${String(context.router.history().length)}`, { variant: "success" }),
|
|
436
|
+
]),
|
|
437
|
+
ui.text(
|
|
438
|
+
latest
|
|
439
|
+
? `Latest: [${latest.level.toUpperCase()}] ${latest.message} @ ${formatTime(latest.timestamp)}`
|
|
440
|
+
: "Latest: no log entries yet",
|
|
441
|
+
),
|
|
442
|
+
]),
|
|
443
|
+
],
|
|
444
|
+
styles.stripStyle,
|
|
445
|
+
{ flex: 2, minWidth: 44 },
|
|
446
|
+
),
|
|
447
|
+
panel(
|
|
448
|
+
"Quick actions",
|
|
449
|
+
[
|
|
450
|
+
ui.column({ gap: 1 }, [
|
|
451
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
452
|
+
ui.button({
|
|
453
|
+
id: "home-open-logs",
|
|
454
|
+
label: "Open Logs",
|
|
455
|
+
onPress: () => navigateTopLevel(context.router, "logs"),
|
|
456
|
+
}),
|
|
457
|
+
ui.button({
|
|
458
|
+
id: "home-open-settings",
|
|
459
|
+
label: "Open Settings",
|
|
460
|
+
onPress: () => navigateTopLevel(context.router, "settings"),
|
|
461
|
+
}),
|
|
462
|
+
ui.button({
|
|
463
|
+
id: "home-open-latest-detail",
|
|
464
|
+
label: "Open Latest Detail",
|
|
465
|
+
disabled: !hasLatest,
|
|
466
|
+
onPress: () => {
|
|
467
|
+
if (!latest) return;
|
|
468
|
+
context.router.navigate("detail", Object.freeze({ id: latest.id }));
|
|
469
|
+
},
|
|
470
|
+
}),
|
|
471
|
+
]),
|
|
472
|
+
ui.text(
|
|
473
|
+
"Tip: open a detail entry, then press Esc/Back to verify focus/history behavior.",
|
|
474
|
+
{ style: styles.quietStyle },
|
|
475
|
+
),
|
|
476
|
+
]),
|
|
477
|
+
],
|
|
478
|
+
styles.stripStyle,
|
|
479
|
+
{ flex: 1, minWidth: 32 },
|
|
480
|
+
),
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
return renderShell(
|
|
484
|
+
"Home",
|
|
485
|
+
context,
|
|
486
|
+
ui.column({ gap: 1 }, [
|
|
487
|
+
ui.callout(
|
|
488
|
+
"This app uses first-party page routing with keybindings, history, and focus restoration.",
|
|
489
|
+
{
|
|
490
|
+
title: "Router-ready CLI Template",
|
|
491
|
+
variant: "info",
|
|
492
|
+
},
|
|
493
|
+
),
|
|
494
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
495
|
+
ui.badge(`timeout:${String(state.commandTimeoutSec)}s`, { variant: "info" }),
|
|
496
|
+
ui.badge(`theme:${themeLabel(state.themeName)}`, {
|
|
497
|
+
variant: themeBadgeVariant(state.themeName),
|
|
498
|
+
}),
|
|
499
|
+
ui.tag(`debug:${state.includeDebug ? "on" : "off"}`, {
|
|
500
|
+
variant: state.includeDebug ? "info" : "default",
|
|
501
|
+
}),
|
|
502
|
+
]),
|
|
503
|
+
stacked
|
|
504
|
+
? ui.column({ gap: 1 }, telemetryPanels)
|
|
505
|
+
: ui.row({ gap: 1, wrap: true, items: "stretch" }, telemetryPanels),
|
|
506
|
+
]),
|
|
507
|
+
"Overview",
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderLogs(
|
|
512
|
+
_params: Readonly<Record<string, string>>,
|
|
513
|
+
context: RouteRenderContext<AppState>,
|
|
514
|
+
): VNode {
|
|
515
|
+
const state = context.state;
|
|
516
|
+
const styles = getViewStyles(state.themeName);
|
|
517
|
+
const stacked = isStackedLayout(state);
|
|
518
|
+
const compact = isCompactLayout(state);
|
|
519
|
+
const recent = [...state.logs].slice(-8).reverse();
|
|
520
|
+
|
|
521
|
+
const logsConsole = ui.logsConsole({
|
|
522
|
+
id: "logs-console",
|
|
523
|
+
entries: state.logs,
|
|
524
|
+
scrollTop: state.logsScrollTop,
|
|
525
|
+
expandedEntries: state.expandedLogIds,
|
|
526
|
+
...(state.includeDebug
|
|
527
|
+
? {}
|
|
528
|
+
: { levelFilter: Object.freeze(["info", "warn", "error"] as const) }),
|
|
529
|
+
onScroll: (scrollTop) => {
|
|
530
|
+
context.update((prev) => Object.freeze({ ...prev, logsScrollTop: scrollTop }));
|
|
531
|
+
},
|
|
532
|
+
onEntryToggle: (entryId, expanded) => {
|
|
533
|
+
context.update((prev) =>
|
|
534
|
+
Object.freeze({
|
|
535
|
+
...prev,
|
|
536
|
+
expandedLogIds: updateExpanded(prev.expandedLogIds, entryId, expanded),
|
|
537
|
+
}),
|
|
538
|
+
);
|
|
539
|
+
},
|
|
540
|
+
onClear: () => {
|
|
541
|
+
context.update((prev) =>
|
|
542
|
+
Object.freeze({
|
|
543
|
+
...prev,
|
|
544
|
+
logs: Object.freeze([]),
|
|
545
|
+
logsScrollTop: 0,
|
|
546
|
+
expandedLogIds: Object.freeze([]),
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const recentPanel = panel(
|
|
553
|
+
"Open recent entry",
|
|
554
|
+
[
|
|
555
|
+
ui.column({ gap: 1 }, [
|
|
556
|
+
...recent.map((entry) => {
|
|
557
|
+
const when = formatTime(entry.timestamp);
|
|
558
|
+
const sourceLabel =
|
|
559
|
+
entry.source.length > 10 ? `${entry.source.slice(0, 10)}…` : entry.source;
|
|
560
|
+
return ui.row({ gap: 1, items: "center", wrap: true }, [
|
|
561
|
+
ui.badge(entry.level.toUpperCase(), { variant: levelBadgeVariant(entry.level) }),
|
|
562
|
+
ui.button({
|
|
563
|
+
id: `open-${entry.id}`,
|
|
564
|
+
label: `${sourceLabel} ${when}`,
|
|
565
|
+
onPress: () => context.router.navigate("detail", Object.freeze({ id: entry.id })),
|
|
566
|
+
}),
|
|
567
|
+
]);
|
|
568
|
+
}),
|
|
569
|
+
...(recent.length === 0
|
|
570
|
+
? [ui.text("No entries in history.", { style: styles.metaStyle })]
|
|
571
|
+
: []),
|
|
572
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
573
|
+
ui.button({
|
|
574
|
+
id: "logs-open-latest-detail",
|
|
575
|
+
label: "Latest detail",
|
|
576
|
+
disabled: recent.length === 0,
|
|
577
|
+
onPress: () => {
|
|
578
|
+
const latest = recent[0];
|
|
579
|
+
if (!latest) return;
|
|
580
|
+
context.router.navigate("detail", Object.freeze({ id: latest.id }));
|
|
581
|
+
},
|
|
582
|
+
}),
|
|
583
|
+
ui.button({
|
|
584
|
+
id: "logs-open-settings",
|
|
585
|
+
label: "Settings",
|
|
586
|
+
onPress: () => navigateTopLevel(context.router, "settings"),
|
|
587
|
+
}),
|
|
588
|
+
]),
|
|
589
|
+
]),
|
|
590
|
+
],
|
|
591
|
+
styles.stripStyle,
|
|
592
|
+
stacked ? { minWidth: 34 } : { flex: 2, minWidth: 34 },
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const streamPanel = panel("Log stream", [logsConsole], styles.stripStyle, {
|
|
596
|
+
...(stacked ? {} : { flex: 3 }),
|
|
597
|
+
minWidth: 52,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
return renderShell(
|
|
601
|
+
"Logs",
|
|
602
|
+
context,
|
|
603
|
+
ui.column({ gap: 1 }, [
|
|
604
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
605
|
+
ui.tag(`debug:${state.includeDebug ? "on" : "off"}`, {
|
|
606
|
+
variant: state.includeDebug ? "info" : "default",
|
|
607
|
+
}),
|
|
608
|
+
ui.tag(`refresh:${state.autoRefresh ? "on" : "off"}`, {
|
|
609
|
+
variant: state.autoRefresh ? "success" : "warning",
|
|
610
|
+
}),
|
|
611
|
+
ui.badge(`entries:${String(state.logs.length)}`, { variant: "default" }),
|
|
612
|
+
ui.button({
|
|
613
|
+
id: "logs-toggle-refresh",
|
|
614
|
+
label: state.autoRefresh ? "Pause stream" : "Resume stream",
|
|
615
|
+
onPress: () => {
|
|
616
|
+
context.update((prev) => Object.freeze({ ...prev, autoRefresh: !prev.autoRefresh }));
|
|
617
|
+
},
|
|
618
|
+
}),
|
|
619
|
+
ui.button({
|
|
620
|
+
id: "logs-clear-inline",
|
|
621
|
+
label: "Clear logs",
|
|
622
|
+
onPress: () => {
|
|
623
|
+
context.update((prev) =>
|
|
624
|
+
Object.freeze({
|
|
625
|
+
...prev,
|
|
626
|
+
logs: Object.freeze([]),
|
|
627
|
+
logsScrollTop: 0,
|
|
628
|
+
expandedLogIds: Object.freeze([]),
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
},
|
|
632
|
+
}),
|
|
633
|
+
]),
|
|
634
|
+
stacked
|
|
635
|
+
? ui.column({ gap: 1 }, [streamPanel, recentPanel])
|
|
636
|
+
: ui.row({ gap: compact ? 1 : 2, wrap: true, items: "stretch" }, [
|
|
637
|
+
streamPanel,
|
|
638
|
+
recentPanel,
|
|
639
|
+
]),
|
|
640
|
+
]),
|
|
641
|
+
compact ? "Logs (compact)" : "Live Logs",
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function renderSettings(
|
|
646
|
+
_params: Readonly<Record<string, string>>,
|
|
647
|
+
context: RouteRenderContext<AppState>,
|
|
648
|
+
): VNode {
|
|
649
|
+
const state = context.state;
|
|
650
|
+
const styles = getViewStyles(state.themeName);
|
|
651
|
+
const stacked = isStackedLayout(state);
|
|
652
|
+
const compact = isCompactLayout(state);
|
|
653
|
+
|
|
654
|
+
const profilePanel = panel(
|
|
655
|
+
"Profile",
|
|
656
|
+
[
|
|
657
|
+
ui.column({ gap: 1 }, [
|
|
658
|
+
ui.text("Operator", { style: styles.sectionLabelStyle }),
|
|
659
|
+
ui.input({
|
|
660
|
+
id: "settings-operator",
|
|
661
|
+
value: state.operatorName,
|
|
662
|
+
onInput: (value) => {
|
|
663
|
+
context.update((prev) => Object.freeze({ ...prev, operatorName: value }));
|
|
664
|
+
},
|
|
665
|
+
}),
|
|
666
|
+
ui.text("Environment", { style: styles.sectionLabelStyle }),
|
|
667
|
+
ui.select({
|
|
668
|
+
id: "settings-environment",
|
|
669
|
+
value: state.environment,
|
|
670
|
+
options: ENV_OPTIONS,
|
|
671
|
+
onChange: (value) => {
|
|
672
|
+
if (!isEnvironment(value)) return;
|
|
673
|
+
context.update((prev) => Object.freeze({ ...prev, environment: value }));
|
|
674
|
+
},
|
|
675
|
+
}),
|
|
676
|
+
ui.text("Theme", { style: styles.sectionLabelStyle }),
|
|
677
|
+
ui.select({
|
|
678
|
+
id: "settings-theme",
|
|
679
|
+
value: state.themeName,
|
|
680
|
+
options: THEME_OPTIONS,
|
|
681
|
+
onChange: (value) => {
|
|
682
|
+
if (!isThemeName(value)) return;
|
|
683
|
+
applyTheme(context, value);
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
]),
|
|
687
|
+
],
|
|
688
|
+
styles.stripStyle,
|
|
689
|
+
stacked ? { minWidth: 34 } : { flex: 1, minWidth: 34 },
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const runtimePanel = panel(
|
|
693
|
+
"Runtime",
|
|
694
|
+
[
|
|
695
|
+
ui.column({ gap: 1 }, [
|
|
696
|
+
ui.checkbox({
|
|
697
|
+
id: "settings-auto-refresh",
|
|
698
|
+
checked: state.autoRefresh,
|
|
699
|
+
label: "Auto-refresh log stream",
|
|
700
|
+
onChange: (checked) => {
|
|
701
|
+
context.update((prev) => Object.freeze({ ...prev, autoRefresh: checked }));
|
|
702
|
+
},
|
|
703
|
+
}),
|
|
704
|
+
ui.checkbox({
|
|
705
|
+
id: "settings-include-debug",
|
|
706
|
+
checked: state.includeDebug,
|
|
707
|
+
label: "Include debug level entries",
|
|
708
|
+
onChange: (checked) => {
|
|
709
|
+
context.update((prev) => Object.freeze({ ...prev, includeDebug: checked }));
|
|
710
|
+
},
|
|
711
|
+
}),
|
|
712
|
+
ui.text(`Command timeout: ${String(state.commandTimeoutSec)}s`, {
|
|
713
|
+
style: styles.sectionLabelStyle,
|
|
714
|
+
}),
|
|
715
|
+
ui.slider({
|
|
716
|
+
id: "settings-timeout",
|
|
717
|
+
value: state.commandTimeoutSec,
|
|
718
|
+
min: 5,
|
|
719
|
+
max: 120,
|
|
720
|
+
step: 5,
|
|
721
|
+
onChange: (value) => {
|
|
722
|
+
context.update((prev) => Object.freeze({ ...prev, commandTimeoutSec: value }));
|
|
723
|
+
},
|
|
724
|
+
}),
|
|
725
|
+
ui.text(
|
|
726
|
+
`Current preset: ${themeLabel(state.themeName)} · ${state.environment.toUpperCase()}`,
|
|
727
|
+
{ style: styles.metaStyle },
|
|
728
|
+
),
|
|
729
|
+
]),
|
|
730
|
+
],
|
|
731
|
+
styles.stripStyle,
|
|
732
|
+
stacked ? { minWidth: 34 } : { flex: 1, minWidth: 34 },
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
return renderShell(
|
|
736
|
+
"Settings",
|
|
737
|
+
context,
|
|
738
|
+
ui.column({ gap: 1 }, [
|
|
739
|
+
ui.callout("Adjust runtime behavior, filtering, and presentation preferences.", {
|
|
740
|
+
title: "Preferences",
|
|
741
|
+
variant: "info",
|
|
742
|
+
}),
|
|
743
|
+
panel(
|
|
744
|
+
"Theme presets",
|
|
745
|
+
[
|
|
746
|
+
ui.row(
|
|
747
|
+
{ gap: 1, wrap: true },
|
|
748
|
+
THEME_OPTIONS.map((option) =>
|
|
749
|
+
ui.button({
|
|
750
|
+
id: `settings-theme-preset-${option.value}`,
|
|
751
|
+
label: option.label,
|
|
752
|
+
disabled: option.value === state.themeName,
|
|
753
|
+
onPress: () => applyTheme(context, option.value),
|
|
754
|
+
}),
|
|
755
|
+
),
|
|
756
|
+
),
|
|
757
|
+
],
|
|
758
|
+
styles.stripStyle,
|
|
759
|
+
{ minWidth: 34 },
|
|
760
|
+
),
|
|
761
|
+
stacked
|
|
762
|
+
? ui.column({ gap: 1 }, [profilePanel, runtimePanel])
|
|
763
|
+
: ui.row({ gap: compact ? 1 : 2, wrap: true, items: "stretch" }, [
|
|
764
|
+
profilePanel,
|
|
765
|
+
runtimePanel,
|
|
766
|
+
]),
|
|
767
|
+
panel(
|
|
768
|
+
"Current session",
|
|
769
|
+
[
|
|
770
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
771
|
+
ui.badge(`env:${state.environment}`, {
|
|
772
|
+
variant: environmentBadgeVariant(state.environment),
|
|
773
|
+
}),
|
|
774
|
+
ui.badge(`theme:${themeLabel(state.themeName)}`, {
|
|
775
|
+
variant: themeBadgeVariant(state.themeName),
|
|
776
|
+
}),
|
|
777
|
+
ui.badge(`timeout:${String(state.commandTimeoutSec)}s`, { variant: "info" }),
|
|
778
|
+
ui.tag(`debug:${state.includeDebug ? "on" : "off"}`, {
|
|
779
|
+
variant: state.includeDebug ? "info" : "default",
|
|
780
|
+
}),
|
|
781
|
+
]),
|
|
782
|
+
],
|
|
783
|
+
styles.stripStyle,
|
|
784
|
+
),
|
|
785
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
786
|
+
ui.button({
|
|
787
|
+
id: "settings-open-logs",
|
|
788
|
+
label: "Logs",
|
|
789
|
+
onPress: () => navigateTopLevel(context.router, "logs"),
|
|
790
|
+
}),
|
|
791
|
+
ui.button({
|
|
792
|
+
id: "settings-open-home",
|
|
793
|
+
label: "Home",
|
|
794
|
+
onPress: () => navigateTopLevel(context.router, "home"),
|
|
795
|
+
}),
|
|
796
|
+
]),
|
|
797
|
+
]),
|
|
798
|
+
compact ? "Settings (compact)" : "Configuration",
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function renderDetail(
|
|
803
|
+
params: Readonly<Record<string, string>>,
|
|
804
|
+
context: RouteRenderContext<AppState>,
|
|
805
|
+
): VNode {
|
|
806
|
+
const styles = getViewStyles(context.state.themeName);
|
|
807
|
+
const entryId = (params as Readonly<{ id?: string }>).id;
|
|
808
|
+
const logs = context.state.logs;
|
|
809
|
+
const index = entryId ? logs.findIndex((entry) => entry.id === entryId) : -1;
|
|
810
|
+
const entry = index >= 0 ? logs[index] : undefined;
|
|
811
|
+
|
|
812
|
+
if (!entry) {
|
|
813
|
+
return renderShell(
|
|
814
|
+
"Log Detail",
|
|
815
|
+
context,
|
|
816
|
+
ui.box({ border: "single", px: 1, py: 0, style: styles.stripStyle }, [
|
|
817
|
+
ui.column({ gap: 1 }, [
|
|
818
|
+
ui.callout("The selected log entry no longer exists in the bounded history window.", {
|
|
819
|
+
title: "Entry unavailable",
|
|
820
|
+
variant: "warning",
|
|
821
|
+
}),
|
|
822
|
+
ui.button({
|
|
823
|
+
id: "detail-missing-back",
|
|
824
|
+
label: "Back to Logs",
|
|
825
|
+
onPress: () => {
|
|
826
|
+
if (context.router.canGoBack()) context.router.back();
|
|
827
|
+
else navigateTopLevel(context.router, "logs");
|
|
828
|
+
},
|
|
829
|
+
}),
|
|
830
|
+
]),
|
|
831
|
+
]),
|
|
832
|
+
"Log Detail",
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const previous = index > 0 ? logs[index - 1] : undefined;
|
|
837
|
+
const next = index >= 0 && index < logs.length - 1 ? logs[index + 1] : undefined;
|
|
838
|
+
|
|
839
|
+
return renderShell(
|
|
840
|
+
"Log Detail",
|
|
841
|
+
context,
|
|
842
|
+
ui.box({ border: "single", p: 1, style: styles.stripStyle }, [
|
|
843
|
+
ui.column({ gap: 1 }, [
|
|
844
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
845
|
+
ui.badge(entry.level.toUpperCase(), {
|
|
846
|
+
variant: levelBadgeVariant(entry.level),
|
|
847
|
+
}),
|
|
848
|
+
ui.tag(`source:${entry.source}`, { variant: "default" }),
|
|
849
|
+
ui.tag(`time:${formatTime(entry.timestamp)}`, { variant: "default" }),
|
|
850
|
+
ui.badge(`index ${String(index + 1)} / ${String(logs.length)}`, { variant: "default" }),
|
|
851
|
+
]),
|
|
852
|
+
ui.text(entry.message),
|
|
853
|
+
ui.callout(entry.details ?? "No extra details", {
|
|
854
|
+
title: "Details",
|
|
855
|
+
variant: "info",
|
|
856
|
+
}),
|
|
857
|
+
ui.row({ gap: 1, wrap: true }, [
|
|
858
|
+
ui.button({
|
|
859
|
+
id: "detail-prev",
|
|
860
|
+
label: "Previous",
|
|
861
|
+
disabled: previous === undefined,
|
|
862
|
+
onPress: () => {
|
|
863
|
+
if (!previous) return;
|
|
864
|
+
context.router.replace("detail", Object.freeze({ id: previous.id }));
|
|
865
|
+
},
|
|
866
|
+
}),
|
|
867
|
+
ui.button({
|
|
868
|
+
id: "detail-next",
|
|
869
|
+
label: "Next",
|
|
870
|
+
disabled: next === undefined,
|
|
871
|
+
onPress: () => {
|
|
872
|
+
if (!next) return;
|
|
873
|
+
context.router.replace("detail", Object.freeze({ id: next.id }));
|
|
874
|
+
},
|
|
875
|
+
}),
|
|
876
|
+
ui.button({
|
|
877
|
+
id: "detail-back",
|
|
878
|
+
label: "Back",
|
|
879
|
+
onPress: () => {
|
|
880
|
+
if (context.router.canGoBack()) context.router.back();
|
|
881
|
+
else navigateTopLevel(context.router, "logs");
|
|
882
|
+
},
|
|
883
|
+
}),
|
|
884
|
+
]),
|
|
885
|
+
]),
|
|
886
|
+
]),
|
|
887
|
+
"Log Detail",
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const routes: readonly RouteDefinition<AppState>[] = Object.freeze([
|
|
892
|
+
{
|
|
893
|
+
id: "home",
|
|
894
|
+
title: "Home",
|
|
895
|
+
screen: renderHome,
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
id: "logs",
|
|
899
|
+
title: "Logs",
|
|
900
|
+
screen: renderLogs,
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
id: "settings",
|
|
904
|
+
title: "Settings",
|
|
905
|
+
screen: renderSettings,
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
id: "detail",
|
|
909
|
+
title: "Detail",
|
|
910
|
+
screen: renderDetail,
|
|
911
|
+
},
|
|
912
|
+
]);
|
|
913
|
+
|
|
914
|
+
const topLevelRoutes: readonly RouteDefinition<AppState>[] = Object.freeze(
|
|
915
|
+
routes.filter((route) => route.id !== "detail"),
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
app = createApp({
|
|
919
|
+
backend: createNodeBackend({
|
|
920
|
+
fpsCap: UI_FPS_CAP,
|
|
921
|
+
}),
|
|
922
|
+
initialState,
|
|
923
|
+
routes,
|
|
924
|
+
initialRoute: "home",
|
|
925
|
+
config: {
|
|
926
|
+
fpsCap: UI_FPS_CAP,
|
|
927
|
+
},
|
|
928
|
+
theme: THEME_BY_NAME[initialState.themeName],
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
async function shutdown(): Promise<void> {
|
|
932
|
+
if (isStopping) return;
|
|
933
|
+
isStopping = true;
|
|
934
|
+
clearInterval(logTimer);
|
|
935
|
+
try {
|
|
936
|
+
await app.stop();
|
|
937
|
+
} catch {
|
|
938
|
+
// Ignore shutdown races on forced exit paths.
|
|
939
|
+
}
|
|
940
|
+
app.dispose();
|
|
941
|
+
exit(0);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
app.keys({
|
|
945
|
+
q: () => {
|
|
946
|
+
void shutdown();
|
|
947
|
+
},
|
|
948
|
+
"ctrl+c": () => {
|
|
949
|
+
void shutdown();
|
|
950
|
+
},
|
|
951
|
+
f1: () => {
|
|
952
|
+
const router = app.router;
|
|
953
|
+
if (!router) return;
|
|
954
|
+
navigateTopLevel(router, "home");
|
|
955
|
+
},
|
|
956
|
+
f2: () => {
|
|
957
|
+
const router = app.router;
|
|
958
|
+
if (!router) return;
|
|
959
|
+
navigateTopLevel(router, "logs");
|
|
960
|
+
},
|
|
961
|
+
f3: () => {
|
|
962
|
+
const router = app.router;
|
|
963
|
+
if (!router) return;
|
|
964
|
+
navigateTopLevel(router, "settings");
|
|
965
|
+
},
|
|
966
|
+
"alt+1": () => {
|
|
967
|
+
const router = app.router;
|
|
968
|
+
if (!router) return;
|
|
969
|
+
navigateTopLevel(router, "home");
|
|
970
|
+
},
|
|
971
|
+
"alt+2": () => {
|
|
972
|
+
const router = app.router;
|
|
973
|
+
if (!router) return;
|
|
974
|
+
navigateTopLevel(router, "logs");
|
|
975
|
+
},
|
|
976
|
+
"alt+3": () => {
|
|
977
|
+
const router = app.router;
|
|
978
|
+
if (!router) return;
|
|
979
|
+
navigateTopLevel(router, "settings");
|
|
980
|
+
},
|
|
981
|
+
"ctrl+1": () => {
|
|
982
|
+
const router = app.router;
|
|
983
|
+
if (!router) return;
|
|
984
|
+
navigateTopLevel(router, "home");
|
|
985
|
+
},
|
|
986
|
+
"ctrl+2": () => {
|
|
987
|
+
const router = app.router;
|
|
988
|
+
if (!router) return;
|
|
989
|
+
navigateTopLevel(router, "logs");
|
|
990
|
+
},
|
|
991
|
+
"ctrl+3": () => {
|
|
992
|
+
const router = app.router;
|
|
993
|
+
if (!router) return;
|
|
994
|
+
navigateTopLevel(router, "settings");
|
|
995
|
+
},
|
|
996
|
+
escape: () => {
|
|
997
|
+
const router = app.router;
|
|
998
|
+
if (!router) return;
|
|
999
|
+
const route = router.currentRoute();
|
|
1000
|
+
if (route?.id === "detail") {
|
|
1001
|
+
if (router.canGoBack()) router.back();
|
|
1002
|
+
else navigateTopLevel(router, "logs");
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
app.onEvent((event) => {
|
|
1008
|
+
if (event.kind === "engine") {
|
|
1009
|
+
const raw = event.event;
|
|
1010
|
+
if (raw.kind === "resize") {
|
|
1011
|
+
app.update((prev) => {
|
|
1012
|
+
if (prev.viewportCols === raw.cols && prev.viewportRows === raw.rows) return prev;
|
|
1013
|
+
return Object.freeze({
|
|
1014
|
+
...prev,
|
|
1015
|
+
viewportCols: raw.cols,
|
|
1016
|
+
viewportRows: raw.rows,
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
if (raw.kind === "text" && raw.codepoint >= 0 && raw.codepoint <= 0x10ffff) {
|
|
1021
|
+
const ch = String.fromCodePoint(raw.codepoint).toLowerCase();
|
|
1022
|
+
if (ch === "q") void shutdown();
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (event.kind === "fatal") {
|
|
1026
|
+
clearInterval(logTimer);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const logTimer = setInterval(() => {
|
|
1031
|
+
app.update((prev) => {
|
|
1032
|
+
if (!prev.autoRefresh) return prev;
|
|
1033
|
+
return appendTickLog(prev);
|
|
1034
|
+
});
|
|
1035
|
+
}, LOG_TICK_MS);
|
|
1036
|
+
|
|
1037
|
+
await app.start();
|