1import ArrowDown from "lucide-solid/icons/arrow-down";
2import { createMemo, type JSX, Show } from "solid-js";
3import type { LogLine } from "../types/model.ts";
4import { clock, count, date, datetime } from "../format.ts";
5import { VirtualList } from "./VirtualList.tsx";
6import "./LogView.css";
7
8/** SGR sequences capture their parameters; every other escape and control character is dropped. */
9const ESCAPE = /\x1b\[([0-9;]*)m|\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]?|[\x00-\x08\x0b-\x1f\x7f]/g;
10/** The eight base colors in theme terms; the bright eight reuse them. */
11const ANSI = ["var(--muted)", "var(--critical)", "var(--good)", "var(--warning)", "var(--series-1)", "var(--series-5)",
12 "var(--series-3)", "var(--text)"];
13
14function xterm(n: number) {
15 if (n < 16) return ANSI[n % 8]!;
16 if (n >= 232) return `rgb(${Array(3).fill(8 + (n - 232) * 10).join(" ")})`;
17 return `rgb(${[36, 6, 1].map((step) => Math.floor((n - 16) / step) % 6).map((c) => (c ? 55 + c * 40 : 0)).join(" ")})`;
18}
19
20function sgr(style: JSX.CSSProperties, params: string): JSX.CSSProperties {
21 const codes = (params || "0").split(";").map(Number);
22 const next = { ...style };
23 const color = (key: "--fg" | "--bg", value: string | undefined) => (value ? (next[key] = value) : delete next[key]);
24 for (let i = 0; i < codes.length; i++) {
25 const code = codes[i]!;
26 if (code === 0) for (const key in next) delete next[key as keyof JSX.CSSProperties];
27 else if (code === 1) next["font-weight"] = 600;
28 else if (code === 2) next.opacity = 0.65;
29 else if (code === 3) next["font-style"] = "italic";
30 else if (code === 4) next["text-decoration"] = "underline";
31 else if (code === 22) {
32 delete next["font-weight"];
33 delete next.opacity;
34 }
35 else if (code === 23) delete next["font-style"];
36 else if (code === 24) delete next["text-decoration"];
37 else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) color("--fg", ANSI[code % 10]);
38 else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) color("--bg", ANSI[code % 10]);
39 else if (code === 39 || code === 49) color(code === 39 ? "--fg" : "--bg", undefined);
40 else if (code === 38 || code === 48) {
41 const rgb = codes[i + 1] === 5 ? xterm(codes[i + 2]!) : `rgb(${codes.slice(i + 2, i + 5).join(" ")})`;
42 color(code === 38 ? "--fg" : "--bg", rgb);
43 i += codes[i + 1] === 5 ? 2 : 4;
44 }
45 }
46 return next;
47}
48
49/** A log line as a terminal shows it: colored, each line from its last carriage return; each of `needles` is marked. */
50function ansi(raw: string, needles: string[]) {
51 const text = raw.replace(/[^\n]*\r(?=[^\n])/g, "");
52 if (!needles.length && !/[\x00-\x08\x0b-\x1f\x7f]/.test(text)) return text;
53 const segments: { text: string; style: JSX.CSSProperties }[] = [];
54 let style: JSX.CSSProperties = {};
55 let last = 0;
56 for (const match of text.matchAll(ESCAPE)) {
57 if (match.index > last) segments.push({ text: text.slice(last, match.index), style });
58 if (match[1] !== undefined) style = sgr(style, match[1]);
59 last = match.index + match[0].length;
60 }
61 segments.push({ text: text.slice(last), style });
62 const plain = segments.map((segment) => segment.text).join("").toLowerCase();
63 const found: [number, number][] = [];
64 for (const needle of needles.map((needle) => needle.toLowerCase())) {
65 for (let at = plain.indexOf(needle); at !== -1; at = plain.indexOf(needle, at + needle.length)) found.push([at, at + needle.length]);
66 }
67 const marks: [number, number][] = [];
68 for (const [from, to] of found.sort((a, b) => a[0] - b[0])) {
69 const last = marks.at(-1);
70 if (last && from <= last[1]) last[1] = Math.max(last[1], to);
71 else marks.push([from, to]);
72 }
73 let end = 0;
74 return segments.map((segment) => {
75 const start = end;
76 end += segment.text.length;
77 const parts: JSX.Element[] = [];
78 let cut = start;
79 for (const [from, to] of marks) {
80 if (to <= cut || from >= end) continue;
81 if (from > cut) parts.push(segment.text.slice(cut - start, from - start));
82 cut = Math.min(to, end);
83 parts.push(<mark>{segment.text.slice(Math.max(from, start) - start, cut - start)}</mark>);
84 }
85 if (cut < end) parts.push(segment.text.slice(cut - start));
86 return Object.keys(segment.style).length ? <span class="ansi" style={segment.style}>{parts}</span> : parts;
87 });
88}
89
90/** Line height and character width of `.logs` text, for estimating how a line wraps before it renders. */
91const LINE = 18.6;
92const CHAR = 7.2;
93/** Everything in a log row beside the text: the gutters, the time column, and the gap after it. */
94const ROW_CHROME = 2 * 28 + 62 + 12;
95
96function estimate(line: LogLine, width: number) {
97 const columns = Math.max(16, Math.floor((width - ROW_CHROME) / CHAR));
98 let rows = 0;
99 for (const part of line.text.replace(ESCAPE, "").split("\n")) rows += Math.max(1, Math.ceil(part.length / columns));
100 return rows * LINE;
101}
102
103/**
104 * Log lines, newest at the bottom, as a terminal shows them: colored, soft-wrapped, split by day, `marks` marked, and
105 * named by container when several are mixed. Follows new lines while scrolled to the end; away from it, a button leads back.
106 */
107export function LogView(props: {
108 lines: LogLine[];
109 follow: boolean;
110 onFollow: (atEnd: boolean) => void;
111 marks?: string[];
112 /** Makes a line's container name a button, when several containers are mixed. */
113 onContainer?: (container: string) => void;
114 /** Called while the view is near the oldest line. */
115 onStart?: () => void;
116 /** Lines that arrived while away from the end, counted on the button back to it. */
117 unseen?: number;
118 /** What the button back to the end does, if more than following again. */
119 onLatest?: () => void;
120 /** Rows above a line, after its day divider. */
121 before?: (line: LogLine, index: () => number) => JSX.Element;
122}) {
123 const mixed = createMemo(() => new Set(props.lines.map((line) => line.container)).size > 1);
124 return (
125 <div class="log-view">
126 <VirtualList class="logs" label="Log lines" items={props.lines} estimate={estimate} follow={props.follow}
127 onFollow={props.onFollow} onStart={props.onStart}>
128 {(line, index) => (
129 <>
130 {/* Rows can outlive a swapped-in list for a tick, so the previous line may not exist yet. */}
131 <Show when={index() > 0 && props.lines[index() - 1] && date(props.lines[index() - 1]!.t) !== date(line.t)}>
132 <div class="log-edge day">{date(line.t)}</div>
133 </Show>
134 {props.before?.(line, index)}
135 <div class={`log ${line.level ?? ""}`}>
136 <time data-tip={datetime(line.t)}>{clock(line.t)}</time>
137 <span>
138 <Show when={mixed()}>
139 <Show when={props.onContainer} fallback={<span class="container">{line.container}</span>}>
140 {(filter) => (
141 <button class="container" data-tip={`only ${line.container}`} onClick={() => filter()(line.container)}>
142 {line.container}
143 </button>
144 )}
145 </Show>
146 </Show>
147 {ansi(line.text, props.marks ?? [])}
148 </span>
149 </div>
150 </>
151 )}
152 </VirtualList>
153 <Show when={!props.follow}>
154 <button class="button small latest" onClick={() => (props.onLatest ?? (() => props.onFollow(true)))()}>
155 <ArrowDown size={14} />{props.unseen ? `${count(props.unseen)} new` : "latest"}
156 </button>
157 </Show>
158 </div>
159 );
160}