@dimina-kit/inspect 0.3.0-dev.20260711141929 → 0.4.0-dev.20260716153350

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.
@@ -0,0 +1,500 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
+ import { ConnectedCompilePanel } from './connected-compile-panel.js'
4
+ import type { CompileFeedEvent, CompileFeedSnapshot, CompilePanelSource } from './compile-source.js'
5
+ import type { CompileEvent, CompileLogEntry } from './compile-types.js'
6
+
7
+ const EMPTY_SNAPSHOT: CompileFeedSnapshot = { events: [], logs: [] }
8
+
9
+ function evt(overrides: Partial<CompileEvent> = {}): CompileEvent {
10
+ return { at: 1000, status: 'compiling', message: 'building', ...overrides }
11
+ }
12
+ function logEntry(overrides: Partial<CompileLogEntry> = {}): CompileLogEntry {
13
+ return { at: 1000, stream: 'stdout', text: 'line', ...overrides }
14
+ }
15
+
16
+ function createDeferred<T>() {
17
+ let resolve!: (value: T) => void
18
+ const promise = new Promise<T>((res) => {
19
+ resolve = res
20
+ })
21
+ return { promise, resolve }
22
+ }
23
+
24
+ /**
25
+ * A programmable CompilePanelSource: getSnapshot() consumes one queued
26
+ * promise per call (defaulting to an empty feed), subscribe() records the
27
+ * latest push callback so tests can simulate live CompileFeedEvents, and
28
+ * every method is a spy. `clear` is intentionally omitted by default — tests
29
+ * that need it attach it explicitly to exercise the optional-capability
30
+ * contract.
31
+ */
32
+ function createFakeSource() {
33
+ const unsubscribe = vi.fn()
34
+ const getSnapshotQueue: Array<Promise<CompileFeedSnapshot>> = []
35
+ let latestOnEvent: ((evt: CompileFeedEvent) => void) | null = null
36
+ const source: CompilePanelSource = {
37
+ getSnapshot: vi.fn(() => getSnapshotQueue.shift() ?? Promise.resolve(EMPTY_SNAPSHOT)),
38
+ subscribe: vi.fn((onEvent: (evt: CompileFeedEvent) => void) => {
39
+ latestOnEvent = onEvent
40
+ return unsubscribe
41
+ }),
42
+ setActive: vi.fn(),
43
+ }
44
+ return {
45
+ source,
46
+ unsubscribe,
47
+ getSnapshotQueue,
48
+ pushFeed: (feedEvent: CompileFeedEvent) => latestOnEvent?.(feedEvent),
49
+ }
50
+ }
51
+
52
+ /** Flushes the microtask queue inside `act` so promise-driven setState calls settle. */
53
+ async function flush() {
54
+ await act(async () => {
55
+ await Promise.resolve()
56
+ await Promise.resolve()
57
+ })
58
+ }
59
+
60
+ function rowsOf(): HTMLElement[] {
61
+ return Array.from(document.querySelectorAll<HTMLElement>('[data-compile-row]'))
62
+ }
63
+ function logsOf(): HTMLElement[] {
64
+ return Array.from(document.querySelectorAll<HTMLElement>('[data-compile-log]'))
65
+ }
66
+ function timelineOf(): HTMLElement[] {
67
+ return Array.from(document.querySelectorAll<HTMLElement>('[data-compile-row], [data-compile-log]'))
68
+ }
69
+
70
+ afterEach(() => {
71
+ cleanup()
72
+ vi.unstubAllGlobals()
73
+ })
74
+
75
+ describe('ConnectedCompilePanel: seeding', () => {
76
+ it('seeds via getSnapshot exactly once when enabled and active are true from mount', async () => {
77
+ const { source, getSnapshotQueue } = createFakeSource()
78
+ getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'first build' })], logs: [] }))
79
+ render(<ConnectedCompilePanel source={source} />)
80
+ await flush()
81
+
82
+ expect(source.getSnapshot).toHaveBeenCalledTimes(1)
83
+ expect(rowsOf().some((r) => r.textContent?.includes('first build'))).toBe(true)
84
+ })
85
+
86
+ it('seeds only on the active rising edge, and re-seeds on every subsequent rising edge', async () => {
87
+ const { source, getSnapshotQueue } = createFakeSource()
88
+ getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'A' })], logs: [] }))
89
+ const { rerender } = render(<ConnectedCompilePanel source={source} active={false} />)
90
+ await flush()
91
+ expect(source.getSnapshot).not.toHaveBeenCalled()
92
+
93
+ rerender(<ConnectedCompilePanel source={source} active />)
94
+ await flush()
95
+ expect(source.getSnapshot).toHaveBeenCalledTimes(1)
96
+ expect(rowsOf().some((r) => r.textContent?.includes('A'))).toBe(true)
97
+
98
+ rerender(<ConnectedCompilePanel source={source} active={false} />)
99
+ await flush()
100
+ expect(source.getSnapshot).toHaveBeenCalledTimes(1)
101
+
102
+ getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'B' })], logs: [] }))
103
+ rerender(<ConnectedCompilePanel source={source} active />)
104
+ await flush()
105
+ expect(source.getSnapshot).toHaveBeenCalledTimes(2)
106
+ expect(rowsOf().some((r) => r.textContent?.includes('B'))).toBe(true)
107
+ })
108
+
109
+ it('makes zero source calls while disabled', async () => {
110
+ const { source } = createFakeSource()
111
+ render(<ConnectedCompilePanel source={source} enabled={false} />)
112
+ await flush()
113
+
114
+ expect(source.getSnapshot).not.toHaveBeenCalled()
115
+ expect(source.subscribe).not.toHaveBeenCalled()
116
+ expect(source.setActive).not.toHaveBeenCalled()
117
+ })
118
+
119
+ it('replaces (not merges) the rendered lists on a fresh seed', async () => {
120
+ const { source, getSnapshotQueue } = createFakeSource()
121
+ getSnapshotQueue.push(Promise.resolve({
122
+ events: [evt({ message: 'old event' })],
123
+ logs: [logEntry({ text: 'old log' })],
124
+ }))
125
+ const { rerender } = render(<ConnectedCompilePanel source={source} active />)
126
+ await flush()
127
+ expect(rowsOf().some((r) => r.textContent?.includes('old event'))).toBe(true)
128
+
129
+ rerender(<ConnectedCompilePanel source={source} active={false} />)
130
+ await flush()
131
+ getSnapshotQueue.push(Promise.resolve({
132
+ events: [evt({ message: 'new event' })],
133
+ logs: [logEntry({ text: 'new log' })],
134
+ }))
135
+ rerender(<ConnectedCompilePanel source={source} active />)
136
+ await flush()
137
+
138
+ expect(rowsOf().some((r) => r.textContent?.includes('old event'))).toBe(false)
139
+ expect(logsOf().some((r) => r.textContent?.includes('old log'))).toBe(false)
140
+ expect(rowsOf().some((r) => r.textContent?.includes('new event'))).toBe(true)
141
+ expect(logsOf().some((r) => r.textContent?.includes('new log'))).toBe(true)
142
+ })
143
+ })
144
+
145
+ describe('ConnectedCompilePanel: stale resolutions', () => {
146
+ it('drops a late getSnapshot resolution after unmount without throwing', async () => {
147
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
148
+ const { source, getSnapshotQueue } = createFakeSource()
149
+ const deferred = createDeferred<CompileFeedSnapshot>()
150
+ getSnapshotQueue.push(deferred.promise)
151
+ const { unmount } = render(<ConnectedCompilePanel source={source} />)
152
+
153
+ unmount()
154
+
155
+ await act(async () => {
156
+ deferred.resolve({ events: [evt({ message: 'late' })], logs: [] })
157
+ await Promise.resolve()
158
+ await Promise.resolve()
159
+ })
160
+
161
+ expect(errorSpy).not.toHaveBeenCalled()
162
+ errorSpy.mockRestore()
163
+ })
164
+
165
+ it('drops a late getSnapshot resolution after a source swap without rendering stale data', async () => {
166
+ const first = createFakeSource()
167
+ const deferred = createDeferred<CompileFeedSnapshot>()
168
+ first.getSnapshotQueue.push(deferred.promise)
169
+ const { rerender } = render(<ConnectedCompilePanel source={first.source} />)
170
+ await flush()
171
+
172
+ const second = createFakeSource()
173
+ second.getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'fresh' })], logs: [] }))
174
+ rerender(<ConnectedCompilePanel source={second.source} />)
175
+ await flush()
176
+ expect(rowsOf().some((r) => r.textContent?.includes('fresh'))).toBe(true)
177
+
178
+ await act(async () => {
179
+ deferred.resolve({ events: [evt({ message: 'stale' })], logs: [] })
180
+ await Promise.resolve()
181
+ await Promise.resolve()
182
+ })
183
+
184
+ expect(rowsOf().some((r) => r.textContent?.includes('stale'))).toBe(false)
185
+ })
186
+ })
187
+
188
+ describe('ConnectedCompilePanel: visibility and lifecycle', () => {
189
+ it('forwards active prop changes to source.setActive in both directions', async () => {
190
+ const { source, getSnapshotQueue } = createFakeSource()
191
+ getSnapshotQueue.push(Promise.resolve(EMPTY_SNAPSHOT))
192
+ const { rerender } = render(<ConnectedCompilePanel source={source} active />)
193
+ await flush()
194
+
195
+ rerender(<ConnectedCompilePanel source={source} active={false} />)
196
+ await flush()
197
+ expect(source.setActive).toHaveBeenLastCalledWith(false)
198
+
199
+ rerender(<ConnectedCompilePanel source={source} active />)
200
+ await flush()
201
+ expect(source.setActive).toHaveBeenLastCalledWith(true)
202
+ })
203
+
204
+ it('calls setActive(false) and unsubscribes on unmount', async () => {
205
+ const { source, unsubscribe, getSnapshotQueue } = createFakeSource()
206
+ getSnapshotQueue.push(Promise.resolve(EMPTY_SNAPSHOT))
207
+ const { unmount } = render(<ConnectedCompilePanel source={source} />)
208
+ await flush()
209
+
210
+ unmount()
211
+
212
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
213
+ expect(source.setActive).toHaveBeenLastCalledWith(false)
214
+ })
215
+
216
+ it('tears down the old source and reseeds/resubscribes the new one on a source swap', async () => {
217
+ const first = createFakeSource()
218
+ first.getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'A' })], logs: [] }))
219
+ const { rerender } = render(<ConnectedCompilePanel source={first.source} />)
220
+ await flush()
221
+
222
+ const second = createFakeSource()
223
+ second.getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'B' })], logs: [] }))
224
+ rerender(<ConnectedCompilePanel source={second.source} />)
225
+ await flush()
226
+
227
+ expect(first.unsubscribe).toHaveBeenCalledTimes(1)
228
+ expect(first.source.setActive).toHaveBeenLastCalledWith(false)
229
+ expect(second.source.getSnapshot).toHaveBeenCalledTimes(1)
230
+ expect(second.source.subscribe).toHaveBeenCalledTimes(1)
231
+ expect(rowsOf().some((r) => r.textContent?.includes('B'))).toBe(true)
232
+ })
233
+ })
234
+
235
+ describe('ConnectedCompilePanel: live push merge', () => {
236
+ it('appends a pushed event to the event list alongside the seeded ones', async () => {
237
+ const { source, getSnapshotQueue, pushFeed } = createFakeSource()
238
+ getSnapshotQueue.push(Promise.resolve({ events: [evt({ message: 'seeded' })], logs: [] }))
239
+ render(<ConnectedCompilePanel source={source} />)
240
+ await flush()
241
+
242
+ act(() => {
243
+ pushFeed({ kind: 'event', event: evt({ at: 2000, message: 'pushed' }) })
244
+ })
245
+
246
+ expect(rowsOf()).toHaveLength(2)
247
+ expect(rowsOf().some((r) => r.textContent?.includes('pushed'))).toBe(true)
248
+ })
249
+
250
+ it('appends a pushed log to the log list alongside the seeded ones', async () => {
251
+ const { source, getSnapshotQueue, pushFeed } = createFakeSource()
252
+ getSnapshotQueue.push(Promise.resolve({ events: [], logs: [logEntry({ text: 'seeded log' })] }))
253
+ render(<ConnectedCompilePanel source={source} />)
254
+ await flush()
255
+
256
+ act(() => {
257
+ pushFeed({ kind: 'log', log: logEntry({ at: 2000, text: 'pushed log' }) })
258
+ })
259
+
260
+ expect(logsOf()).toHaveLength(2)
261
+ expect(logsOf().some((r) => r.textContent?.includes('pushed log'))).toBe(true)
262
+ })
263
+
264
+ it('clears both lists back to the empty state on a reset push', async () => {
265
+ const { source, getSnapshotQueue, pushFeed } = createFakeSource()
266
+ getSnapshotQueue.push(Promise.resolve({
267
+ events: [evt({ message: 'gone event' })],
268
+ logs: [logEntry({ text: 'gone log' })],
269
+ }))
270
+ render(<ConnectedCompilePanel source={source} />)
271
+ await flush()
272
+ expect(rowsOf()).toHaveLength(1)
273
+
274
+ act(() => {
275
+ pushFeed({ kind: 'reset' })
276
+ })
277
+
278
+ expect(rowsOf()).toHaveLength(0)
279
+ expect(logsOf()).toHaveLength(0)
280
+ expect(screen.getByText('暂无编译信息')).not.toBeNull()
281
+ })
282
+ })
283
+
284
+ describe('ConnectedCompilePanel: FIFO caps', () => {
285
+ it('caps events at the newest 200 and logs at the newest 300, evicting the oldest on overflow', async () => {
286
+ const { source, pushFeed } = createFakeSource()
287
+ render(<ConnectedCompilePanel source={source} />)
288
+ await flush()
289
+ act(() => {
290
+ for (let i = 0; i < 201; i++) pushFeed({ kind: 'event', event: evt({ at: 1000 + i, message: `evt-${i}` }) })
291
+ for (let i = 0; i < 301; i++) pushFeed({ kind: 'log', log: logEntry({ at: 1000 + i, text: `log-${i}` }) })
292
+ })
293
+
294
+ expect(rowsOf()).toHaveLength(200)
295
+ expect(rowsOf().some((r) => r.textContent?.includes('evt-0'))).toBe(false)
296
+ expect(rowsOf().some((r) => r.textContent?.includes('evt-200'))).toBe(true)
297
+ expect(logsOf()).toHaveLength(300)
298
+ expect(logsOf().some((r) => r.textContent?.includes('log-0'))).toBe(false)
299
+ expect(logsOf().some((r) => r.textContent?.includes('log-300'))).toBe(true)
300
+ })
301
+ })
302
+
303
+ describe('ConnectedCompilePanel: seq stamping preserves arrival order in the merged timeline', () => {
304
+ it('renders an event before a same-tick log pushed after it, and vice versa, when neither carries a seq', async () => {
305
+ const forward = createFakeSource()
306
+ const { unmount } = render(<ConnectedCompilePanel source={forward.source} />)
307
+ await flush()
308
+ act(() => {
309
+ forward.pushFeed({ kind: 'event', event: evt({ at: 5000, message: 'tied-event' }) })
310
+ })
311
+ act(() => {
312
+ forward.pushFeed({ kind: 'log', log: logEntry({ at: 5000, text: 'tied-log' }) })
313
+ })
314
+ let texts = timelineOf().map((el) => el.textContent ?? '')
315
+ expect(texts.findIndex((t) => t.includes('tied-event'))).toBeLessThan(
316
+ texts.findIndex((t) => t.includes('tied-log')),
317
+ )
318
+ unmount()
319
+
320
+ const reversed = createFakeSource()
321
+ render(<ConnectedCompilePanel source={reversed.source} />)
322
+ await flush()
323
+ act(() => {
324
+ reversed.pushFeed({ kind: 'log', log: logEntry({ at: 5000, text: 'tied-log-first' }) })
325
+ })
326
+ act(() => {
327
+ reversed.pushFeed({ kind: 'event', event: evt({ at: 5000, message: 'tied-event-second' }) })
328
+ })
329
+ texts = timelineOf().map((el) => el.textContent ?? '')
330
+ expect(texts.findIndex((t) => t.includes('tied-log-first'))).toBeLessThan(
331
+ texts.findIndex((t) => t.includes('tied-event-second')),
332
+ )
333
+ })
334
+
335
+ it('does not let the local seq counter undercut an explicit seq already seen', async () => {
336
+ const { source, pushFeed } = createFakeSource()
337
+ render(<ConnectedCompilePanel source={source} />)
338
+ await flush()
339
+
340
+ // The first push carries an explicit, high seq. If the local monotonic
341
+ // counter naively started from 0, the next (unstamped) push would be
342
+ // assigned a lower seq and render BEFORE this one on the same `at` tick —
343
+ // silently reversing arrival order.
344
+ act(() => {
345
+ pushFeed({ kind: 'event', event: evt({ at: 9000, seq: 100, message: 'explicit-seq-first' }) })
346
+ })
347
+ act(() => {
348
+ pushFeed({ kind: 'log', log: logEntry({ at: 9000, text: 'auto-seq-second' }) })
349
+ })
350
+
351
+ const texts = timelineOf().map((el) => el.textContent ?? '')
352
+ const eventIndex = texts.findIndex((t) => t.includes('explicit-seq-first'))
353
+ const logIndex = texts.findIndex((t) => t.includes('auto-seq-second'))
354
+ expect(eventIndex).toBeGreaterThanOrEqual(0)
355
+ expect(logIndex).toBeGreaterThanOrEqual(0)
356
+ expect(eventIndex).toBeLessThan(logIndex)
357
+ })
358
+ })
359
+
360
+ describe('ConnectedCompilePanel: rendering contract', () => {
361
+ it('shows the empty state text when there are no events and no logs', async () => {
362
+ const { source } = createFakeSource()
363
+ render(<ConnectedCompilePanel source={source} enabled={false} />)
364
+
365
+ expect(screen.getByText('暂无编译信息')).not.toBeNull()
366
+ })
367
+
368
+ it('tags each event row with data-status matching its status', async () => {
369
+ const { source, getSnapshotQueue } = createFakeSource()
370
+ getSnapshotQueue.push(Promise.resolve({
371
+ events: [evt({ status: 'error', message: 'boom' })],
372
+ logs: [],
373
+ }))
374
+ render(<ConnectedCompilePanel source={source} />)
375
+ await flush()
376
+
377
+ const row = rowsOf().find((r) => r.textContent?.includes('boom'))
378
+ expect(row?.getAttribute('data-status')).toBe('error')
379
+ })
380
+
381
+ it('tags each log row with data-stream matching its stream', async () => {
382
+ const { source, getSnapshotQueue } = createFakeSource()
383
+ getSnapshotQueue.push(Promise.resolve({
384
+ events: [],
385
+ logs: [logEntry({ stream: 'stderr', text: 'oh no' })],
386
+ }))
387
+ render(<ConnectedCompilePanel source={source} />)
388
+ await flush()
389
+
390
+ const row = logsOf().find((r) => r.textContent?.includes('oh no'))
391
+ expect(row?.getAttribute('data-stream')).toBe('stderr')
392
+ })
393
+
394
+ it('reflects the latest event status/message in the current-status badge', async () => {
395
+ const { source, getSnapshotQueue } = createFakeSource()
396
+ getSnapshotQueue.push(Promise.resolve({
397
+ events: [
398
+ evt({ at: 1000, status: 'compiling', message: 'building now' }),
399
+ evt({ at: 2000, status: 'ready', message: 'build finished' }),
400
+ ],
401
+ logs: [],
402
+ }))
403
+ render(<ConnectedCompilePanel source={source} />)
404
+ await flush()
405
+
406
+ const badge = document.querySelector('[data-compile-current]')
407
+ expect(badge?.textContent).toContain('build finished')
408
+ })
409
+
410
+ it('renders a filter input with placeholder "过滤..." and a clear button labeled "清空"', async () => {
411
+ const { source } = createFakeSource()
412
+ render(<ConnectedCompilePanel source={source} enabled={false} />)
413
+
414
+ expect(screen.getByPlaceholderText('过滤...')).not.toBeNull()
415
+ expect(screen.getByText('清空')).not.toBeNull()
416
+ })
417
+ })
418
+
419
+ describe('ConnectedCompilePanel: duration badge on a compiling→ready pair', () => {
420
+ it('shows a duration badge on a ready event immediately following a compiling event', async () => {
421
+ const { source, getSnapshotQueue } = createFakeSource()
422
+ getSnapshotQueue.push(Promise.resolve({
423
+ events: [
424
+ evt({ at: 1000, status: 'compiling', message: 'building' }),
425
+ evt({ at: 1500, status: 'ready', message: 'done' }),
426
+ ],
427
+ logs: [],
428
+ }))
429
+ render(<ConnectedCompilePanel source={source} />)
430
+ await flush()
431
+
432
+ const readyRow = rowsOf().find((r) => r.textContent?.includes('done'))
433
+ expect(readyRow?.querySelector('[data-compile-duration]')).not.toBeNull()
434
+ })
435
+
436
+ it('shows no duration badge when a ready event has no immediately preceding compiling event', async () => {
437
+ const noPredecessor = createFakeSource()
438
+ noPredecessor.getSnapshotQueue.push(Promise.resolve({
439
+ events: [evt({ at: 1000, status: 'ready', message: 'lone ready' })],
440
+ logs: [],
441
+ }))
442
+ const { unmount } = render(<ConnectedCompilePanel source={noPredecessor.source} />)
443
+ await flush()
444
+ expect(
445
+ rowsOf().find((r) => r.textContent?.includes('lone ready'))?.querySelector('[data-compile-duration]'),
446
+ ).toBeNull()
447
+ unmount()
448
+
449
+ const nonCompilingPredecessor = createFakeSource()
450
+ nonCompilingPredecessor.getSnapshotQueue.push(Promise.resolve({
451
+ events: [
452
+ evt({ at: 1000, status: 'compiling', message: 'building' }),
453
+ evt({ at: 1200, status: 'error', message: 'broke' }),
454
+ evt({ at: 1500, status: 'ready', message: 'done anyway' }),
455
+ ],
456
+ logs: [],
457
+ }))
458
+ render(<ConnectedCompilePanel source={nonCompilingPredecessor.source} />)
459
+ await flush()
460
+ expect(
461
+ rowsOf().find((r) => r.textContent?.includes('done anyway'))?.querySelector('[data-compile-duration]'),
462
+ ).toBeNull()
463
+ })
464
+ })
465
+
466
+ describe('ConnectedCompilePanel: clear button', () => {
467
+ it('clears both rendered lists and calls source.clear once when the source provides it', async () => {
468
+ const { source, getSnapshotQueue } = createFakeSource()
469
+ const clear = vi.fn(async () => {})
470
+ source.clear = clear
471
+ getSnapshotQueue.push(Promise.resolve({
472
+ events: [evt({ message: 'to be cleared' })],
473
+ logs: [logEntry({ text: 'log to be cleared' })],
474
+ }))
475
+ render(<ConnectedCompilePanel source={source} />)
476
+ await flush()
477
+
478
+ fireEvent.click(screen.getByText('清空'))
479
+ await flush()
480
+
481
+ expect(rowsOf()).toHaveLength(0)
482
+ expect(logsOf()).toHaveLength(0)
483
+ expect(clear).toHaveBeenCalledTimes(1)
484
+ })
485
+
486
+ it('clears the rendered lists without throwing when the source has no clear', async () => {
487
+ const { source, getSnapshotQueue } = createFakeSource()
488
+ getSnapshotQueue.push(Promise.resolve({
489
+ events: [evt({ message: 'to be cleared' })],
490
+ logs: [],
491
+ }))
492
+ render(<ConnectedCompilePanel source={source} />)
493
+ await flush()
494
+
495
+ expect(() => fireEvent.click(screen.getByText('清空'))).not.toThrow()
496
+ await flush()
497
+
498
+ expect(rowsOf()).toHaveLength(0)
499
+ })
500
+ })
@@ -0,0 +1,91 @@
1
+ // The 编译 panel's data wiring, written once against CompilePanelSource: seed
2
+ // on the (enabled && active) rising edge, stay live by appending pushed
3
+ // events/logs under FIFO caps, stamp a shared monotonic `seq` onto unstamped
4
+ // arrivals (the merged timeline's same-`at` tie-break), forward the
5
+ // visibility gate, and route 清空. Hosts render this with their transport
6
+ // implementation and the pure CompilePanel view underneath never needs
7
+ // host-specific code.
8
+ import { useRef, useState } from 'react'
9
+ import { CompilePanel } from './compile-panel-view.js'
10
+ import { useSourceWiring } from './use-source-wiring.js'
11
+ import type { CompileFeedSnapshot, CompilePanelSource } from './compile-source.js'
12
+
13
+ export interface ConnectedCompilePanelProps {
14
+ source: CompilePanelSource
15
+ /** Panel visibility (the host's tab-active state). Defaults to true. */
16
+ active?: boolean
17
+ /** Data availability gate. While false the panel makes no source calls at
18
+ * all and keeps the last rendered timeline. Defaults to true. */
19
+ enabled?: boolean
20
+ }
21
+
22
+ /** events cap — FIFO, oldest evicted first. */
23
+ const MAX_COMPILE_EVENTS = 200
24
+ /** logs cap — FIFO, oldest evicted first. */
25
+ const MAX_COMPILE_LOGS = 300
26
+
27
+ const EMPTY_FEED: CompileFeedSnapshot = { events: [], logs: [] }
28
+
29
+ export function ConnectedCompilePanel({
30
+ source,
31
+ active = true,
32
+ enabled = true,
33
+ }: ConnectedCompilePanelProps) {
34
+ const [feed, setFeed] = useState<CompileFeedSnapshot>(EMPTY_FEED)
35
+ // The shared arrival counter behind `seq` stamping. Ratcheted past every
36
+ // explicit seq seen so a locally stamped item can never undercut one the
37
+ // host stamped itself (which would silently reverse arrival order on a
38
+ // same-`at` tie).
39
+ const nextSeq = useRef(0)
40
+
41
+ const stamp = <T extends { seq?: number }>(item: T): T => {
42
+ if (item.seq !== undefined) {
43
+ nextSeq.current = Math.max(nextSeq.current, item.seq + 1)
44
+ return item
45
+ }
46
+ return { ...item, seq: nextSeq.current++ }
47
+ }
48
+
49
+ useSourceWiring({
50
+ source,
51
+ enabled,
52
+ active,
53
+ subscribe: s => s.subscribe((evt) => {
54
+ if (evt.kind === 'reset') {
55
+ setFeed(EMPTY_FEED)
56
+ return
57
+ }
58
+ if (evt.kind === 'event') {
59
+ const event = stamp(evt.event)
60
+ setFeed(prev => ({
61
+ ...prev,
62
+ events: [...prev.events, event].slice(-MAX_COMPILE_EVENTS),
63
+ }))
64
+ return
65
+ }
66
+ const log = stamp(evt.log)
67
+ setFeed(prev => ({
68
+ ...prev,
69
+ logs: [...prev.logs, log].slice(-MAX_COMPILE_LOGS),
70
+ }))
71
+ }),
72
+ seed: (s, isDisposed) => {
73
+ void s.getSnapshot().then((snap) => {
74
+ if (isDisposed()) return
75
+ setFeed({
76
+ events: snap.events.map(stamp).slice(-MAX_COMPILE_EVENTS),
77
+ logs: snap.logs.map(stamp).slice(-MAX_COMPILE_LOGS),
78
+ })
79
+ })
80
+ },
81
+ })
82
+
83
+ const handleClear = () => {
84
+ // The local timeline empties regardless; clearing the host-side history
85
+ // is the optional capability.
86
+ setFeed(EMPTY_FEED)
87
+ void source.clear?.()
88
+ }
89
+
90
+ return <CompilePanel events={feed.events} logs={feed.logs} onClear={handleClear} />
91
+ }
package/src/index.ts CHANGED
@@ -14,3 +14,19 @@ export type { WxmlPanelSource } from './panel-source.js'
14
14
  export type { StorageItem, StorageEvent, StorageWriteResult } from './storage-types.js'
15
15
  export type { StoragePanelSource } from './storage-source.js'
16
16
  export { applyStorageEvent } from './storage-reducer.js'
17
+ export {
18
+ AppDataAccumulator,
19
+ decodeWorkerMessage,
20
+ decodeOutgoingMessage,
21
+ decodedToInput,
22
+ type AppDataSnapshot,
23
+ type AppDataInput,
24
+ type DecodedEntry,
25
+ } from './appdata-accumulator.js'
26
+ export type { AppDataPanelSource } from './appdata-source.js'
27
+ export type { CompileEvent, CompileLogEntry } from './compile-types.js'
28
+ export type {
29
+ CompilePanelSource,
30
+ CompileFeedSnapshot,
31
+ CompileFeedEvent,
32
+ } from './compile-source.js'
package/src/panel.tsx CHANGED
@@ -5,3 +5,8 @@ export { WxmlPanel } from './panel-view.js'
5
5
  export { ConnectedWxmlPanel, type ConnectedWxmlPanelProps } from './connected-panel.js'
6
6
  export { StoragePanel, type StoragePanelProps } from './storage-panel-view.js'
7
7
  export { ConnectedStoragePanel, type ConnectedStoragePanelProps } from './connected-storage-panel.js'
8
+ export { AppDataPanel, type AppDataPanelProps, type AppDataPanelState } from './appdata-panel-view.js'
9
+ export { ConnectedAppDataPanel, type ConnectedAppDataPanelProps } from './connected-appdata-panel.js'
10
+ export { useActiveBridgeId } from './use-active-bridge-id.js'
11
+ export { CompilePanel, type CompilePanelProps } from './compile-panel-view.js'
12
+ export { ConnectedCompilePanel, type ConnectedCompilePanelProps } from './connected-compile-panel.js'