export type LogLevel = "info" | "warn" | "error"; export interface LogEntry { timestamp: number; level: LogLevel; message: string; } type LogListener = () => void; const MAX_ENTRIES = 500; const STORAGE_KEY = "simpl-resultat-logs"; const logs: LogEntry[] = []; const listeners = new Set(); // Immutable view of `logs`, rebuilt on every mutation. `useSyncExternalStore` // compares snapshots by identity (Object.is), so `getLogs` MUST return a NEW // reference whenever the logs change — and a STABLE one between changes (never // re-slice per call, which would loop the renderer). The old behaviour mutated // `logs` in place and returned that same array, so the log console never // updated live: a log produced while the panel was open was never re-rendered. let logsSnapshot: readonly LogEntry[] = []; function refreshSnapshot() { logsSnapshot = logs.slice(); } let initialized = false; function loadFromStorage() { try { const stored = sessionStorage.getItem(STORAGE_KEY); if (stored) { const parsed: LogEntry[] = JSON.parse(stored); logs.push(...parsed); refreshSnapshot(); } } catch { // ignore corrupted storage } } function saveToStorage() { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(logs)); } catch { // ignore quota errors } } function addEntry(level: LogLevel, args: unknown[]) { const message = args .map((a) => { if (typeof a === "string") return a; try { return JSON.stringify(a); } catch { return String(a); } }) .join(" "); logs.push({ timestamp: Date.now(), level, message }); if (logs.length > MAX_ENTRIES) { logs.splice(0, logs.length - MAX_ENTRIES); } refreshSnapshot(); saveToStorage(); listeners.forEach((fn) => fn()); } export function initLogCapture() { if (initialized) return; initialized = true; loadFromStorage(); const origLog = console.log.bind(console); const origWarn = console.warn.bind(console); const origError = console.error.bind(console); console.log = (...args: unknown[]) => { addEntry("info", args); origLog(...args); }; console.warn = (...args: unknown[]) => { addEntry("warn", args); origWarn(...args); }; console.error = (...args: unknown[]) => { addEntry("error", args); origError(...args); }; } export function getLogs(): readonly LogEntry[] { return logsSnapshot; } /// Extract the N most recent non-info entries formatted as a single string, /// suitable for appending to a feedback body. Empty string if no qualifying /// entries. Each line: `[ISO timestamp] LEVEL: message`. export function getRecentErrorLogs(n: number): string { if (n <= 0) return ""; const errors = logs.filter((l) => l.level !== "info"); const tail = errors.slice(Math.max(0, errors.length - n)); return tail .map((l) => { const iso = new Date(l.timestamp).toISOString(); return `[${iso}] ${l.level.toUpperCase()}: ${l.message}`; }) .join("\n"); } export function clearLogs() { logs.length = 0; refreshSnapshot(); saveToStorage(); listeners.forEach((fn) => fn()); } // --------------------------------------------------------------------------- // App logging API. Prefer these over raw `console.*` in application code so // meaningful events land in the in-app log console (Settings → Systems → // Journaux / Logs). They forward to the captured console methods, so entries // also show up in devtools. `console.log` is captured as the `info` level. // --------------------------------------------------------------------------- export function logInfo(...args: unknown[]): void { console.log(...args); } export function logWarn(...args: unknown[]): void { console.warn(...args); } export function logError(...args: unknown[]): void { console.error(...args); } export function subscribe(listener: LogListener): () => void { listeners.add(listener); return () => listeners.delete(listener); }