import { useEffect, useRef, useState } from 'react'; import { invokeCommand as invoke } from '../ipc'; import { save } from '@tauri-apps/plugin-dialog'; import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger'; import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { MAX_LOG_LINES, appendBoundedLogEntries, liveLogEntry, mergeLogSnapshotAndLiveEntries, persistedLogEntry, pushBoundedLogEntry, type LogEntry } from '../utils/logEntries'; export default function LogsView() { const { addToast } = useToast(); const logsEnabled = useSettingsStore(state => state.logsEnabled); const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled); const [logs, setLogs] = useState([]); const [levelFilter, setLevelFilter] = useState('All'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null); const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden'); const scrollRef = useRef(null); const liveBatchRef = useRef([]); const liveFrameRef = useRef(null); const clearGenerationRef = useRef(0); const clearInFlightRef = useRef | null>(null); const toggleInFlightRef = useRef | null>(null); const [isClearing, setIsClearing] = useState(false); const [isToggling, setIsToggling] = useState(false); useEffect(() => { const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden'); document.addEventListener('visibilitychange', handleVisibilityChange); return () => document.removeEventListener('visibilitychange', handleVisibilityChange); }, []); useEffect(() => { if (!pageVisible) { void setLogStreamActive(false).catch(console.error); return; } if (!logsEnabled) { void setLogStreamActive(false).catch(console.error); } let active = true; let initialized = false; const initGeneration = clearGenerationRef.current; let pendingLiveEntries: LogEntry[] = []; let unlistenPromise: Promise<() => void> | undefined; const scheduleLiveEntry = (entry: LogEntry) => { if (!initialized) { pushBoundedLogEntry(pendingLiveEntries, entry); return; } pushBoundedLogEntry(liveBatchRef.current, entry); if (liveFrameRef.current !== null) return; liveFrameRef.current = window.requestAnimationFrame(() => { liveFrameRef.current = null; if (!active || liveBatchRef.current.length === 0) return; const batch = liveBatchRef.current; liveBatchRef.current = []; setLogs(current => appendBoundedLogEntries(current, batch)); }); }; const init = async () => { try { await initLogger(); if (!active) return; if (logsEnabled) { unlistenPromise = attachLogger((log) => { if (!active) return; scheduleLiveEntry(liveLogEntry(log.level, log.message)); }); await unlistenPromise; if (!active) return; await setLogStreamActive(true); if (!active) { await setLogStreamActive(false).catch(console.error); return; } } const lines = await invoke('read_logs', { limit: MAX_LOG_LINES }); if (!active) return; const snapshot = lines.map(persistedLogEntry); initialized = true; if (initGeneration !== clearGenerationRef.current) { pendingLiveEntries = []; return; } const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries); pendingLiveEntries = []; setLogs(caughtUpLogs); } catch (e) { console.error('Failed to init logs:', e); } }; void init(); return () => { active = false; liveBatchRef.current = []; if (liveFrameRef.current !== null) { window.cancelAnimationFrame(liveFrameRef.current); liveFrameRef.current = null; } if (logsEnabled) { void setLogStreamActive(false).catch(console.error); } if (unlistenPromise) { void unlistenPromise.then(unlisten => unlisten()).catch(console.error); } }; }, [logsEnabled, pageVisible]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [logs]); useEffect(() => { const handleCloseMenu = () => setContextMenu(null); const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') setContextMenu(null); }; window.addEventListener('click', handleCloseMenu); window.addEventListener('keydown', handleEscape); return () => { window.removeEventListener('click', handleCloseMenu); window.removeEventListener('keydown', handleEscape); }; }, []); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const selection = window.getSelection()?.toString(); if (selection && selection.trim().length > 0) { setContextMenu({ x: e.clientX, y: e.clientY, text: selection }); } else { setContextMenu(null); } }; const handleCopy = async () => { if (contextMenu?.text) { try { await navigator.clipboard.writeText(contextMenu.text); addToast({ message: 'Copied to clipboard', variant: 'success' }); } catch (err) { console.error('Clipboard write error:', err); addToast({ message: 'Failed to copy to clipboard', variant: 'error' }); } } setContextMenu(null); }; const handleExport = async () => { try { const path = await save({ defaultPath: 'Firelink-Support-Logs.log', filters: [{ name: 'Log Files', extensions: ['log'] }], }); if (!path) return; await invoke('export_logs', { destination: path }); addToast({ message: 'Support logs exported', variant: 'success' }); } catch (e) { console.error('Export failed:', e); addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true }); } }; const handleClear = async () => { if (clearInFlightRef.current) return; const clearOperation = invoke('clear_logs'); clearInFlightRef.current = clearOperation; setIsClearing(true); try { await clearOperation; clearGenerationRef.current += 1; liveBatchRef.current = []; if (liveFrameRef.current !== null) { window.cancelAnimationFrame(liveFrameRef.current); liveFrameRef.current = null; } setLogs([]); addToast({ message: 'Logs cleared', variant: 'info' }); } catch (error) { addToast({ message: `Could not clear logs: ${String(error)}`, variant: 'error', isActionable: true }); } finally { if (clearInFlightRef.current === clearOperation) { clearInFlightRef.current = null; } setIsClearing(false); } }; const handleToggleLogging = async () => { if (toggleInFlightRef.current) return; const nextEnabled = !logsEnabled; const toggleOperation = (async () => { await setLogPaused(!nextEnabled); setLogsEnabled(nextEnabled); addToast({ message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled', variant: 'success' }); })(); toggleInFlightRef.current = toggleOperation; setIsToggling(true); try { await toggleOperation; } catch (error) { addToast({ message: `Could not update diagnostic logging: ${String(error)}`, variant: 'error', isActionable: true }); } finally { if (toggleInFlightRef.current === toggleOperation) { toggleInFlightRef.current = null; } setIsToggling(false); } }; const severityClass = (level: string) => { switch (level) { case 'Error': return 'log-error'; case 'Warn': return 'log-warn'; case 'Info': return 'log-info'; default: return 'log-debug'; } }; return (
{/* Toolbar */}
Logs ({logs.length} entries) {logsEnabled ? 'Collecting' : 'Off'}
{/* Privacy Hint */}
Local diagnostics: Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
{/* Console */}
{logs.length === 0 && (
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
)} {logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
[{entry.level}] {entry.message}
))}
{/* Context Menu */} {contextMenu && (
e.stopPropagation()} >
)}
); }