1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import CloseIcon from '@mui/icons-material/Close';
5import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
14} from '@mui/material';
15import type { ChangeEvent, PointerEvent as ReactPointerEvent } from 'react';
16import { useEffect, useRef, useState } from 'react';
21} from '@/stores/fersLogStore';
22import LogLevelSelect from './LogLevelSelect';
24interface RawLogDrawerProps {
27 onResize: (nextWidth: number) => void;
28 onResizeEnd: (nextWidth: number) => void;
31const levelColor = (level: FersLogLevel) => {
37 return 'secondary.main';
40 return 'text.secondary';
42 return 'text.disabled';
44 return 'text.primary';
48const RawLogLine = ({ entry }: { entry: FersLogEntry }) => (
52 color: levelColor(entry.level),
53 fontFamily: '"Roboto Mono", Consolas, "Liberation Mono", monospace',
56 whiteSpace: 'pre-wrap',
57 wordBreak: 'break-word',
64export default function RawLogDrawer({
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);
82 if (!shouldAutoScrollRef.current || !scrollRef.current) {
86 scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
89 const handleScroll = () => {
90 const element = scrollRef.current;
95 shouldAutoScrollRef.current =
96 element.scrollHeight - element.scrollTop - element.clientHeight <
100 const handleMaxLinesChange = (event: ChangeEvent<HTMLInputElement>) => {
101 const nextValue = Number(event.target.value);
102 if (Number.isFinite(nextValue)) {
103 setMaxLines(nextValue);
107 const getPointerWidth = (clientX: number) => {
108 const drawerBounds = drawerRef.current?.getBoundingClientRect();
113 return clientX - drawerBounds.left;
116 const handleResizePointerDown = (
117 event: ReactPointerEvent<HTMLDivElement>
119 if (event.pointerType !== 'touch' && event.button !== 0) {
123 event.preventDefault();
124 setDragPointerId(event.pointerId);
128 if (dragPointerId === null) {
132 const previousCursor = document.body.style.cursor;
133 const previousUserSelect = document.body.style.userSelect;
135 const handlePointerMove = (event: PointerEvent) => {
136 if (event.pointerId !== dragPointerId) {
140 onResize(getPointerWidth(event.clientX));
143 const handlePointerUp = (event: PointerEvent) => {
144 if (event.pointerId !== dragPointerId) {
148 onResizeEnd(getPointerWidth(event.clientX));
149 setDragPointerId(null);
152 document.body.style.cursor = 'col-resize';
153 document.body.style.userSelect = 'none';
155 window.addEventListener('pointermove', handlePointerMove);
156 window.addEventListener('pointerup', handlePointerUp);
157 window.addEventListener('pointercancel', handlePointerUp);
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);
166 }, [dragPointerId, onResize, onResizeEnd, width]);
179 bgcolor: 'background.paper',
181 borderColor: 'divider',
183 zIndex: (theme) => theme.zIndex.drawer,
185 flexDirection: 'column',
192 aria-orientation="vertical"
193 aria-label="Resize raw log viewer"
194 onPointerDown={handleResizePointerDown}
196 position: 'absolute',
202 cursor: 'col-resize',
206 position: 'absolute',
212 transform: 'translateX(-50%)',
214 dragPointerId === null
215 ? 'rgba(139, 148, 158, 0.35)'
217 transition: 'background-color 0.2s ease',
220 backgroundColor: 'primary.light',
228 alignItems: 'center',
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
249 onChange={handleMaxLinesChange}
259 <Tooltip title="Clear logs">
262 aria-label="Clear logs"
264 disabled={entries.length === 0}
270 <Tooltip title="Close logs">
272 aria-label="Close logs"
273 onClick={() => setOpen(false)}
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.
289 onScroll={handleScroll}
294 bgcolor: 'background.default',
297 {entries.length === 0 ? (
302 alignItems: 'center',
303 justifyContent: 'center',
308 <Typography variant="body2" color="text.secondary">
313 entries.map((entry) => (
314 <RawLogLine key={entry.sequence} entry={entry} />
319 <Box sx={{ p: 1.5, display: 'flex', justifyContent: 'flex-end' }}>
320 <Button size="small" onClick={() => setOpen(false)}>