FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
RawLogDrawer.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import CloseIcon from '@mui/icons-material/Close';
5import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
6import {
7 Box,
8 Button,
9 Divider,
10 IconButton,
11 TextField,
12 Tooltip,
13 Typography,
14} from '@mui/material';
15import type { ChangeEvent, PointerEvent as ReactPointerEvent } from 'react';
16import { useEffect, useRef, useState } from 'react';
17import {
18 type FersLogEntry,
19 type FersLogLevel,
20 useFersLogStore,
21} from '@/stores/fersLogStore';
22import LogLevelSelect from './LogLevelSelect';
23
24interface RawLogDrawerProps {
25 leftOffset: number;
26 width: number;
27 onResize: (nextWidth: number) => void;
28 onResizeEnd: (nextWidth: number) => void;
29}
30
31const levelColor = (level: FersLogLevel) => {
32 switch (level) {
33 case 'FATAL':
34 case 'ERROR':
35 return 'error.main';
36 case 'WARNING':
37 return 'secondary.main';
38 case 'TRACE':
39 case 'DEBUG':
40 return 'text.secondary';
41 case 'OFF':
42 return 'text.disabled';
43 default:
44 return 'text.primary';
45 }
46};
47
48const RawLogLine = ({ entry }: { entry: FersLogEntry }) => (
49 <Box
50 component="div"
51 sx={{
52 color: levelColor(entry.level),
53 fontFamily: '"Roboto Mono", Consolas, "Liberation Mono", monospace',
54 fontSize: 12,
55 lineHeight: 1.55,
56 whiteSpace: 'pre-wrap',
57 wordBreak: 'break-word',
58 }}
59 >
60 {entry.line}
61 </Box>
62);
63
64export default function RawLogDrawer({
65 leftOffset,
66 width,
67 onResize,
68 onResizeEnd,
69}: RawLogDrawerProps) {
70 const entries = useFersLogStore((state) => state.entries);
71 const droppedCount = useFersLogStore((state) => state.droppedCount);
72 const maxLines = useFersLogStore((state) => state.maxLines);
73 const clearLogs = useFersLogStore((state) => state.clearLogs);
74 const setMaxLines = useFersLogStore((state) => state.setMaxLines);
75 const setOpen = useFersLogStore((state) => state.setOpen);
76 const [dragPointerId, setDragPointerId] = useState<number | null>(null);
77 const drawerRef = useRef<HTMLDivElement | null>(null);
78 const scrollRef = useRef<HTMLDivElement | null>(null);
79 const shouldAutoScrollRef = useRef(true);
80
81 useEffect(() => {
82 if (!shouldAutoScrollRef.current || !scrollRef.current) {
83 return;
84 }
85
86 scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
87 }, [entries.length]);
88
89 const handleScroll = () => {
90 const element = scrollRef.current;
91 if (!element) {
92 return;
93 }
94
95 shouldAutoScrollRef.current =
96 element.scrollHeight - element.scrollTop - element.clientHeight <
97 24;
98 };
99
100 const handleMaxLinesChange = (event: ChangeEvent<HTMLInputElement>) => {
101 const nextValue = Number(event.target.value);
102 if (Number.isFinite(nextValue)) {
103 setMaxLines(nextValue);
104 }
105 };
106
107 const getPointerWidth = (clientX: number) => {
108 const drawerBounds = drawerRef.current?.getBoundingClientRect();
109 if (!drawerBounds) {
110 return width;
111 }
112
113 return clientX - drawerBounds.left;
114 };
115
116 const handleResizePointerDown = (
117 event: ReactPointerEvent<HTMLDivElement>
118 ) => {
119 if (event.pointerType !== 'touch' && event.button !== 0) {
120 return;
121 }
122
123 event.preventDefault();
124 setDragPointerId(event.pointerId);
125 };
126
127 useEffect(() => {
128 if (dragPointerId === null) {
129 return;
130 }
131
132 const previousCursor = document.body.style.cursor;
133 const previousUserSelect = document.body.style.userSelect;
134
135 const handlePointerMove = (event: PointerEvent) => {
136 if (event.pointerId !== dragPointerId) {
137 return;
138 }
139
140 onResize(getPointerWidth(event.clientX));
141 };
142
143 const handlePointerUp = (event: PointerEvent) => {
144 if (event.pointerId !== dragPointerId) {
145 return;
146 }
147
148 onResizeEnd(getPointerWidth(event.clientX));
149 setDragPointerId(null);
150 };
151
152 document.body.style.cursor = 'col-resize';
153 document.body.style.userSelect = 'none';
154
155 window.addEventListener('pointermove', handlePointerMove);
156 window.addEventListener('pointerup', handlePointerUp);
157 window.addEventListener('pointercancel', handlePointerUp);
158
159 return () => {
160 document.body.style.cursor = previousCursor;
161 document.body.style.userSelect = previousUserSelect;
162 window.removeEventListener('pointermove', handlePointerMove);
163 window.removeEventListener('pointerup', handlePointerUp);
164 window.removeEventListener('pointercancel', handlePointerUp);
165 };
166 }, [dragPointerId, onResize, onResizeEnd, width]);
167
168 return (
169 <Box
170 ref={drawerRef}
171 sx={{
172 position: 'fixed',
173 left: leftOffset,
174 top: 0,
175 bottom: 0,
176 width,
177 minWidth: width,
178 maxWidth: width,
179 bgcolor: 'background.paper',
180 borderRight: 1,
181 borderColor: 'divider',
182 boxShadow: 8,
183 zIndex: (theme) => theme.zIndex.drawer,
184 display: 'flex',
185 flexDirection: 'column',
186 flexShrink: 0,
187 overflow: 'hidden',
188 }}
189 >
190 <Box
191 role="separator"
192 aria-orientation="vertical"
193 aria-label="Resize raw log viewer"
194 onPointerDown={handleResizePointerDown}
195 sx={{
196 position: 'absolute',
197 top: 0,
198 right: -5,
199 bottom: 0,
200 width: 10,
201 zIndex: 2,
202 cursor: 'col-resize',
203 touchAction: 'none',
204 '&::before': {
205 content: '""',
206 position: 'absolute',
207 top: 0,
208 bottom: 0,
209 left: '50%',
210 width: 2,
211 borderRadius: 999,
212 transform: 'translateX(-50%)',
213 backgroundColor:
214 dragPointerId === null
215 ? 'rgba(139, 148, 158, 0.35)'
216 : 'primary.main',
217 transition: 'background-color 0.2s ease',
218 },
219 '&:hover::before': {
220 backgroundColor: 'primary.light',
221 },
222 }}
223 />
224 <Box
225 sx={{
226 p: 2,
227 display: 'flex',
228 alignItems: 'center',
229 flexWrap: 'wrap',
230 gap: 1.5,
231 }}
232 >
233 <Box sx={{ minWidth: 0, flexGrow: 1 }}>
234 <Typography variant="h6">Raw Logs</Typography>
235 <Typography variant="caption" color="text.secondary">
236 {entries.length} lines retained, drag edge to resize
237 </Typography>
238 </Box>
239 <LogLevelSelect
240 id="raw-log-level"
241 label="Log level"
242 sx={{ width: 150 }}
243 />
244 <TextField
245 label="Max lines"
246 type="number"
247 size="small"
248 value={maxLines}
249 onChange={handleMaxLinesChange}
250 sx={{ width: 120 }}
251 slotProps={{
252 htmlInput: {
253 min: 100,
254 max: 20000,
255 step: 100,
256 },
257 }}
258 />
259 <Tooltip title="Clear logs">
260 <span>
261 <IconButton
262 aria-label="Clear logs"
263 onClick={clearLogs}
264 disabled={entries.length === 0}
265 >
266 <DeleteSweepIcon />
267 </IconButton>
268 </span>
269 </Tooltip>
270 <Tooltip title="Close logs">
271 <IconButton
272 aria-label="Close logs"
273 onClick={() => setOpen(false)}
274 >
275 <CloseIcon />
276 </IconButton>
277 </Tooltip>
278 </Box>
279 <Divider />
280 {droppedCount > 0 && (
281 <Box sx={{ px: 2, py: 1, bgcolor: 'action.hover' }}>
282 <Typography variant="caption" color="text.secondary">
283 {droppedCount} older log lines dropped.
284 </Typography>
285 </Box>
286 )}
287 <Box
288 ref={scrollRef}
289 onScroll={handleScroll}
290 sx={{
291 flexGrow: 1,
292 overflow: 'auto',
293 p: 2,
294 bgcolor: 'background.default',
295 }}
296 >
297 {entries.length === 0 ? (
298 <Box
299 sx={{
300 height: '100%',
301 display: 'flex',
302 alignItems: 'center',
303 justifyContent: 'center',
304 textAlign: 'center',
305 px: 3,
306 }}
307 >
308 <Typography variant="body2" color="text.secondary">
309 No FERS logs yet.
310 </Typography>
311 </Box>
312 ) : (
313 entries.map((entry) => (
314 <RawLogLine key={entry.sequence} entry={entry} />
315 ))
316 )}
317 </Box>
318 <Divider />
319 <Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
320 <Button size="small" onClick={() => setOpen(false)}>
321 Close
322 </Button>
323 </Box>
324 </Box>
325 );
326}