@davstack/tui 0.2.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 +66 -0
- package/bin/davstack.mjs +45 -0
- package/package.json +33 -0
- package/src/App.test.tsx +92 -0
- package/src/App.tsx +103 -0
- package/src/cli.ts +62 -0
- package/src/commands/check.test.ts +160 -0
- package/src/commands/check.ts +112 -0
- package/src/components/BottomBar.tsx +28 -0
- package/src/components/DaemonSupervisor.tsx +50 -0
- package/src/components/DescriptorSync.tsx +19 -0
- package/src/components/GlobalHotkeys.tsx +30 -0
- package/src/components/MainView.tsx +75 -0
- package/src/components/QuitConfirm.tsx +20 -0
- package/src/components/QuitController.tsx +69 -0
- package/src/components/StatusBar.test.tsx +47 -0
- package/src/components/StatusBar.tsx +67 -0
- package/src/hooks/useConfigDiscovery.ts +56 -0
- package/src/hooks/useDaemonProcess.test.ts +415 -0
- package/src/hooks/useDaemonProcess.ts +275 -0
- package/src/hooks/useHotkeys.test.tsx +508 -0
- package/src/hooks/useHotkeys.ts +164 -0
- package/src/hooks/useNoColor.test.ts +50 -0
- package/src/hooks/useNoColor.ts +35 -0
- package/src/hooks/useRingBuffer.test.tsx +86 -0
- package/src/hooks/useRingBuffer.ts +46 -0
- package/src/lib/config-discovery.test.ts +77 -0
- package/src/lib/config-discovery.ts +57 -0
- package/src/lib/daemon-registry.ts +143 -0
- package/src/lib/global-teardown.test.ts +75 -0
- package/src/lib/global-teardown.ts +78 -0
- package/src/lib/kill-tree.test.ts +69 -0
- package/src/lib/kill-tree.ts +31 -0
- package/src/lib/package-info.ts +35 -0
- package/src/lib/port-owner.test.ts +105 -0
- package/src/lib/port-owner.ts +90 -0
- package/src/lib/port-probe.test.ts +41 -0
- package/src/lib/port-probe.ts +29 -0
- package/src/lib/repo-root.test.ts +36 -0
- package/src/lib/repo-root.ts +30 -0
- package/src/lib/ring-buffer.test.ts +63 -0
- package/src/lib/ring-buffer.ts +47 -0
- package/src/state/daemons-context.tsx +149 -0
- package/src/state/quit-context.tsx +27 -0
- package/src/state/view-context.tsx +32 -0
- package/src/views/ServerList.test.tsx +167 -0
- package/src/views/ServerList.tsx +109 -0
- package/src/views/ServerLogView.tsx +79 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// Unit tests for the hotkey dispatcher. We mount the contexts (View +
|
|
2
|
+
// Daemons) and a small capture component that grabs the hook's return
|
|
3
|
+
// value so tests can call handlers directly. This is the seam where
|
|
4
|
+
// keystrokes arrive from <GlobalHotkeys>, so testing here covers the
|
|
5
|
+
// production routing without depending on Ink's raw-mode plumbing.
|
|
6
|
+
|
|
7
|
+
import React from "react"
|
|
8
|
+
import { afterEach, expect, test, vi } from "vitest"
|
|
9
|
+
import { render } from "ink-testing-library"
|
|
10
|
+
|
|
11
|
+
import { ViewProvider, useView, type View } from "../state/view-context.tsx"
|
|
12
|
+
import { DaemonsProvider, useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
|
|
13
|
+
import { QuitProvider, useQuit } from "../state/quit-context.tsx"
|
|
14
|
+
import { useHotkeys, type HotkeyHandlers } from "./useHotkeys.ts"
|
|
15
|
+
import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
|
|
16
|
+
|
|
17
|
+
function makeDescriptor(key: "logs" | "vitest" | "playwright"): DaemonDescriptor {
|
|
18
|
+
return {
|
|
19
|
+
key,
|
|
20
|
+
label: key,
|
|
21
|
+
port: 1000,
|
|
22
|
+
readyRegex: /listening/i,
|
|
23
|
+
spawn: () => {
|
|
24
|
+
throw new Error("not spawned in this test")
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CapturedApis {
|
|
30
|
+
hotkeys: HotkeyHandlers
|
|
31
|
+
view: View
|
|
32
|
+
setFocusedIdx: (n: number) => void
|
|
33
|
+
registerRow: (row: DaemonRow) => void
|
|
34
|
+
registerControls: ReturnType<typeof useDaemons>["registerControls"]
|
|
35
|
+
quitConfirming: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Capture({ onUpdate, onQuit }: {
|
|
39
|
+
onUpdate: (api: CapturedApis) => void
|
|
40
|
+
onQuit: () => void
|
|
41
|
+
}): null {
|
|
42
|
+
const hotkeys = useHotkeys(onQuit)
|
|
43
|
+
const v = useView()
|
|
44
|
+
const d = useDaemons()
|
|
45
|
+
const q = useQuit()
|
|
46
|
+
// Publish during render so tests can synchronously read the latest
|
|
47
|
+
// context values + handlers after render(...). Safe here — onUpdate
|
|
48
|
+
// is a setter into a closure, not a React state update.
|
|
49
|
+
onUpdate({
|
|
50
|
+
hotkeys,
|
|
51
|
+
view: v.view,
|
|
52
|
+
setFocusedIdx: v.setFocusedIdx,
|
|
53
|
+
registerRow: d.registerRow,
|
|
54
|
+
registerControls: d.registerControls,
|
|
55
|
+
quitConfirming: q.confirming,
|
|
56
|
+
})
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function tick(): Promise<void> {
|
|
61
|
+
// Let React flush queued setState/effects between assertions.
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderWithProviders(descriptors: DaemonDescriptor[], onQuit: () => void) {
|
|
66
|
+
let captured: CapturedApis | null = null
|
|
67
|
+
const r = render(
|
|
68
|
+
<ViewProvider>
|
|
69
|
+
<DaemonsProvider descriptors={descriptors}>
|
|
70
|
+
<QuitProvider>
|
|
71
|
+
<Capture onQuit={onQuit} onUpdate={(api) => (captured = api)} />
|
|
72
|
+
</QuitProvider>
|
|
73
|
+
</DaemonsProvider>
|
|
74
|
+
</ViewProvider>,
|
|
75
|
+
)
|
|
76
|
+
if (!captured) throw new Error("Capture never published")
|
|
77
|
+
return { r, get: (): CapturedApis => captured! }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let unmount: (() => void) | null = null
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
unmount?.()
|
|
83
|
+
unmount = null
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("pressing `1` from list view jumps into daemon 1's log view", async () => {
|
|
87
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
88
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
89
|
+
unmount = () => r.unmount()
|
|
90
|
+
|
|
91
|
+
// Seed rows so toggleByKey + numberKey see populated rowsRef.
|
|
92
|
+
get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
|
|
93
|
+
get().registerRow({ descriptor: descriptors[1], status: "idle", lines: [], exitCode: null })
|
|
94
|
+
get().registerRow({ descriptor: descriptors[2], status: "idle", lines: [], exitCode: null })
|
|
95
|
+
await tick()
|
|
96
|
+
|
|
97
|
+
get().hotkeys.handle("1", {})
|
|
98
|
+
await tick()
|
|
99
|
+
expect(get().view).toEqual({ kind: "log", key: "logs" })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("pressing `2` from inside log view jumps to daemon 2", async () => {
|
|
103
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
104
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
105
|
+
unmount = () => r.unmount()
|
|
106
|
+
|
|
107
|
+
for (const d of descriptors) {
|
|
108
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
109
|
+
}
|
|
110
|
+
await tick()
|
|
111
|
+
|
|
112
|
+
get().hotkeys.handle("1", {})
|
|
113
|
+
await tick()
|
|
114
|
+
expect(get().view).toEqual({ kind: "log", key: "logs" })
|
|
115
|
+
|
|
116
|
+
get().hotkeys.handle("2", {})
|
|
117
|
+
await tick()
|
|
118
|
+
expect(get().view).toEqual({ kind: "log", key: "vitest" })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("escape from log view returns to list view", async () => {
|
|
122
|
+
const descriptors = [makeDescriptor("logs")]
|
|
123
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
124
|
+
unmount = () => r.unmount()
|
|
125
|
+
|
|
126
|
+
get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
|
|
127
|
+
await tick()
|
|
128
|
+
get().hotkeys.handle("1", {})
|
|
129
|
+
await tick()
|
|
130
|
+
expect(get().view.kind).toBe("log")
|
|
131
|
+
|
|
132
|
+
get().hotkeys.handle("", { escape: true })
|
|
133
|
+
await tick()
|
|
134
|
+
expect(get().view.kind).toBe("list")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("escape from list view is a no-op", async () => {
|
|
138
|
+
const descriptors = [makeDescriptor("logs")]
|
|
139
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
140
|
+
unmount = () => r.unmount()
|
|
141
|
+
|
|
142
|
+
get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
|
|
143
|
+
await tick()
|
|
144
|
+
expect(get().view.kind).toBe("list")
|
|
145
|
+
get().hotkeys.handle("", { escape: true })
|
|
146
|
+
await tick()
|
|
147
|
+
expect(get().view.kind).toBe("list")
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("out-of-range number keys are no-ops", async () => {
|
|
151
|
+
const descriptors = [makeDescriptor("logs")]
|
|
152
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
153
|
+
unmount = () => r.unmount()
|
|
154
|
+
|
|
155
|
+
get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
|
|
156
|
+
await tick()
|
|
157
|
+
get().hotkeys.handle("5", {})
|
|
158
|
+
await tick()
|
|
159
|
+
expect(get().view.kind).toBe("list")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("`q` invokes the quit handler", () => {
|
|
163
|
+
const descriptors = [makeDescriptor("logs")]
|
|
164
|
+
const onQuit = vi.fn()
|
|
165
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
166
|
+
unmount = () => r.unmount()
|
|
167
|
+
|
|
168
|
+
get().hotkeys.handle("q", {})
|
|
169
|
+
expect(onQuit).toHaveBeenCalledTimes(1)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("ctrl-c also invokes the quit handler", () => {
|
|
173
|
+
const descriptors = [makeDescriptor("logs")]
|
|
174
|
+
const onQuit = vi.fn()
|
|
175
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
176
|
+
unmount = () => r.unmount()
|
|
177
|
+
|
|
178
|
+
get().hotkeys.handle("c", { ctrl: true })
|
|
179
|
+
expect(onQuit).toHaveBeenCalledTimes(1)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("onToggleFocused calls stop on a running focused daemon", async () => {
|
|
183
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
|
|
184
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
185
|
+
unmount = () => r.unmount()
|
|
186
|
+
|
|
187
|
+
const start = vi.fn()
|
|
188
|
+
const stop = vi.fn()
|
|
189
|
+
get().registerControls("logs", { start, stop })
|
|
190
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
191
|
+
await tick()
|
|
192
|
+
// focusedIdx defaults to 0 -> logs.
|
|
193
|
+
|
|
194
|
+
get().hotkeys.onToggleFocused()
|
|
195
|
+
expect(stop).toHaveBeenCalledTimes(1)
|
|
196
|
+
expect(start).not.toHaveBeenCalled()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("onToggleFocused calls start on an idle focused daemon", async () => {
|
|
200
|
+
const descriptors = [makeDescriptor("logs")]
|
|
201
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
202
|
+
unmount = () => r.unmount()
|
|
203
|
+
|
|
204
|
+
const start = vi.fn()
|
|
205
|
+
const stop = vi.fn()
|
|
206
|
+
get().registerControls("logs", { start, stop })
|
|
207
|
+
get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
|
|
208
|
+
await tick()
|
|
209
|
+
|
|
210
|
+
get().hotkeys.onToggleFocused()
|
|
211
|
+
expect(start).toHaveBeenCalledTimes(1)
|
|
212
|
+
expect(stop).not.toHaveBeenCalled()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("`q` with a running daemon enters the confirm-quit state instead of quitting", async () => {
|
|
216
|
+
const descriptors = [makeDescriptor("logs")]
|
|
217
|
+
const onQuit = vi.fn()
|
|
218
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
219
|
+
unmount = () => r.unmount()
|
|
220
|
+
|
|
221
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
222
|
+
await tick()
|
|
223
|
+
|
|
224
|
+
get().hotkeys.handle("q", {})
|
|
225
|
+
await tick()
|
|
226
|
+
|
|
227
|
+
expect(onQuit).not.toHaveBeenCalled()
|
|
228
|
+
expect(get().quitConfirming).toBe(true)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test("pressing `y` while confirming triggers cascade shutdown", async () => {
|
|
232
|
+
const descriptors = [makeDescriptor("logs")]
|
|
233
|
+
const onQuit = vi.fn()
|
|
234
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
235
|
+
unmount = () => r.unmount()
|
|
236
|
+
|
|
237
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
238
|
+
await tick()
|
|
239
|
+
|
|
240
|
+
get().hotkeys.handle("q", {})
|
|
241
|
+
await tick()
|
|
242
|
+
expect(get().quitConfirming).toBe(true)
|
|
243
|
+
|
|
244
|
+
get().hotkeys.handle("y", {})
|
|
245
|
+
await tick()
|
|
246
|
+
expect(onQuit).toHaveBeenCalledTimes(1)
|
|
247
|
+
expect(get().quitConfirming).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test("pressing `n` while confirming cancels back to the prior view", async () => {
|
|
251
|
+
const descriptors = [makeDescriptor("logs")]
|
|
252
|
+
const onQuit = vi.fn()
|
|
253
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
254
|
+
unmount = () => r.unmount()
|
|
255
|
+
|
|
256
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
257
|
+
await tick()
|
|
258
|
+
|
|
259
|
+
get().hotkeys.handle("q", {})
|
|
260
|
+
await tick()
|
|
261
|
+
expect(get().quitConfirming).toBe(true)
|
|
262
|
+
|
|
263
|
+
get().hotkeys.handle("n", {})
|
|
264
|
+
await tick()
|
|
265
|
+
expect(get().quitConfirming).toBe(false)
|
|
266
|
+
expect(onQuit).not.toHaveBeenCalled()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test("pressing `esc` while confirming cancels", async () => {
|
|
270
|
+
const descriptors = [makeDescriptor("logs")]
|
|
271
|
+
const onQuit = vi.fn()
|
|
272
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
273
|
+
unmount = () => r.unmount()
|
|
274
|
+
|
|
275
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
276
|
+
await tick()
|
|
277
|
+
get().hotkeys.handle("q", {})
|
|
278
|
+
await tick()
|
|
279
|
+
|
|
280
|
+
get().hotkeys.handle("", { escape: true })
|
|
281
|
+
await tick()
|
|
282
|
+
expect(get().quitConfirming).toBe(false)
|
|
283
|
+
expect(onQuit).not.toHaveBeenCalled()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test("while confirming, number keys are swallowed", async () => {
|
|
287
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest")]
|
|
288
|
+
const onQuit = vi.fn()
|
|
289
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
290
|
+
unmount = () => r.unmount()
|
|
291
|
+
|
|
292
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
293
|
+
get().registerRow({ descriptor: descriptors[1], status: "idle", lines: [], exitCode: null })
|
|
294
|
+
await tick()
|
|
295
|
+
get().hotkeys.handle("q", {})
|
|
296
|
+
await tick()
|
|
297
|
+
expect(get().quitConfirming).toBe(true)
|
|
298
|
+
|
|
299
|
+
// Number key while confirming should NOT navigate.
|
|
300
|
+
get().hotkeys.handle("2", {})
|
|
301
|
+
await tick()
|
|
302
|
+
expect(get().view.kind).toBe("list")
|
|
303
|
+
expect(get().quitConfirming).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test("`q` with no running daemons quits immediately (no confirm)", async () => {
|
|
307
|
+
const descriptors = [makeDescriptor("logs")]
|
|
308
|
+
const onQuit = vi.fn()
|
|
309
|
+
const { r, get } = renderWithProviders(descriptors, onQuit)
|
|
310
|
+
unmount = () => r.unmount()
|
|
311
|
+
|
|
312
|
+
// All-idle rows.
|
|
313
|
+
get().registerRow({ descriptor: descriptors[0], status: "idle", lines: [], exitCode: null })
|
|
314
|
+
await tick()
|
|
315
|
+
|
|
316
|
+
get().hotkeys.handle("q", {})
|
|
317
|
+
await tick()
|
|
318
|
+
expect(onQuit).toHaveBeenCalledTimes(1)
|
|
319
|
+
expect(get().quitConfirming).toBe(false)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test("`c` in log view clears the focused daemon's ring buffer", async () => {
|
|
323
|
+
const descriptors = [makeDescriptor("logs")]
|
|
324
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
325
|
+
unmount = () => r.unmount()
|
|
326
|
+
|
|
327
|
+
const clear = vi.fn()
|
|
328
|
+
get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), clear })
|
|
329
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
330
|
+
await tick()
|
|
331
|
+
|
|
332
|
+
// Drill into log view first.
|
|
333
|
+
get().hotkeys.handle("1", {})
|
|
334
|
+
await tick()
|
|
335
|
+
expect(get().view.kind).toBe("log")
|
|
336
|
+
|
|
337
|
+
get().hotkeys.handle("c", {})
|
|
338
|
+
expect(clear).toHaveBeenCalledTimes(1)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test("`c` in list view is a no-op (does not clear)", async () => {
|
|
342
|
+
const descriptors = [makeDescriptor("logs")]
|
|
343
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
344
|
+
unmount = () => r.unmount()
|
|
345
|
+
|
|
346
|
+
const clear = vi.fn()
|
|
347
|
+
get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), clear })
|
|
348
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
349
|
+
await tick()
|
|
350
|
+
|
|
351
|
+
get().hotkeys.handle("c", {})
|
|
352
|
+
expect(clear).not.toHaveBeenCalled()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
test("`k` on a blocked focused row calls takeover", async () => {
|
|
356
|
+
const descriptors = [makeDescriptor("logs")]
|
|
357
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
358
|
+
unmount = () => r.unmount()
|
|
359
|
+
|
|
360
|
+
const takeover = vi.fn()
|
|
361
|
+
get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), takeover })
|
|
362
|
+
get().registerRow({ descriptor: descriptors[0], status: "blocked", lines: [], exitCode: null })
|
|
363
|
+
await tick()
|
|
364
|
+
|
|
365
|
+
get().hotkeys.handle("k", {})
|
|
366
|
+
expect(takeover).toHaveBeenCalledTimes(1)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test("`k` on a running focused row is a no-op", async () => {
|
|
370
|
+
const descriptors = [makeDescriptor("logs")]
|
|
371
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
372
|
+
unmount = () => r.unmount()
|
|
373
|
+
|
|
374
|
+
const takeover = vi.fn()
|
|
375
|
+
get().registerControls("logs", { start: vi.fn(), stop: vi.fn(), takeover })
|
|
376
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
377
|
+
await tick()
|
|
378
|
+
|
|
379
|
+
get().hotkeys.handle("k", {})
|
|
380
|
+
expect(takeover).not.toHaveBeenCalled()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test("left arrow from list-view cycles focus backward and wraps from 0 to last", async () => {
|
|
384
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
385
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
386
|
+
unmount = () => r.unmount()
|
|
387
|
+
|
|
388
|
+
for (const d of descriptors) {
|
|
389
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
390
|
+
}
|
|
391
|
+
await tick()
|
|
392
|
+
// focusedIdx starts at 0; left wraps to last (index 2).
|
|
393
|
+
get().hotkeys.handle("", { leftArrow: true })
|
|
394
|
+
await tick()
|
|
395
|
+
// Still in list view; assert via onToggleFocused targeting the focused row.
|
|
396
|
+
expect(get().view.kind).toBe("list")
|
|
397
|
+
const stop = vi.fn()
|
|
398
|
+
get().registerControls("playwright", { start: vi.fn(), stop })
|
|
399
|
+
get().registerRow({ descriptor: descriptors[2], status: "running", lines: [], exitCode: null })
|
|
400
|
+
await tick()
|
|
401
|
+
get().hotkeys.onToggleFocused()
|
|
402
|
+
expect(stop).toHaveBeenCalledTimes(1)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test("right arrow from list-view cycles focus forward and wraps from last to 0", async () => {
|
|
406
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
407
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
408
|
+
unmount = () => r.unmount()
|
|
409
|
+
|
|
410
|
+
for (const d of descriptors) {
|
|
411
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
412
|
+
}
|
|
413
|
+
await tick()
|
|
414
|
+
// Advance focus to the last (index 2): two right presses.
|
|
415
|
+
get().hotkeys.handle("", { rightArrow: true })
|
|
416
|
+
await tick()
|
|
417
|
+
get().hotkeys.handle("", { rightArrow: true })
|
|
418
|
+
await tick()
|
|
419
|
+
// One more right wraps back to index 0.
|
|
420
|
+
get().hotkeys.handle("", { rightArrow: true })
|
|
421
|
+
await tick()
|
|
422
|
+
expect(get().view.kind).toBe("list")
|
|
423
|
+
const stop = vi.fn()
|
|
424
|
+
get().registerControls("logs", { start: vi.fn(), stop })
|
|
425
|
+
get().registerRow({ descriptor: descriptors[0], status: "running", lines: [], exitCode: null })
|
|
426
|
+
await tick()
|
|
427
|
+
get().hotkeys.onToggleFocused()
|
|
428
|
+
expect(stop).toHaveBeenCalledTimes(1)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test("left arrow from log-view cycles the focused log target backward", async () => {
|
|
432
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
433
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
434
|
+
unmount = () => r.unmount()
|
|
435
|
+
|
|
436
|
+
for (const d of descriptors) {
|
|
437
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
438
|
+
}
|
|
439
|
+
await tick()
|
|
440
|
+
// Drill into log view on daemon 2 (index 1).
|
|
441
|
+
get().hotkeys.handle("2", {})
|
|
442
|
+
await tick()
|
|
443
|
+
expect(get().view).toEqual({ kind: "log", key: "vitest" })
|
|
444
|
+
|
|
445
|
+
get().hotkeys.handle("", { leftArrow: true })
|
|
446
|
+
await tick()
|
|
447
|
+
expect(get().view).toEqual({ kind: "log", key: "logs" })
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test("right arrow from log-view cycles the focused log target forward", async () => {
|
|
451
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
452
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
453
|
+
unmount = () => r.unmount()
|
|
454
|
+
|
|
455
|
+
for (const d of descriptors) {
|
|
456
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
457
|
+
}
|
|
458
|
+
await tick()
|
|
459
|
+
get().hotkeys.handle("1", {})
|
|
460
|
+
await tick()
|
|
461
|
+
expect(get().view).toEqual({ kind: "log", key: "logs" })
|
|
462
|
+
|
|
463
|
+
get().hotkeys.handle("", { rightArrow: true })
|
|
464
|
+
await tick()
|
|
465
|
+
expect(get().view).toEqual({ kind: "log", key: "vitest" })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
test("tab from list-view cycles focus forward like right arrow", async () => {
|
|
469
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
470
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
471
|
+
unmount = () => r.unmount()
|
|
472
|
+
|
|
473
|
+
for (const d of descriptors) {
|
|
474
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
475
|
+
}
|
|
476
|
+
await tick()
|
|
477
|
+
// From focusedIdx 0, one tab -> idx 1 (vitest).
|
|
478
|
+
get().hotkeys.handle("", { tab: true })
|
|
479
|
+
await tick()
|
|
480
|
+
expect(get().view.kind).toBe("list")
|
|
481
|
+
const stop = vi.fn()
|
|
482
|
+
get().registerControls("vitest", { start: vi.fn(), stop })
|
|
483
|
+
get().registerRow({ descriptor: descriptors[1], status: "running", lines: [], exitCode: null })
|
|
484
|
+
await tick()
|
|
485
|
+
get().hotkeys.onToggleFocused()
|
|
486
|
+
expect(stop).toHaveBeenCalledTimes(1)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test("shift+tab from list-view cycles focus backward like left arrow", async () => {
|
|
490
|
+
const descriptors = [makeDescriptor("logs"), makeDescriptor("vitest"), makeDescriptor("playwright")]
|
|
491
|
+
const { r, get } = renderWithProviders(descriptors, () => {})
|
|
492
|
+
unmount = () => r.unmount()
|
|
493
|
+
|
|
494
|
+
for (const d of descriptors) {
|
|
495
|
+
get().registerRow({ descriptor: d, status: "idle", lines: [], exitCode: null })
|
|
496
|
+
}
|
|
497
|
+
await tick()
|
|
498
|
+
// From focusedIdx 0, shift+tab wraps backward -> idx 2 (playwright).
|
|
499
|
+
get().hotkeys.handle("", { tab: true, shift: true })
|
|
500
|
+
await tick()
|
|
501
|
+
expect(get().view.kind).toBe("list")
|
|
502
|
+
const stop = vi.fn()
|
|
503
|
+
get().registerControls("playwright", { start: vi.fn(), stop })
|
|
504
|
+
get().registerRow({ descriptor: descriptors[2], status: "running", lines: [], exitCode: null })
|
|
505
|
+
await tick()
|
|
506
|
+
get().hotkeys.onToggleFocused()
|
|
507
|
+
expect(stop).toHaveBeenCalledTimes(1)
|
|
508
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Pure hotkey dispatcher. Takes a raw key/input shape and routes it
|
|
2
|
+
// through the view + daemons + quit contexts. Exposed as a hook so tests
|
|
3
|
+
// can invoke handlers directly without piping bytes through Ink's
|
|
4
|
+
// raw-mode machinery.
|
|
5
|
+
|
|
6
|
+
import { useCallback } from "react"
|
|
7
|
+
|
|
8
|
+
import { useView } from "../state/view-context.tsx"
|
|
9
|
+
import { useDaemons } from "../state/daemons-context.tsx"
|
|
10
|
+
import { useQuit } from "../state/quit-context.tsx"
|
|
11
|
+
|
|
12
|
+
export interface KeyEvent {
|
|
13
|
+
ctrl?: boolean
|
|
14
|
+
escape?: boolean
|
|
15
|
+
leftArrow?: boolean
|
|
16
|
+
rightArrow?: boolean
|
|
17
|
+
tab?: boolean
|
|
18
|
+
shift?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HotkeyHandlers {
|
|
22
|
+
onQuit: () => void
|
|
23
|
+
onNumberKey: (idx: number) => void
|
|
24
|
+
onEscape: () => void
|
|
25
|
+
// List-view-only: handle `s` toggle on the focused row.
|
|
26
|
+
onToggleFocused: () => void
|
|
27
|
+
// List-view-only: kill external port owner and re-spawn when blocked.
|
|
28
|
+
onTakeoverFocused: () => void
|
|
29
|
+
// Log-view-only: clear the currently-viewed daemon's ring buffer.
|
|
30
|
+
onClearLog: () => void
|
|
31
|
+
// Cycle the focused daemon by +1 / -1 with wrap-around. In log view this
|
|
32
|
+
// also swaps the displayed daemon; in list view it just moves focus.
|
|
33
|
+
onCycleFocus: (offset: 1 | -1) => void
|
|
34
|
+
// Master dispatcher used by ink's useInput.
|
|
35
|
+
handle: (input: string, key: KeyEvent) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `quit` is the cascade-shutdown trigger from QuitController. We invoke
|
|
39
|
+
// it directly when no daemons are live; otherwise we route through the
|
|
40
|
+
// confirm overlay (requestConfirm).
|
|
41
|
+
export function useHotkeys(quit: () => void): HotkeyHandlers {
|
|
42
|
+
const { showLog, showList, setFocusedIdx, view, focusedIdx } = useView()
|
|
43
|
+
const { rowsRef, toggleByKey, clearByKey, takeoverByKey, anyLive } = useDaemons()
|
|
44
|
+
const { confirming, requestConfirm, cancelConfirm } = useQuit()
|
|
45
|
+
|
|
46
|
+
const onQuit = useCallback(() => {
|
|
47
|
+
if (anyLive()) {
|
|
48
|
+
requestConfirm()
|
|
49
|
+
} else {
|
|
50
|
+
quit()
|
|
51
|
+
}
|
|
52
|
+
}, [quit, anyLive, requestConfirm])
|
|
53
|
+
|
|
54
|
+
const onNumberKey = useCallback(
|
|
55
|
+
(idx: number) => {
|
|
56
|
+
const target = rowsRef.current[idx]
|
|
57
|
+
if (!target) return
|
|
58
|
+
setFocusedIdx(idx)
|
|
59
|
+
showLog(target.descriptor.key)
|
|
60
|
+
},
|
|
61
|
+
[rowsRef, setFocusedIdx, showLog],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const onEscape = useCallback(() => {
|
|
65
|
+
if (view.kind === "log") showList()
|
|
66
|
+
}, [view, showList])
|
|
67
|
+
|
|
68
|
+
const onToggleFocused = useCallback(() => {
|
|
69
|
+
const target = rowsRef.current[focusedIdx]
|
|
70
|
+
if (!target) return
|
|
71
|
+
toggleByKey(target.descriptor.key)
|
|
72
|
+
}, [rowsRef, focusedIdx, toggleByKey])
|
|
73
|
+
|
|
74
|
+
const onTakeoverFocused = useCallback(() => {
|
|
75
|
+
const target = rowsRef.current[focusedIdx]
|
|
76
|
+
if (!target) return
|
|
77
|
+
takeoverByKey(target.descriptor.key)
|
|
78
|
+
}, [rowsRef, focusedIdx, takeoverByKey])
|
|
79
|
+
|
|
80
|
+
const onClearLog = useCallback(() => {
|
|
81
|
+
if (view.kind !== "log") return
|
|
82
|
+
clearByKey(view.key)
|
|
83
|
+
}, [view, clearByKey])
|
|
84
|
+
|
|
85
|
+
const onCycleFocus = useCallback(
|
|
86
|
+
(offset: 1 | -1) => {
|
|
87
|
+
const n = rowsRef.current.length
|
|
88
|
+
if (n === 0) return
|
|
89
|
+
const nextIdx = (focusedIdx + offset + n) % n
|
|
90
|
+
setFocusedIdx(nextIdx)
|
|
91
|
+
if (view.kind === "log") {
|
|
92
|
+
const target = rowsRef.current[nextIdx]
|
|
93
|
+
if (target) showLog(target.descriptor.key)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[rowsRef, focusedIdx, setFocusedIdx, view, showLog],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const handle = useCallback(
|
|
100
|
+
(input: string, key: KeyEvent) => {
|
|
101
|
+
// Confirm overlay swallows everything except y / n / esc.
|
|
102
|
+
if (confirming) {
|
|
103
|
+
if (input === "y") {
|
|
104
|
+
cancelConfirm()
|
|
105
|
+
quit()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (input === "n" || key.escape) {
|
|
109
|
+
cancelConfirm()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (input === "q") {
|
|
115
|
+
onQuit()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (key.ctrl && input === "c") {
|
|
119
|
+
onQuit()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (input === "c" && view.kind === "log") {
|
|
123
|
+
onClearLog()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (input === "k" && view.kind === "list") {
|
|
127
|
+
onTakeoverFocused()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (/^[1-9]$/.test(input)) {
|
|
131
|
+
onNumberKey(Number(input) - 1)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (key.leftArrow) {
|
|
135
|
+
onCycleFocus(-1)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (key.rightArrow) {
|
|
139
|
+
onCycleFocus(1)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (key.tab) {
|
|
143
|
+
onCycleFocus(key.shift ? -1 : 1)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (key.escape) {
|
|
147
|
+
onEscape()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
[confirming, cancelConfirm, quit, onQuit, onNumberKey, onEscape, onClearLog, onTakeoverFocused, onCycleFocus, view],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
onQuit,
|
|
156
|
+
onNumberKey,
|
|
157
|
+
onEscape,
|
|
158
|
+
onToggleFocused,
|
|
159
|
+
onTakeoverFocused,
|
|
160
|
+
onClearLog,
|
|
161
|
+
onCycleFocus,
|
|
162
|
+
handle,
|
|
163
|
+
}
|
|
164
|
+
}
|