Files
feuerwerk/src/editor/console/log.ts
T

146 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type LogLevel = 'info' | 'warn' | 'error';
export interface LogLine {
readonly level: LogLevel;
readonly text: string;
/** Wall clock of the call, in ms; the panel draws it as `HH:MM:SS`. */
readonly time: number;
}
/** Local time of `ms` as `HH:MM:SS` – a console tail is read within the session, not across days. */
export function formatTime(ms: number): string {
const at = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
}
/** Oldest lines are dropped past this; the panel is a tail, not an archive. */
export const LOG_CAPACITY = 500;
const MAX_VALUE = 200;
function formatValue(value: unknown): string {
if (typeof value === 'string') return value;
// An Error prints its whole stack, which would bury the message under frames nobody reads here.
if (value instanceof Error) return value.stack?.split('\n')[0] ?? `${value.name}: ${value.message}`;
if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`;
if (typeof value === 'object' && value !== null) {
let json: string;
try {
json = JSON.stringify(value);
} catch {
return '[unserialisable]';
}
return json.length > MAX_VALUE ? `${json.slice(0, MAX_VALUE)}…` : json;
}
return String(value);
}
const HAS_DIRECTIVE = /%[sdifoOc%]/;
const DIRECTIVE = /%([sdifoOc%])/g;
/**
* One line out of the arguments of a `console.*` call, the way the browser joins them: a leading
* format string consumes one argument per directive, `%c` swallows its style, the rest follow.
*/
export function formatArgs(args: readonly unknown[]): string {
const [first, ...rest] = args;
if (typeof first !== 'string' || !HAS_DIRECTIVE.test(first)) return args.map(formatValue).join(' ');
const text = first.replace(DIRECTIVE, (match, spec: string) => {
if (spec === '%') return '%';
if (rest.length === 0) return match;
const value = rest.shift();
if (spec === 'c') return '';
if (spec === 'd' || spec === 'i') return String(Math.trunc(Number(value)));
if (spec === 'f') return String(Number(value));
return formatValue(value);
});
return [text, ...rest.map(formatValue)].join(' ');
}
export class ConsoleLog {
private buffer: LogLine[] = [];
private readonly listeners = new Set<() => void>();
private depth = 0;
constructor(private readonly now: () => number = Date.now) {}
/**
* Appends `text`; an embedded newline starts a new line, so a stack stays readable. Lines inside
* a group are indented under its head.
*/
push(level: LogLevel, text: string): void {
const time = this.now();
const indent = ' '.repeat(this.depth);
for (const part of text.split('\n')) this.buffer.push({ level, text: indent + part, time });
if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY);
for (const listener of this.listeners) listener();
}
/** A group's head is a line like any other; what follows until `closeGroup` sits under it. */
openGroup(text: string): void {
this.push('info', text);
this.depth += 1;
}
closeGroup(): void {
this.depth = Math.max(0, this.depth - 1);
}
get lines(): readonly LogLine[] {
return this.buffer;
}
clear(): void {
this.depth = 0;
if (this.buffer.length === 0) return;
this.buffer = [];
for (const listener of this.listeners) listener();
}
/** Returns the call that takes the listener away again – a panel destroyed on sign-out (#177). */
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
export const consoleLog = new ConsoleLog();
const METHODS = ['log', 'info', 'debug', 'warn', 'error', 'group', 'groupCollapsed', 'groupEnd'] as const;
type ConsoleMethod = (typeof METHODS)[number];
export type ConsoleLike = Record<ConsoleMethod, (...args: unknown[]) => void>;
const LEVEL_OF: Record<ConsoleMethod, LogLevel | 'open' | 'close'> = {
log: 'info',
info: 'info',
debug: 'info',
warn: 'warn',
error: 'error',
group: 'open',
groupCollapsed: 'open',
groupEnd: 'close',
};
/**
* Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the
* check scripts still see it. Groups are mirrored too: Pixi's deprecation warnings put the message
* into `groupCollapsed` and only the stack into `warn`. Returns the undo, which only the tests use.
*/
export function captureConsole(target: ConsoleLike = console, log: ConsoleLog = consoleLog): () => void {
const originals = METHODS.map((method) => [method, target[method]] as const);
for (const [method, original] of originals) {
const level = LEVEL_OF[method];
target[method] = (...args: unknown[]) => {
original.apply(target, args);
if (level === 'close') log.closeGroup();
else if (level === 'open') log.openGroup(formatArgs(args));
else log.push(level, formatArgs(args));
};
}
return () => {
for (const [method, original] of originals) target[method] = original;
};
}