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,
    };
  }
}