@hoox-sh/hoox-tui 0.2.0 → 0.3.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/README.md +50 -31
- package/dist/main.js +4238 -1711
- package/package.json +2 -2
- package/src/app.tsx +45 -30
- package/src/cli-bridge-test-double.ts +48 -1
- package/src/components/layout/sidebar.tsx +27 -11
- package/src/components/layout/statusbar.test.tsx +19 -5
- package/src/components/layout/statusbar.tsx +34 -18
- package/src/components/shared/crash-screen.tsx +24 -2
- package/src/components/shared/error-boundary.tsx +56 -33
- package/src/components/ui/connection-toasts.test.ts +58 -1
- package/src/components/views/config-editor.test.tsx +4 -2
- package/src/components/views/config-editor.tsx +31 -3
- package/src/components/views/dashboard/alerts-panel.tsx +36 -8
- package/src/components/views/dashboard/auto-repair-helpers.test.ts +23 -0
- package/src/components/views/dashboard.test.tsx +2 -1
- package/src/components/views/dashboard.tsx +126 -41
- package/src/components/views/db-query.test.tsx +7 -0
- package/src/components/views/db-query.tsx +5 -4
- package/src/components/views/edge-topology.tsx +3 -2
- package/src/components/views/kv-viewer.test.tsx +28 -0
- package/src/components/views/kv-viewer.tsx +4 -3
- package/src/components/views/logs-viewer.tsx +46 -19
- package/src/components/views/queue-depth.test.tsx +39 -0
- package/src/components/views/queue-depth.tsx +3 -2
- package/src/components/views/secrets-viewer.test.tsx +2 -2
- package/src/components/views/secrets-viewer.tsx +4 -3
- package/src/components/views/service-manager.tsx +147 -53
- package/src/components/views/settings.tsx +50 -10
- package/src/components/views/setup-wizard.test.tsx +13 -0
- package/src/components/views/setup-wizard.tsx +53 -22
- package/src/components/views/trade-monitor.tsx +20 -13
- package/src/components/views/worker-detail.tsx +27 -12
- package/src/components/views/worker-settings.test.tsx +5 -6
- package/src/components/views/worker-settings.tsx +13 -11
- package/src/components/views/workers-overview.tsx +27 -6
- package/src/hooks/hooks.test.ts +303 -9
- package/src/hooks/index.ts +7 -1
- package/src/hooks/shell-overlay.ts +41 -0
- package/src/hooks/use-polling.ts +91 -35
- package/src/hooks/use-service-data.ts +21 -4
- package/src/main.tsx +15 -0
- package/src/services/cli-bridge/index.ts +28 -22
- package/src/services/cli-bridge/standalone.ts +29 -2
- package/src/services/cli-bridge.test.ts +848 -1
- package/src/services/clipboard.test.ts +217 -3
- package/src/services/dashboard-settings-loader.test.ts +50 -0
- package/src/services/tui-connection.test.ts +21 -0
- package/src/stores/service-store.test.ts +89 -3
- package/src/stores/ui-store.test.ts +23 -0
- package/src/test-setup.ts +11 -2
- package/src/types.ts +1 -0
- package/src/view-registry.test.ts +3 -1
- package/src/view-registry.tsx +14 -1
package/src/hooks/hooks.test.ts
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Hook unit tests — renderer ref, global keyboard registration, polling helpers.
|
|
8
8
|
*/
|
|
9
|
-
import { describe, it, expect, beforeEach, afterEach,
|
|
10
|
-
import {
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
setRendererRef,
|
|
12
|
+
getRendererRef,
|
|
13
|
+
registerGlobalHandler,
|
|
14
|
+
computePollingBackoff,
|
|
15
|
+
createPollingController,
|
|
16
|
+
POLLING_MAX_BACKOFF_MS,
|
|
17
|
+
} from "./index";
|
|
11
18
|
import type { KeyEvent } from "./use-keyboard";
|
|
12
19
|
|
|
13
20
|
describe("renderer-ref", () => {
|
|
@@ -36,6 +43,10 @@ describe("registerGlobalHandler", () => {
|
|
|
36
43
|
setRendererRef(null);
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
setRendererRef(null);
|
|
48
|
+
});
|
|
49
|
+
|
|
39
50
|
it("registers and unregisters without throwing", () => {
|
|
40
51
|
const calls: string[] = [];
|
|
41
52
|
const unsub = registerGlobalHandler((key: KeyEvent) => {
|
|
@@ -54,19 +65,302 @@ describe("registerGlobalHandler", () => {
|
|
|
54
65
|
b();
|
|
55
66
|
c();
|
|
56
67
|
});
|
|
68
|
+
|
|
69
|
+
it("dispatches keypresses in priority order when renderer is attached", () => {
|
|
70
|
+
const order: number[] = [];
|
|
71
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
72
|
+
const keyInput = {
|
|
73
|
+
on: (event: string, fn: (...args: unknown[]) => void) => {
|
|
74
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
75
|
+
listeners.get(event)!.add(fn);
|
|
76
|
+
return () => listeners.get(event)?.delete(fn);
|
|
77
|
+
},
|
|
78
|
+
off: (event: string, fn: (...args: unknown[]) => void) => {
|
|
79
|
+
listeners.get(event)?.delete(fn);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
setRendererRef({
|
|
83
|
+
keyInput,
|
|
84
|
+
} as unknown as import("@opentui/core").CliRenderer);
|
|
85
|
+
|
|
86
|
+
const unsubHigh = registerGlobalHandler(() => {
|
|
87
|
+
order.push(0);
|
|
88
|
+
}, 0);
|
|
89
|
+
const unsubMid = registerGlobalHandler(() => {
|
|
90
|
+
order.push(10);
|
|
91
|
+
}, 10);
|
|
92
|
+
const unsubLow = registerGlobalHandler(() => {
|
|
93
|
+
order.push(50);
|
|
94
|
+
}, 50);
|
|
95
|
+
|
|
96
|
+
const handlers = listeners.get("keypress");
|
|
97
|
+
expect(handlers?.size).toBe(1);
|
|
98
|
+
for (const h of handlers ?? []) {
|
|
99
|
+
h({
|
|
100
|
+
name: "a",
|
|
101
|
+
ctrl: false,
|
|
102
|
+
shift: false,
|
|
103
|
+
alt: false,
|
|
104
|
+
meta: false,
|
|
105
|
+
sequence: "a",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
expect(order).toEqual([0, 10, 50]);
|
|
109
|
+
|
|
110
|
+
unsubHigh();
|
|
111
|
+
unsubMid();
|
|
112
|
+
unsubLow();
|
|
113
|
+
// Last unsubscribe detaches key input
|
|
114
|
+
expect(listeners.get("keypress")?.size ?? 0).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("snapshot protects against unsubscribe mid-dispatch", () => {
|
|
118
|
+
const seen: string[] = [];
|
|
119
|
+
let unsubB: (() => void) | null = null;
|
|
120
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
121
|
+
const keyInput = {
|
|
122
|
+
on: (event: string, fn: (...args: unknown[]) => void) => {
|
|
123
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
124
|
+
listeners.get(event)!.add(fn);
|
|
125
|
+
return () => listeners.get(event)?.delete(fn);
|
|
126
|
+
},
|
|
127
|
+
off: () => {},
|
|
128
|
+
};
|
|
129
|
+
setRendererRef({
|
|
130
|
+
keyInput,
|
|
131
|
+
} as unknown as import("@opentui/core").CliRenderer);
|
|
132
|
+
|
|
133
|
+
const unsubA = registerGlobalHandler(() => {
|
|
134
|
+
seen.push("a");
|
|
135
|
+
unsubB?.();
|
|
136
|
+
}, 0);
|
|
137
|
+
unsubB = registerGlobalHandler(() => {
|
|
138
|
+
seen.push("b");
|
|
139
|
+
}, 1);
|
|
140
|
+
|
|
141
|
+
for (const h of listeners.get("keypress") ?? []) {
|
|
142
|
+
h({
|
|
143
|
+
name: "x",
|
|
144
|
+
ctrl: false,
|
|
145
|
+
shift: false,
|
|
146
|
+
alt: false,
|
|
147
|
+
meta: false,
|
|
148
|
+
sequence: "x",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Snapshot ensures b still runs even though unsub mid-dispatch
|
|
152
|
+
expect(seen).toEqual(["a", "b"]);
|
|
153
|
+
unsubA();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("attaches when on() returns non-function (off fallback path)", () => {
|
|
157
|
+
let offCalled = false;
|
|
158
|
+
const keyInput = {
|
|
159
|
+
on: () => undefined,
|
|
160
|
+
off: () => {
|
|
161
|
+
offCalled = true;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
setRendererRef({
|
|
165
|
+
keyInput,
|
|
166
|
+
} as unknown as import("@opentui/core").CliRenderer);
|
|
167
|
+
const unsub = registerGlobalHandler(() => {}, 5);
|
|
168
|
+
unsub();
|
|
169
|
+
expect(offCalled).toBe(true);
|
|
170
|
+
});
|
|
57
171
|
});
|
|
58
172
|
|
|
59
|
-
describe("
|
|
173
|
+
describe("computePollingBackoff", () => {
|
|
60
174
|
it("caps exponential backoff at 16s", () => {
|
|
61
175
|
const base = 1000;
|
|
62
|
-
const MAX = 16_000;
|
|
63
176
|
for (let retry = 0; retry < 10; retry++) {
|
|
64
|
-
const backoff =
|
|
65
|
-
expect(backoff).toBeLessThanOrEqual(
|
|
177
|
+
const backoff = computePollingBackoff(base, retry);
|
|
178
|
+
expect(backoff).toBeLessThanOrEqual(POLLING_MAX_BACKOFF_MS);
|
|
66
179
|
}
|
|
67
|
-
expect(
|
|
180
|
+
expect(computePollingBackoff(base, 10)).toBe(POLLING_MAX_BACKOFF_MS);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("doubles per retry until cap", () => {
|
|
184
|
+
expect(computePollingBackoff(500, 0)).toBe(500);
|
|
185
|
+
expect(computePollingBackoff(500, 1)).toBe(1000);
|
|
186
|
+
expect(computePollingBackoff(500, 2)).toBe(2000);
|
|
187
|
+
expect(computePollingBackoff(500, 3)).toBe(4000);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("respects custom max backoff", () => {
|
|
191
|
+
expect(computePollingBackoff(1000, 5, 3000)).toBe(3000);
|
|
68
192
|
});
|
|
69
193
|
});
|
|
70
194
|
|
|
71
|
-
|
|
72
|
-
|
|
195
|
+
describe("createPollingController", () => {
|
|
196
|
+
/** Flush microtasks after advancing fake timers (Bun has no advanceTimersByTimeAsync). */
|
|
197
|
+
async function advance(ms: number): Promise<void> {
|
|
198
|
+
jest.advanceTimersByTime(ms);
|
|
199
|
+
// Drain promise chains scheduled by timer callbacks
|
|
200
|
+
await Promise.resolve();
|
|
201
|
+
await Promise.resolve();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
jest.useFakeTimers();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
afterEach(() => {
|
|
209
|
+
jest.useRealTimers();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("invokes callback immediately when immediate=true", async () => {
|
|
213
|
+
let calls = 0;
|
|
214
|
+
const ctrl = createPollingController({
|
|
215
|
+
getCallback: () => async () => {
|
|
216
|
+
calls++;
|
|
217
|
+
},
|
|
218
|
+
getIntervalMs: () => 1000,
|
|
219
|
+
immediate: true,
|
|
220
|
+
});
|
|
221
|
+
ctrl.start();
|
|
222
|
+
// Let the microtask from the immediate poll resolve
|
|
223
|
+
await Promise.resolve();
|
|
224
|
+
expect(calls).toBe(1);
|
|
225
|
+
ctrl.stop();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("defers first poll when immediate=false", async () => {
|
|
229
|
+
let calls = 0;
|
|
230
|
+
const ctrl = createPollingController({
|
|
231
|
+
getCallback: () => async () => {
|
|
232
|
+
calls++;
|
|
233
|
+
},
|
|
234
|
+
getIntervalMs: () => 500,
|
|
235
|
+
immediate: false,
|
|
236
|
+
});
|
|
237
|
+
ctrl.start();
|
|
238
|
+
await Promise.resolve();
|
|
239
|
+
expect(calls).toBe(0);
|
|
240
|
+
await advance(500);
|
|
241
|
+
expect(calls).toBe(1);
|
|
242
|
+
ctrl.stop();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("increments retry count on failure and resets on success", async () => {
|
|
246
|
+
let shouldFail = true;
|
|
247
|
+
const ctrl = createPollingController({
|
|
248
|
+
getCallback: () => async () => {
|
|
249
|
+
if (shouldFail) throw new Error("boom");
|
|
250
|
+
},
|
|
251
|
+
getIntervalMs: () => 100,
|
|
252
|
+
immediate: true,
|
|
253
|
+
});
|
|
254
|
+
ctrl.start();
|
|
255
|
+
await Promise.resolve();
|
|
256
|
+
expect(ctrl.getRetryCount()).toBe(1);
|
|
257
|
+
|
|
258
|
+
// Next scheduled poll after backoff 100 * 2^1 = 200
|
|
259
|
+
shouldFail = false;
|
|
260
|
+
await advance(200);
|
|
261
|
+
expect(ctrl.getRetryCount()).toBe(0);
|
|
262
|
+
ctrl.stop();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("stop prevents further polls after pending timeout", async () => {
|
|
266
|
+
let calls = 0;
|
|
267
|
+
const ctrl = createPollingController({
|
|
268
|
+
getCallback: () => async () => {
|
|
269
|
+
calls++;
|
|
270
|
+
},
|
|
271
|
+
getIntervalMs: () => 1000,
|
|
272
|
+
immediate: true,
|
|
273
|
+
});
|
|
274
|
+
ctrl.start();
|
|
275
|
+
await Promise.resolve();
|
|
276
|
+
expect(calls).toBe(1);
|
|
277
|
+
ctrl.stop();
|
|
278
|
+
await advance(10_000);
|
|
279
|
+
expect(calls).toBe(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("reads fresh interval on each iteration", async () => {
|
|
283
|
+
let interval = 100;
|
|
284
|
+
let calls = 0;
|
|
285
|
+
const ctrl = createPollingController({
|
|
286
|
+
getCallback: () => async () => {
|
|
287
|
+
calls++;
|
|
288
|
+
},
|
|
289
|
+
getIntervalMs: () => interval,
|
|
290
|
+
immediate: true,
|
|
291
|
+
});
|
|
292
|
+
ctrl.start();
|
|
293
|
+
await Promise.resolve();
|
|
294
|
+
expect(calls).toBe(1);
|
|
295
|
+
interval = 250;
|
|
296
|
+
// Next poll was scheduled at previous interval (100); after it runs,
|
|
297
|
+
// subsequent delay uses the new interval.
|
|
298
|
+
await advance(100);
|
|
299
|
+
expect(calls).toBe(2);
|
|
300
|
+
await advance(249);
|
|
301
|
+
expect(calls).toBe(2);
|
|
302
|
+
await advance(1);
|
|
303
|
+
expect(calls).toBe(3);
|
|
304
|
+
ctrl.stop();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("stop during in-flight callback does not schedule next poll", async () => {
|
|
308
|
+
let resolveCb!: () => void;
|
|
309
|
+
let calls = 0;
|
|
310
|
+
const ctrl = createPollingController({
|
|
311
|
+
getCallback: () => () =>
|
|
312
|
+
new Promise<void>((resolve) => {
|
|
313
|
+
calls++;
|
|
314
|
+
resolveCb = resolve;
|
|
315
|
+
}),
|
|
316
|
+
getIntervalMs: () => 100,
|
|
317
|
+
immediate: true,
|
|
318
|
+
});
|
|
319
|
+
ctrl.start();
|
|
320
|
+
await Promise.resolve();
|
|
321
|
+
expect(calls).toBe(1);
|
|
322
|
+
ctrl.stop();
|
|
323
|
+
resolveCb();
|
|
324
|
+
await Promise.resolve();
|
|
325
|
+
await advance(1000);
|
|
326
|
+
expect(calls).toBe(1);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("useServiceData", () => {
|
|
331
|
+
afterEach(async () => {
|
|
332
|
+
const { __setServiceStoreHookForTests } =
|
|
333
|
+
await import("./use-service-data");
|
|
334
|
+
__setServiceStoreHookForTests(null);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("applies the selector via the store hook", async () => {
|
|
338
|
+
const { useServiceData, __setServiceStoreHookForTests } =
|
|
339
|
+
await import("./use-service-data");
|
|
340
|
+
const { useServiceStore } = await import("@hoox-sh/hoox-shared");
|
|
341
|
+
const snapshot = useServiceStore.getState();
|
|
342
|
+
|
|
343
|
+
__setServiceStoreHookForTests((selector) => selector(snapshot));
|
|
344
|
+
|
|
345
|
+
const status = useServiceData((s) => s.connectionStatus);
|
|
346
|
+
expect(status).toBe(snapshot.connectionStatus);
|
|
347
|
+
|
|
348
|
+
const workers = useServiceData((s) => s.workers);
|
|
349
|
+
expect(workers).toBe(snapshot.workers);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("restores real store hook after null reset", async () => {
|
|
353
|
+
const { useServiceData, __setServiceStoreHookForTests } =
|
|
354
|
+
await import("./use-service-data");
|
|
355
|
+
__setServiceStoreHookForTests((() => "injected") as never);
|
|
356
|
+
expect(useServiceData((() => "x") as never) as unknown).toBe("injected");
|
|
357
|
+
__setServiceStoreHookForTests(null);
|
|
358
|
+
// Real hook throws outside React — proves restore happened
|
|
359
|
+
expect(() => useServiceData((s) => s.connectionStatus)).toThrow();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("is re-exported from hooks index", async () => {
|
|
363
|
+
const mod = await import("./index");
|
|
364
|
+
expect(typeof mod.useServiceData).toBe("function");
|
|
365
|
+
});
|
|
366
|
+
});
|
package/src/hooks/index.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
export { useKeyboard, registerGlobalHandler } from "./use-keyboard";
|
|
7
7
|
export type { KeyEvent, UseKeyboardOptions } from "./use-keyboard";
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
usePolling,
|
|
10
|
+
computePollingBackoff,
|
|
11
|
+
createPollingController,
|
|
12
|
+
POLLING_MAX_BACKOFF_MS,
|
|
13
|
+
} from "./use-polling";
|
|
9
14
|
export type { UsePollingOptions } from "./use-polling";
|
|
10
15
|
export { useServiceData } from "./use-service-data";
|
|
11
16
|
export { setRendererRef, getRendererRef } from "./renderer-ref";
|
|
17
|
+
export { isShellOverlayOpen, useViewKeyboard } from "./shell-overlay";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shell overlay helpers — command palette / quit modal ownership of keys.
|
|
8
|
+
* View-local keyboard handlers should no-op while an overlay is open so
|
|
9
|
+
* typing in the palette (or confirming quit) does not fire view shortcuts.
|
|
10
|
+
*/
|
|
11
|
+
import { useKeyboard } from "@opentui/react";
|
|
12
|
+
import { useUIStore } from "@hoox-sh/hoox-shared";
|
|
13
|
+
|
|
14
|
+
/** True when palette or a UI-store modal owns the keyboard. */
|
|
15
|
+
export function isShellOverlayOpen(): boolean {
|
|
16
|
+
const s = useUIStore.getState();
|
|
17
|
+
return Boolean(s.commandPaletteOpen || s.modal);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Key shape from OpenTUI; fields optional at the type boundary. */
|
|
21
|
+
export type ViewKeyEvent = {
|
|
22
|
+
name?: string;
|
|
23
|
+
ctrl?: boolean;
|
|
24
|
+
alt?: boolean;
|
|
25
|
+
meta?: boolean;
|
|
26
|
+
shift?: boolean;
|
|
27
|
+
sequence?: string;
|
|
28
|
+
/** Some OpenTUI builds expose the raw character as `raw`. */
|
|
29
|
+
raw?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* View-scoped keyboard hook: skips the handler while shell overlays are open.
|
|
34
|
+
* Drop-in for `@opentui/react` `useKeyboard` in primary views.
|
|
35
|
+
*/
|
|
36
|
+
export function useViewKeyboard(handler: (key: ViewKeyEvent) => void): void {
|
|
37
|
+
useKeyboard((key) => {
|
|
38
|
+
if (isShellOverlayOpen()) return;
|
|
39
|
+
handler(key as ViewKeyEvent);
|
|
40
|
+
});
|
|
41
|
+
}
|
package/src/hooks/use-polling.ts
CHANGED
|
@@ -18,6 +18,89 @@ export interface UsePollingOptions {
|
|
|
18
18
|
immediate?: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Max poll delay after repeated failures (ms). Exported for unit tests. */
|
|
22
|
+
export const POLLING_MAX_BACKOFF_MS = 16_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Exponential backoff for poll failures, capped at {@link POLLING_MAX_BACKOFF_MS}.
|
|
26
|
+
* Exported for unit tests (pure).
|
|
27
|
+
*/
|
|
28
|
+
export function computePollingBackoff(
|
|
29
|
+
baseIntervalMs: number,
|
|
30
|
+
retryCount: number,
|
|
31
|
+
maxBackoffMs: number = POLLING_MAX_BACKOFF_MS
|
|
32
|
+
): number {
|
|
33
|
+
return Math.min(baseIntervalMs * Math.pow(2, retryCount), maxBackoffMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Non-React poll scheduler used by {@link usePolling}.
|
|
38
|
+
* Exported so unit tests can cover success/failure/cancel without a renderer.
|
|
39
|
+
*/
|
|
40
|
+
export function createPollingController(options: {
|
|
41
|
+
getCallback: () => () => Promise<void>;
|
|
42
|
+
getIntervalMs: () => number;
|
|
43
|
+
immediate?: boolean;
|
|
44
|
+
maxBackoffMs?: number;
|
|
45
|
+
/** Mutable retry counter (shared with the React ref when used from the hook). */
|
|
46
|
+
retryCountRef?: { current: number };
|
|
47
|
+
}): { start: () => void; stop: () => void; getRetryCount: () => number } {
|
|
48
|
+
const {
|
|
49
|
+
getCallback,
|
|
50
|
+
getIntervalMs,
|
|
51
|
+
immediate = true,
|
|
52
|
+
maxBackoffMs = POLLING_MAX_BACKOFF_MS,
|
|
53
|
+
retryCountRef = { current: 0 },
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
|
|
59
|
+
const poll = async () => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
try {
|
|
62
|
+
await getCallback()();
|
|
63
|
+
if (cancelled) return;
|
|
64
|
+
retryCountRef.current = 0;
|
|
65
|
+
} catch {
|
|
66
|
+
if (cancelled) return;
|
|
67
|
+
retryCountRef.current++;
|
|
68
|
+
}
|
|
69
|
+
// cancelled is already checked in both branches above (no await between
|
|
70
|
+
// those checks and here), so schedule the next tick directly.
|
|
71
|
+
// Read fresh interval on every iteration so config changes take effect.
|
|
72
|
+
const currentInterval = getIntervalMs();
|
|
73
|
+
const backoff = computePollingBackoff(
|
|
74
|
+
currentInterval,
|
|
75
|
+
retryCountRef.current,
|
|
76
|
+
maxBackoffMs
|
|
77
|
+
);
|
|
78
|
+
timeoutId = setTimeout(() => {
|
|
79
|
+
void poll();
|
|
80
|
+
}, backoff);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
getRetryCount: () => retryCountRef.current,
|
|
85
|
+
start: () => {
|
|
86
|
+
cancelled = false;
|
|
87
|
+
if (immediate) {
|
|
88
|
+
void poll();
|
|
89
|
+
} else {
|
|
90
|
+
const initialInterval = getIntervalMs();
|
|
91
|
+
timeoutId = setTimeout(() => {
|
|
92
|
+
void poll();
|
|
93
|
+
}, initialInterval);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
stop: () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
99
|
+
timeoutId = undefined;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
21
104
|
export function usePolling(options: UsePollingOptions): void {
|
|
22
105
|
const { callback, enabled = true, immediate = true } = options;
|
|
23
106
|
const intervalMs = useConfigStore((s) => s.refreshIntervalMs);
|
|
@@ -32,42 +115,15 @@ export function usePolling(options: UsePollingOptions): void {
|
|
|
32
115
|
return;
|
|
33
116
|
}
|
|
34
117
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
await callbackRef.current();
|
|
43
|
-
if (cancelled) return;
|
|
44
|
-
retryCount.current = 0;
|
|
45
|
-
} catch {
|
|
46
|
-
if (cancelled) return;
|
|
47
|
-
retryCount.current++;
|
|
48
|
-
}
|
|
49
|
-
if (cancelled) return;
|
|
50
|
-
// Read fresh from store on every iteration so config changes take effect
|
|
51
|
-
const currentInterval = useConfigStore.getState().refreshIntervalMs;
|
|
52
|
-
const backoff = Math.min(
|
|
53
|
-
currentInterval * Math.pow(2, retryCount.current),
|
|
54
|
-
MAX_BACKOFF
|
|
55
|
-
);
|
|
56
|
-
timeoutId = setTimeout(poll, backoff);
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
if (immediate) {
|
|
60
|
-
void poll();
|
|
61
|
-
} else {
|
|
62
|
-
const initialInterval = useConfigStore.getState().refreshIntervalMs;
|
|
63
|
-
timeoutId = setTimeout(() => {
|
|
64
|
-
void poll();
|
|
65
|
-
}, initialInterval);
|
|
66
|
-
}
|
|
67
|
-
|
|
118
|
+
const controller = createPollingController({
|
|
119
|
+
getCallback: () => callbackRef.current,
|
|
120
|
+
getIntervalMs: () => useConfigStore.getState().refreshIntervalMs,
|
|
121
|
+
immediate,
|
|
122
|
+
retryCountRef: retryCount,
|
|
123
|
+
});
|
|
124
|
+
controller.start();
|
|
68
125
|
return () => {
|
|
69
|
-
|
|
70
|
-
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
126
|
+
controller.stop();
|
|
71
127
|
};
|
|
72
128
|
}, [intervalMs, enabled, immediate]);
|
|
73
129
|
}
|
|
@@ -11,8 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { useServiceStore } from "@hoox-sh/hoox-shared";
|
|
13
13
|
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
export type ServiceStoreState = ReturnType<typeof useServiceStore.getState>;
|
|
15
|
+
export type ServiceStoreSelector<T> = (state: ServiceStoreState) => T;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Store hook used by {@link useServiceData}. Production points at
|
|
19
|
+
* `useServiceStore`; unit tests may swap this for a pure selector runner
|
|
20
|
+
* via {@link __setServiceStoreHookForTests}.
|
|
21
|
+
*/
|
|
22
|
+
let storeHook: <T>(selector: ServiceStoreSelector<T>) => T =
|
|
23
|
+
useServiceStore as <T>(selector: ServiceStoreSelector<T>) => T;
|
|
24
|
+
|
|
25
|
+
/** @internal Test-only seam — pass `null` to restore the real Zustand hook. */
|
|
26
|
+
export function __setServiceStoreHookForTests(
|
|
27
|
+
hook: (<T>(selector: ServiceStoreSelector<T>) => T) | null
|
|
28
|
+
): void {
|
|
29
|
+
storeHook =
|
|
30
|
+
hook ?? (useServiceStore as <T>(selector: ServiceStoreSelector<T>) => T);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useServiceData<T>(selector: ServiceStoreSelector<T>): T {
|
|
34
|
+
return storeHook(selector);
|
|
18
35
|
}
|
package/src/main.tsx
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createCliRenderer } from "@opentui/core";
|
|
15
15
|
import { createRoot } from "@opentui/react";
|
|
16
|
+
import { ToasterRenderable } from "@opentui-ui/toast";
|
|
16
17
|
import { CrashRecoveryApp } from "./app";
|
|
17
18
|
import {
|
|
18
19
|
Colors,
|
|
@@ -102,6 +103,20 @@ async function main() {
|
|
|
102
103
|
// Set renderer ref so hooks + components can access it via getRendererRef()
|
|
103
104
|
setRendererRef(renderer);
|
|
104
105
|
|
|
106
|
+
// Mount toast host so connection/auth toasts actually render
|
|
107
|
+
try {
|
|
108
|
+
const root = (
|
|
109
|
+
renderer as unknown as {
|
|
110
|
+
root: { add: (node: unknown) => void };
|
|
111
|
+
}
|
|
112
|
+
).root;
|
|
113
|
+
root.add(new ToasterRenderable(renderer));
|
|
114
|
+
} catch (err) {
|
|
115
|
+
await tuiDevLog.warn("startup", "Toaster mount failed — toasts may no-op", {
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
// Mouse drag-select → clipboard (OSC 52 + system tools)
|
|
106
121
|
enableAutoCopyOnSelection(
|
|
107
122
|
renderer as unknown as import("./services/clipboard").ClipboardRenderer
|