File size: 2,807 Bytes
67d18ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 | /**
* The log pane: a bounded ring of leveled log lines with tail-following and
* scrollback, mirroring the Android app's Logs card (LazyColumn that sticks
* to the newest line). Pure state — the frame renderer and terminal glue
* read it, tests drive it directly.
*/
import { stripAnsi } from "./width.js";
export type LogLevel = "info" | "warn" | "error";
export interface LogLine {
readonly seq: number;
readonly level: LogLevel;
readonly text: string;
}
export interface LogView {
/** The lines to draw, oldest first, at most `height` of them. */
readonly lines: readonly LogLine[];
/** Total lines retained (for the "Logs (N)" header). */
readonly total: number;
/** Lines hidden BELOW the viewport toward the tail (0 while following). */
readonly fromBottom: number;
}
export class LogPane {
private lines: LogLine[] = [];
private nextSeq = 0;
/** Lines hidden below the viewport. 0 = follow the tail. */
private offset = 0;
constructor(private readonly capacity = 2000) {}
/**
* Append a log record. ANSI escape sequences are stripped (proxy logs carry
* color codes; the pane applies its own) and embedded newlines split into
* separate rows so multi-line records scroll naturally.
*/
push(text: string, level: LogLevel = "info"): void {
const clean = stripAnsi(String(text)).replace(/\r/g, "");
const parts = clean.split("\n");
if (parts.length > 1 && parts[parts.length - 1] === "") parts.pop();
for (const part of parts) {
this.lines.push({ seq: this.nextSeq++, level, text: part });
}
if (this.lines.length > this.capacity) {
this.lines.splice(0, this.lines.length - this.capacity);
}
}
get count(): number {
return this.lines.length;
}
/** True when the viewport is pinned to the newest lines. */
get following(): boolean {
return this.offset === 0;
}
scrollUp(n: number): void {
this.offset = Math.min(this.lines.length, this.offset + Math.max(0, n));
}
scrollDown(n: number): void {
this.offset = Math.max(0, this.offset - Math.max(0, n));
}
/** Jump back to the tail (re-enable following). */
followBottom(): void {
this.offset = 0;
}
clear(): void {
this.lines = [];
this.offset = 0;
}
/** Slice of lines currently visible in a viewport `height` rows tall. */
view(height: number): LogView {
const rows = Math.max(1, height);
// At max scrollback (offset === length) the viewport pins to the oldest
// rows instead of going empty.
const end = Math.max(Math.min(rows, this.lines.length), this.lines.length - this.offset);
const start = Math.max(0, end - rows);
return {
lines: this.lines.slice(start, end),
total: this.lines.length,
fromBottom: this.lines.length - end,
};
}
}
|