@dimina-kit/inspect 0.3.0 → 0.4.0-dev.20260716163758

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,236 @@
1
+ // The pure 编译 panel view: the host's compile-status events and per-line
2
+ // compiler log merged — in the VIEW layer only — into one oldest-first
3
+ // (chronological) timeline with a text filter. Pure presentation — feed
4
+ // state lives in the connected container (or the host, when it renders this
5
+ // view directly).
6
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
7
+ import type { CompileEvent, CompileLogEntry } from './compile-types.js'
8
+
9
+ /** Join the truthy class fragments (the panel's only class-composition need). */
10
+ function cn(...parts: Array<string | false | undefined>): string {
11
+ return parts.filter(Boolean).join(' ')
12
+ }
13
+
14
+ export interface CompilePanelProps {
15
+ /** Chronological (oldest-first) compile-event log. */
16
+ events: CompileEvent[]
17
+ /**
18
+ * Chronological (oldest-first) per-line compiler log. Optional: omitting it
19
+ * keeps the event-only behaviour (incl. the 暂无编译 empty state).
20
+ */
21
+ logs?: CompileLogEntry[]
22
+ /** Clear the log (the owner empties both stores). */
23
+ onClear: () => void
24
+ }
25
+
26
+ function formatTime(at: number): string {
27
+ const d = new Date(at)
28
+ return [d.getHours(), d.getMinutes(), d.getSeconds()]
29
+ .map((n) => String(n).padStart(2, '0'))
30
+ .join(':')
31
+ }
32
+
33
+ /** Status → row/badge accent. Unknown statuses fall back to muted text. */
34
+ function statusClass(status: string): string {
35
+ if (status === 'error') return 'text-red-500'
36
+ if (status === 'compiling') return 'text-amber-500'
37
+ if (status === 'ready') return 'text-emerald-600'
38
+ return 'text-text-muted'
39
+ }
40
+
41
+ /** Extract the display text from a timeline item for filter matching. */
42
+ function itemText(item: TimelineItem): string {
43
+ return item.kind === 'event' ? item.event.message : item.log.text
44
+ }
45
+
46
+ interface EventItem {
47
+ kind: 'event'
48
+ at: number
49
+ /** Arrival counter shared with logs — the same-`at` tie-break carrier. */
50
+ seq?: number
51
+ event: CompileEvent
52
+ /** Elapsed ms when this ready event pairs with the preceding compiling. */
53
+ durationMs: number | null
54
+ }
55
+
56
+ interface LogItem {
57
+ kind: 'log'
58
+ at: number
59
+ /** Arrival counter shared with events — the same-`at` tie-break carrier. */
60
+ seq?: number
61
+ log: CompileLogEntry
62
+ }
63
+
64
+ type TimelineItem = EventItem | LogItem
65
+
66
+ export function CompilePanel({ events, logs = [], onClear }: CompilePanelProps) {
67
+ const latest = events.length > 0 ? events[events.length - 1]! : null
68
+ const [filter, setFilter] = useState('')
69
+
70
+ const scrollRef = useRef<HTMLDivElement>(null)
71
+ const bottomRef = useRef<HTMLDivElement>(null)
72
+ const shouldAutoScroll = useRef(true)
73
+
74
+ // Track whether the user has scrolled away from the bottom.
75
+ useEffect(() => {
76
+ const container = scrollRef.current
77
+ if (!container) return
78
+ const handleScroll = () => {
79
+ const { scrollTop, scrollHeight, clientHeight } = container
80
+ shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 20
81
+ }
82
+ container.addEventListener('scroll', handleScroll)
83
+ return () => container.removeEventListener('scroll', handleScroll)
84
+ }, [])
85
+
86
+ const totalItems = events.length + logs.length
87
+
88
+ // Auto-scroll to bottom when new items arrive. Optional-call: jsdom-class
89
+ // environments don't implement scrollIntoView.
90
+ useEffect(() => {
91
+ if (shouldAutoScroll.current) {
92
+ bottomRef.current?.scrollIntoView?.({ behavior: 'auto' })
93
+ }
94
+ }, [totalItems])
95
+
96
+ const timeline = useMemo<TimelineItem[]>(() => {
97
+ const items: TimelineItem[] = []
98
+ events.forEach((event, index) => {
99
+ // Duration pairs strictly with the IMMEDIATELY preceding event: a
100
+ // ready right after a compiling shows the elapsed compile time; any
101
+ // intervening event (e.g. an error) breaks the pair.
102
+ const previous = index > 0 ? events[index - 1]! : null
103
+ const durationMs =
104
+ event.status === 'ready' && previous?.status === 'compiling'
105
+ ? event.at - previous.at
106
+ : null
107
+ items.push({ kind: 'event', at: event.at, seq: event.seq, event, durationMs })
108
+ })
109
+ for (const log of logs) {
110
+ items.push({ kind: 'log', at: log.at, seq: log.seq, log })
111
+ }
112
+ // Oldest first (chronological). Within a same-`at` tie (millisecond
113
+ // stamps — an event and the logs of the same compile collide routinely)
114
+ // the shared monotonic `seq` keeps ARRIVAL order.
115
+ return items.sort((a, b) => {
116
+ if (a.at !== b.at) return a.at - b.at
117
+ if (a.seq !== undefined && b.seq !== undefined) return a.seq - b.seq
118
+ return 0
119
+ })
120
+ }, [events, logs])
121
+
122
+ const filtered = useMemo(() => {
123
+ if (!filter) return timeline
124
+ const lower = filter.toLowerCase()
125
+ return timeline.filter((item) => itemText(item).toLowerCase().includes(lower))
126
+ }, [timeline, filter])
127
+
128
+ const isEmpty = events.length === 0 && logs.length === 0
129
+
130
+ return (
131
+ <div className="flex flex-col h-full w-full min-h-0 text-[12px]">
132
+ {/* Header: current-status badge + filter + 清空. */}
133
+ <div className="flex items-center gap-2 px-2 h-8 shrink-0 border-b border-border-subtle">
134
+ {latest ? (
135
+ <span
136
+ data-compile-current
137
+ data-status={latest.status}
138
+ className={cn('truncate font-medium shrink-0', statusClass(latest.status))}
139
+ >
140
+ {latest.message}
141
+ </span>
142
+ ) : (
143
+ <span className="text-text-muted shrink-0">编译信息</span>
144
+ )}
145
+ <div className="flex-1 min-w-0" />
146
+ <input
147
+ type="text"
148
+ value={filter}
149
+ onChange={(e) => setFilter(e.target.value)}
150
+ placeholder="过滤..."
151
+ className="h-6 px-2 text-[12px] rounded-sm border border-border-subtle bg-surface
152
+ text-text placeholder:text-text-dim
153
+ focus:outline-none focus:border-text-muted
154
+ w-40 shrink-0"
155
+ />
156
+ {filter && (
157
+ <span className="text-[11px] text-text-muted tabular-nums shrink-0">
158
+ {filtered.length}/{timeline.length}
159
+ </span>
160
+ )}
161
+ <button
162
+ type="button"
163
+ onClick={onClear}
164
+ className="px-2 h-6 rounded-sm text-text-muted hover:text-text hover:bg-surface-2 transition-colors shrink-0"
165
+ >
166
+ 清空
167
+ </button>
168
+ </div>
169
+
170
+ <div ref={scrollRef} className="flex-1 min-h-0 overflow-auto">
171
+ {isEmpty ? (
172
+ <div className="flex items-center justify-center h-full text-text-muted">
173
+ 暂无编译信息
174
+ </div>
175
+ ) : filtered.length === 0 ? (
176
+ <div className="flex items-center justify-center h-full text-text-muted">
177
+ 无匹配日志
178
+ </div>
179
+ ) : (
180
+ <ul className="px-2 py-1 space-y-0.5">
181
+ {filtered.map((item, index) =>
182
+ item.kind === 'event' ? (
183
+ <li
184
+ key={`e-${item.at}-${index}`}
185
+ data-compile-row
186
+ data-status={item.event.status}
187
+ className="flex items-baseline gap-2 leading-5"
188
+ >
189
+ <span className="text-text-muted tabular-nums shrink-0">
190
+ {formatTime(item.at)}
191
+ </span>
192
+ <span className={cn('break-all', statusClass(item.event.status))}>
193
+ {item.event.message}
194
+ </span>
195
+ {item.event.hotReload === true && (
196
+ <span className="shrink-0 px-1 rounded-sm bg-surface-2 text-text-muted">
197
+ 已重启
198
+ </span>
199
+ )}
200
+ {item.durationMs !== null && (
201
+ <span
202
+ data-compile-duration
203
+ className="shrink-0 text-text-muted tabular-nums"
204
+ >
205
+ {(item.durationMs / 1000).toFixed(1)}s
206
+ </span>
207
+ )}
208
+ </li>
209
+ ) : (
210
+ <li
211
+ key={`l-${item.at}-${index}`}
212
+ data-compile-log
213
+ data-stream={item.log.stream}
214
+ className="flex items-baseline gap-2 leading-5"
215
+ >
216
+ <span className="text-text-muted tabular-nums shrink-0">
217
+ {formatTime(item.at)}
218
+ </span>
219
+ <span
220
+ className={cn(
221
+ 'break-all font-mono',
222
+ item.log.stream === 'stderr' ? 'text-red-500' : 'text-text',
223
+ )}
224
+ >
225
+ {item.log.text}
226
+ </span>
227
+ </li>
228
+ ),
229
+ )}
230
+ <div ref={bottomRef} />
231
+ </ul>
232
+ )}
233
+ </div>
234
+ </div>
235
+ )
236
+ }
@@ -0,0 +1,35 @@
1
+ // The host-transport contract behind the 编译 panel: the panel's data wiring
2
+ // (seed, live event/log appends with FIFO caps, visibility gating, clearing)
3
+ // is written ONCE against this interface (see ConnectedCompilePanel); each
4
+ // host only implements how the operations travel — Electron IPC pushes, an
5
+ // in-page compile-service callback, or anything else.
6
+ import type { CompileEvent, CompileLogEntry } from './compile-types.js'
7
+
8
+ /** The seed payload: both stores, chronological (oldest first). Cross-stream
9
+ * order between an event and a log sharing the same millisecond `at` is only
10
+ * preserved when the host stamps `seq` itself — an unstamped snapshot gets
11
+ * `seq` assigned per store (events first), so such ties sort event-first. */
12
+ export interface CompileFeedSnapshot {
13
+ events: CompileEvent[]
14
+ logs: CompileLogEntry[]
15
+ }
16
+
17
+ /** One live push: a status event, a log line, or a host-side history reset. */
18
+ export type CompileFeedEvent =
19
+ | { kind: 'event'; event: CompileEvent }
20
+ | { kind: 'log'; log: CompileLogEntry }
21
+ | { kind: 'reset' }
22
+
23
+ export interface CompilePanelSource {
24
+ /** Fetch the current feed history (seed on panel activation). */
25
+ getSnapshot(): Promise<CompileFeedSnapshot>
26
+ /** Live feed pushes; returns an unsubscribe function. */
27
+ subscribe(onEvent: (evt: CompileFeedEvent) => void): () => void
28
+ /** Visibility gate: hosts whose feed costs something only keep it armed
29
+ * while some panel is visible. */
30
+ setActive(on: boolean): void
31
+ /** Clear the host-side history. Optional: the panel's 清空 empties its
32
+ * local timeline regardless; hosts that keep no history of their own can
33
+ * omit this. */
34
+ clear?(): void | Promise<void>
35
+ }
@@ -0,0 +1,35 @@
1
+ // The compile panel's two feed item shapes. The stores never cross: status
2
+ // events come from the host's compile-status feed, per-line compiler output
3
+ // lands in the log feed — merging them is a view concern.
4
+
5
+ /** One entry of the 编译 tab's event log (a compile-status transition). */
6
+ export interface CompileEvent {
7
+ /** Wall-clock capture time (Date.now) of the payload's arrival. */
8
+ at: number
9
+ status: string
10
+ message: string
11
+ /** True when the payload came from a watcher rebuild (热更新 chip). */
12
+ hotReload?: boolean
13
+ /**
14
+ * Optional shared monotonic arrival counter spanning compile events AND
15
+ * compile logs — the panel's same-`at` tie-break: `at` is a
16
+ * millisecond stamp, so an event and the log lines of the same compile
17
+ * routinely collide on it.
18
+ */
19
+ seq?: number
20
+ }
21
+
22
+ /** One per-line compiler log entry. `at` is the capture timestamp. */
23
+ export interface CompileLogEntry {
24
+ at: number
25
+ stream: 'stdout' | 'stderr'
26
+ text: string
27
+ /**
28
+ * Optional shared monotonic arrival counter spanning compile EVENTS and
29
+ * LOGS. `at` is a millisecond stamp, so a status event and the log lines
30
+ * of the same compile routinely collide on the same `at` — the panel uses
31
+ * `seq` as the same-`at` tie-break so the merged timeline keeps true
32
+ * arrival order.
33
+ */
34
+ seq?: number
35
+ }