|
| 1 | +import type { DiagnosticEntry } from '@workbench/diagnostics/logger'; |
1 | 2 | import type { WidgetViewProps } from '@workbench/types'; |
2 | 3 |
|
3 | | -import { HStack, Stack, Text } from '@chakra-ui/react'; |
| 4 | +import { Badge, Box, HStack, Stack, Text } from '@chakra-ui/react'; |
4 | 5 | import { Button, Panel } from '@workbench/components/ui'; |
| 6 | +import { clearProjectDiagnostics, useProjectDiagnostics } from '@workbench/diagnostics/logger'; |
5 | 7 | import { StatusWidgetChip } from '@workbench/widget-frame'; |
6 | | -import { useWorkbenchSelector } from '@workbench/WorkbenchContext'; |
| 8 | +import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; |
7 | 9 | import { BugIcon, ClipboardListIcon } from 'lucide-react'; |
8 | | -import { useCallback } from 'react'; |
| 10 | +import { memo, useCallback, useState } from 'react'; |
9 | 11 |
|
10 | 12 | export const DiagnosticsWidgetView = ({ presentation, region }: WidgetViewProps) => { |
11 | | - const errorCount = useWorkbenchSelector((snapshot) => snapshot.state.errorLog.length); |
12 | | - const label = errorCount === 0 ? 'Clean' : `${errorCount} issue${errorCount === 1 ? '' : 's'}`; |
| 13 | + const projectId = useActiveProjectSelector((project) => project.id); |
| 14 | + const entries = useProjectDiagnostics(projectId); |
| 15 | + const label = entries.length === 0 ? 'Clean' : `${entries.length} event${entries.length === 1 ? '' : 's'}`; |
13 | 16 |
|
14 | 17 | if (region === 'bottom' && presentation !== 'expanded') { |
15 | 18 | return ( |
16 | | - <StatusWidgetChip icon={errorCount > 0 ? BugIcon : ClipboardListIcon}>Diagnostics: {label}</StatusWidgetChip> |
| 19 | + <StatusWidgetChip icon={entries.length > 0 ? BugIcon : ClipboardListIcon}>Diagnostics: {label}</StatusWidgetChip> |
17 | 20 | ); |
18 | 21 | } |
19 | 22 |
|
20 | | - return <DiagnosticsPanel />; |
| 23 | + return <DiagnosticsPanel entries={entries} projectId={projectId} />; |
21 | 24 | }; |
22 | 25 |
|
23 | | -const DiagnosticsPanel = () => { |
24 | | - const errorLog = useWorkbenchSelector((snapshot) => snapshot.state.errorLog); |
| 26 | +const DiagnosticsPanel = ({ entries, projectId }: { entries: DiagnosticEntry[]; projectId: string }) => { |
| 27 | + const copyAll = useCallback(() => void navigator.clipboard?.writeText(JSON.stringify(entries, null, 2)), [entries]); |
| 28 | + const clearEntries = useCallback(() => clearProjectDiagnostics(projectId), [projectId]); |
25 | 29 |
|
26 | 30 | return ( |
27 | 31 | <Stack gap="3" p="2"> |
28 | | - {errorLog.length === 0 ? ( |
| 32 | + <HStack justify="space-between"> |
29 | 33 | <Text color="fg.subtle" fontSize="2xs"> |
30 | | - Shell errors and debugging details will appear here without covering the workbench. |
| 34 | + Project-scoped diagnostic events from workbench and widget loggers. |
| 35 | + </Text> |
| 36 | + <HStack gap="2"> |
| 37 | + <Button disabled={entries.length === 0} size="2xs" variant="outline" onClick={copyAll}> |
| 38 | + Copy JSON |
| 39 | + </Button> |
| 40 | + <Button disabled={entries.length === 0} size="2xs" variant="outline" onClick={clearEntries}> |
| 41 | + Clear |
| 42 | + </Button> |
| 43 | + </HStack> |
| 44 | + </HStack> |
| 45 | + {entries.length === 0 ? ( |
| 46 | + <Text color="fg.subtle" fontSize="2xs"> |
| 47 | + Logger events and performance timings will appear here without covering the workbench. |
31 | 48 | </Text> |
32 | 49 | ) : ( |
33 | 50 | <Stack gap="2"> |
34 | | - {errorLog.map((message, index) => ( |
35 | | - <DiagnosticsErrorRow key={`${message}-${index}`} message={message} /> |
| 51 | + {entries.map((entry) => ( |
| 52 | + <DiagnosticsEntryRow key={entry.id} entry={entry} /> |
36 | 53 | ))} |
37 | 54 | </Stack> |
38 | 55 | )} |
39 | 56 | </Stack> |
40 | 57 | ); |
41 | 58 | }; |
42 | 59 |
|
43 | | -const DiagnosticsErrorRow = ({ message }: { message: string }) => { |
44 | | - const copyMessage = useCallback(() => void navigator.clipboard?.writeText(message), [message]); |
| 60 | +const DiagnosticsEntryRow = memo(({ entry }: { entry: DiagnosticEntry }) => { |
| 61 | + const [isRawVisible, setIsRawVisible] = useState(false); |
| 62 | + const copyEntry = useCallback(() => void navigator.clipboard?.writeText(JSON.stringify(entry, null, 2)), [entry]); |
| 63 | + const toggleRaw = useCallback(() => setIsRawVisible((current) => !current), []); |
45 | 64 |
|
46 | 65 | return ( |
47 | 66 | <Panel gap="2" p="2"> |
48 | | - <Text color="fg.muted" fontFamily="mono" fontSize="2xs" whiteSpace="pre-wrap"> |
49 | | - {message} |
50 | | - </Text> |
51 | | - <HStack justify="end"> |
52 | | - <Button size="2xs" variant="outline" onClick={copyMessage}> |
53 | | - Copy |
| 67 | + <Stack gap="2"> |
| 68 | + <HStack align="start" gap="2" justify="space-between"> |
| 69 | + <Stack gap="1" minW="0"> |
| 70 | + <HStack gap="1.5" wrap="wrap"> |
| 71 | + <Badge colorPalette={getLevelColorPalette(entry.level)} size="xs"> |
| 72 | + {entry.level} |
| 73 | + </Badge> |
| 74 | + <Badge colorPalette="gray" size="xs"> |
| 75 | + {entry.namespace} |
| 76 | + </Badge> |
| 77 | + {entry.durationMs !== undefined ? ( |
| 78 | + <Badge colorPalette="purple" size="xs"> |
| 79 | + {entry.durationMs.toFixed(1)}ms |
| 80 | + </Badge> |
| 81 | + ) : null} |
| 82 | + <Text color="fg.muted" fontFamily="mono" fontSize="2xs"> |
| 83 | + {formatSource(entry)} |
| 84 | + </Text> |
| 85 | + </HStack> |
| 86 | + <Text color="fg" fontSize="xs" fontWeight="600"> |
| 87 | + {entry.message || '(no message)'} |
| 88 | + </Text> |
| 89 | + <Text color="fg.subtle" fontSize="2xs"> |
| 90 | + {new Date(entry.createdAt).toLocaleTimeString()} |
| 91 | + </Text> |
| 92 | + </Stack> |
| 93 | + <Button size="2xs" variant="outline" onClick={copyEntry}> |
| 94 | + Copy |
| 95 | + </Button> |
| 96 | + </HStack> |
| 97 | + <Button alignSelf="start" size="2xs" variant="ghost" onClick={toggleRaw}> |
| 98 | + {isRawVisible ? 'Hide raw entry' : 'Raw entry'} |
54 | 99 | </Button> |
55 | | - </HStack> |
| 100 | + {isRawVisible ? ( |
| 101 | + <Box |
| 102 | + as="pre" |
| 103 | + bg="bg.inset" |
| 104 | + borderColor="border.subtle" |
| 105 | + borderWidth="1px" |
| 106 | + color="fg.muted" |
| 107 | + fontFamily="mono" |
| 108 | + fontSize="2xs" |
| 109 | + maxH="16rem" |
| 110 | + overflow="auto" |
| 111 | + p="2" |
| 112 | + rounded="md" |
| 113 | + whiteSpace="pre-wrap" |
| 114 | + > |
| 115 | + {JSON.stringify(entry, null, 2)} |
| 116 | + </Box> |
| 117 | + ) : null} |
| 118 | + </Stack> |
56 | 119 | </Panel> |
57 | 120 | ); |
| 121 | +}); |
| 122 | + |
| 123 | +DiagnosticsEntryRow.displayName = 'DiagnosticsEntryRow'; |
| 124 | + |
| 125 | +const formatSource = (entry: DiagnosticEntry): string => { |
| 126 | + if (entry.source.kind === 'widget') { |
| 127 | + return `${entry.source.typeId}:${entry.source.instanceId}:${entry.source.region}`; |
| 128 | + } |
| 129 | + |
| 130 | + return `workbench:${entry.source.area}`; |
| 131 | +}; |
| 132 | + |
| 133 | +const getLevelColorPalette = (level: DiagnosticEntry['level']): string => { |
| 134 | + switch (level) { |
| 135 | + case 'fatal': |
| 136 | + case 'error': |
| 137 | + return 'red'; |
| 138 | + case 'warn': |
| 139 | + return 'orange'; |
| 140 | + case 'info': |
| 141 | + return 'blue'; |
| 142 | + case 'debug': |
| 143 | + return 'purple'; |
| 144 | + case 'trace': |
| 145 | + return 'gray'; |
| 146 | + } |
58 | 147 | }; |
0 commit comments