Public: anyone can read this; the crew can change it.
A log file with terminal colours (.log, .ansi) renders in colour, with no escape codes shown #10335
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10330-a-log-file-with-terminal-colours-log-ans → main 7 commits fork 3 threads · 3 unresolved
11 files
changed +317
−11
docs/design/areas/render.md +9
| @@ -9,6 +9,15 @@ No script, no pdf.js. | ||
| 9 | 9 | |
| 10 | 10 | The header shows the size (`1.2 MB · PDF`) through `Rendered.summary`. Both themes use tokens only. |
| 11 | 11 | |
| 12 | ## Terminal log (.log, .ansi) | |
| 13 | ||
| 14 | `src/web/render/log.ts` draws a log that holds escape codes inside the existing `.card.code` table, so it looks like any code file. Colour comes only from fixed classes (`src/web/style/content.ts`), tokens only, both themes: | |
| 15 | ||
| 16 | - `.ansi-red` `--danger`, `.ansi-green` `--success`, `.ansi-yellow` `--attn`, `.ansi-blue` `--tok-number`, `.ansi-magenta` `--merged`, `.ansi-cyan` `--tok-property`, `.ansi-gray` (black) `--tok-comment`. White is the page's own ink. | |
| 17 | - `.ansi-b` bold, `.ansi-u` underline. | |
| 18 | ||
| 19 | Backgrounds, 256-colour above 15 and truecolour are dropped; so are cursor moves and OSC. A log with no escape code keeps the plain source view. | |
| 20 | ||
| 12 | 21 | ## Archive listing |
| 13 | 22 | |
| 14 | 23 | A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the existing `.table` alone, with no `.card` around it (path, size, modified; `data-dense`), and a `tfoot` for the count and unpacked size. No new class: `.archive-view` is only a hook on the table and has no rule. How it works: `docs/features/archive-listing.md`. A list that cannot be read falls back to the Download card with the reason as its one sentence. |
docs/design/attention-budget.json +8 −8
| @@ -13,25 +13,25 @@ | ||
| 13 | 13 | }, |
| 14 | 14 | "repository": { |
| 15 | 15 | "390": { |
| 16 | "words": 189, | |
| 17 | "blocks": 28, | |
| 16 | "words": 197, | |
| 17 | "blocks": 29, | |
| 18 | 18 | "colours": 2 |
| 19 | 19 | }, |
| 20 | 20 | "1280": { |
| 21 | "words": 189, | |
| 22 | "blocks": 28, | |
| 21 | "words": 197, | |
| 22 | "blocks": 29, | |
| 23 | 23 | "colours": 2 |
| 24 | 24 | } |
| 25 | 25 | }, |
| 26 | 26 | "app": { |
| 27 | 27 | "390": { |
| 28 | "words": 189, | |
| 29 | "blocks": 28, | |
| 28 | "words": 197, | |
| 29 | "blocks": 29, | |
| 30 | 30 | "colours": 2 |
| 31 | 31 | }, |
| 32 | 32 | "1280": { |
| 33 | "words": 189, | |
| 34 | "blocks": 28, | |
| 33 | "words": 197, | |
| 34 | "blocks": 29, | |
| 35 | 35 | "colours": 2 |
| 36 | 36 | } |
| 37 | 37 | }, |
docs/features/ansi-log.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Terminal logs in colour | |
| 2 | ||
| 3 | `.log` and `.ansi` files that contain terminal escapes open on the blob page in colour (`src/web/render/log.ts`, parser in `ansi.ts`). A log with no escape byte keeps the ordinary source view. | |
| 4 | Only SGR is read: foreground colour (30-37, 90-97, and 256-colour 0-15), bold, underline, and the resets 0, 22, 24, 39. Each becomes a fixed class (`ansi-red`, `ansi-b`, `ansi-u`, …) coloured by design tokens; the input never reaches a `style` attribute or a class name. Background, truecolour and 256-colour above 15 are dropped. | |
| 5 | Every other escape (cursor moves, OSC titles and links, an unterminated OSC up to its newline) and every other control character, a bare CR included, is removed, never acted on. A colour is closed at each line end and reopened on the next. | |
| 6 | Drawn as the code view is: first 1,500 lines, `?all=1` ("Show all lines", no JavaScript) up to 10,000, with an "N more lines" note. `?view=source`, Raw and Download show the bytes untouched. | |
| 7 | The terminal (SSH) file view shows a `.log`/`.ansi` as plain text: its escapes are stripped (`stripAnsi`, `src/tui/term/ansi.ts`) before the lines are built, so a log cannot move the cursor, set a title or print `[31m`. It has no colour there. The class list lives in `docs/design/areas/render.md` and `src/web/style/content.ts`. |
src/tui/screens/workspace.ts +10 −2
| @@ -54,6 +54,7 @@ import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; | ||
| 54 | 54 | import { CUE_CAP, parseSubtitles } from "../../web/render/subtitles.ts"; |
| 55 | 55 | import { layoutTable, type Grid } from "../ui/grid.ts"; |
| 56 | 56 | import type { Ctx, Screen } from "../app/app.ts"; |
| 57 | import { stripAnsi } from "../term/ansi.ts"; | |
| 57 | 58 | import { binding, type Binding } from "../app/keys.ts"; |
| 58 | 59 | import type { KeyEvent } from "../term/input.ts"; |
| 59 | 60 | import { |
| @@ -252,12 +253,19 @@ function landing(nodes: readonly Node[]): Node | undefined { | ||
| 252 | 253 | * spans: the pane draws line numbers itself, so they stay pinned while the |
| 253 | 254 | * code scrolls sideways under them. |
| 254 | 255 | */ |
| 255 | function buildLines( | |
| 256 | function isLogPath(path: string): boolean { | |
| 257 | return /\.(log|ansi)$/i.test(path); | |
| 258 | } | |
| 259 | ||
| 260 | export function buildLines( | |
| 256 | 261 | text: string, |
| 257 | 262 | path: string, |
| 258 | 263 | c: Tokens, |
| 259 | 264 | ): { plain: string[]; lines: Span[][] } { |
| 260 | const body = text.replace(/\n$/, ""); | |
| 265 | // A terminal log carries escapes; the viewer shows its text, never drives | |
| 266 | // the terminal or prints "[31m" as if it were the log. | |
| 267 | const clean = isLogPath(path) && text.includes("\x1b") ? stripAnsi(text) : text; | |
| 268 | const body = clean.replace(/\n$/, ""); | |
| 261 | 269 | const plain = body.split("\n").map((l) => expandTabs(l)); |
| 262 | 270 | // The first line is handed to the detector too: a `#!/usr/bin/env python3` |
| 263 | 271 | // with no extension, a `.zshrc`, a vim modeline. The name is the strong |
src/web/render/ansi.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * Terminal output as escaped HTML lines. Pure: text in, one string per line | |
| 3 | * out. Only SGR (`ESC [ … m`) is interpreted — foreground colour, bold and | |
| 4 | * underline — and each becomes a fixed class (`ansi-red`, `ansi-b`, | |
| 5 | * `ansi-u`); the input never reaches a `style` attribute or a class name. | |
| 6 | * Every other escape (cursor moves, OSC titles and hyperlinks, charset | |
| 7 | * selects) and every other control character is removed, never acted on. | |
| 8 | * Background, 256-colour (above 15) and truecolour requests are dropped. | |
| 9 | */ | |
| 10 | ||
| 11 | import { escapeHtml } from "../html.ts"; | |
| 12 | ||
| 13 | /** Colour class by SGR number (30-37; 90-97 share them). White is the default ink. */ | |
| 14 | const HUES: readonly (string | undefined)[] = [ | |
| 15 | "gray", // 0 black: invisible on a dark page, so it is the quiet ink | |
| 16 | "red", | |
| 17 | "green", | |
| 18 | "yellow", | |
| 19 | "blue", | |
| 20 | "magenta", | |
| 21 | "cyan", | |
| 22 | undefined, // 7 white: the page's own ink | |
| 23 | ]; | |
| 24 | ||
| 25 | interface Pen { | |
| 26 | hue: string | undefined; | |
| 27 | bold: boolean; | |
| 28 | underline: boolean; | |
| 29 | } | |
| 30 | ||
| 31 | const classOf = (pen: Pen): string => { | |
| 32 | const parts: string[] = []; | |
| 33 | if (pen.hue !== undefined) parts.push(`ansi-${pen.hue}`); | |
| 34 | if (pen.bold) parts.push("ansi-b"); | |
| 35 | if (pen.underline) parts.push("ansi-u"); | |
| 36 | return parts.join(" "); | |
| 37 | }; | |
| 38 | ||
| 39 | /** The pen after one SGR parameter list. */ | |
| 40 | function sgr(pen: Pen, params: string): void { | |
| 41 | const codes = params === "" ? [0] : params.split(/[;:]/).map((p) => (p === "" ? 0 : Number(p))); | |
| 42 | for (let i = 0; i < codes.length; i++) { | |
| 43 | const c = codes[i]!; | |
| 44 | if (!Number.isInteger(c)) continue; | |
| 45 | if (c === 0) { | |
| 46 | pen.hue = undefined; | |
| 47 | pen.bold = false; | |
| 48 | pen.underline = false; | |
| 49 | } else if (c === 1) pen.bold = true; | |
| 50 | else if (c === 22) pen.bold = false; | |
| 51 | else if (c === 4) pen.underline = true; | |
| 52 | else if (c === 24) pen.underline = false; | |
| 53 | else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) pen.hue = HUES[c % 10]; | |
| 54 | else if (c === 39) pen.hue = undefined; | |
| 55 | else if (c === 38 || c === 48) { | |
| 56 | const mode = codes[i + 1]; | |
| 57 | if (c === 38 && mode === 5) { | |
| 58 | const n = codes[i + 2]; | |
| 59 | if (n !== undefined && Number.isInteger(n) && n >= 0 && n <= 15) pen.hue = HUES[n % 8]; | |
| 60 | } | |
| 61 | i += mode === 5 ? 2 : mode === 2 ? 4 : 0; | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /** | |
| 67 | * OSC (to BEL or ST), CSI, any other escape pair, then a lone control | |
| 68 | * character that is neither a tab nor a newline (a bare CR included). | |
| 69 | */ | |
| 70 | const TOKEN = | |
| 71 | // eslint-disable-next-line no-control-regex | |
Item 2: |
||
| 72 | /\x1b\][^\x07\x1b\n]*(?:\x07|\x1b\\)?|\x1b\[([0-?]*)[ -/]*([@-~])?|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g; | |
| 73 | ||
| 74 | export interface AnsiLines { | |
| 75 | /** Escaped markup, one entry per line; a colour never spans two entries. */ | |
| 76 | readonly lines: string[]; | |
| 77 | /** Lines the whole text has, whether or not all were drawn. */ | |
| 78 | readonly total: number; | |
| 79 | } | |
| 80 | ||
| 81 | /** The first `limit` lines of `input` as markup, and the line count of all of it. */ | |
| 82 | export function ansiLines(input: string, limit: number): AnsiLines { | |
| 83 | const text = input.replace(/\n$/, ""); | |
| 84 | let total = 1; | |
| 85 | for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) total++; | |
| 86 | ||
| 87 | const pen: Pen = { hue: undefined, bold: false, underline: false }; | |
| 88 | const lines: string[] = []; | |
| 89 | let line = ""; | |
| 90 | const put = (chunk: string): void => { | |
| 91 | if (chunk === "") return; | |
| 92 | const cls = classOf(pen); | |
| 93 | line += cls === "" ? escapeHtml(chunk) : `<span class="${cls}">${escapeHtml(chunk)}</span>`; | |
| 94 | }; | |
| 95 | const feed = (chunk: string): void => { | |
| 96 | const parts = chunk.split("\n"); | |
| 97 | for (let i = 0; i < parts.length; i++) { | |
| 98 | if (i > 0) { | |
| 99 | lines.push(line); | |
| 100 | line = ""; | |
| 101 | } | |
| 102 | put(parts[i]!); | |
| 103 | } | |
| 104 | }; | |
| 105 | ||
| 106 | let at = 0; | |
| 107 | for (const m of text.matchAll(TOKEN)) { | |
| 108 | if (lines.length >= limit) break; | |
| 109 | feed(text.slice(at, m.index)); | |
| 110 | at = m.index + m[0].length; | |
| 111 | if (m[0].startsWith("\x1b[") && m[2] === "m" && /^[0-9;:]*$/.test(m[1]!)) sgr(pen, m[1]!); | |
| 112 | } | |
| 113 | if (lines.length < limit) { | |
| 114 | feed(text.slice(at)); | |
| 115 | lines.push(line); | |
| 116 | } | |
| 117 | return { lines: lines.slice(0, limit), total }; | |
| 118 | } |
src/web/render/kinds.ts +4
| @@ -23,6 +23,7 @@ export type FileKind = | ||
| 23 | 23 | | "archive" |
| 24 | 24 | | "config" |
| 25 | 25 | | "markdown" |
| 26 | | "log" | |
| 26 | 27 | | "subtitles" |
| 27 | 28 | | "text" |
| 28 | 29 | | "binary"; |
| @@ -45,6 +46,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 45 | 46 | archive: 50 * MB, |
| 46 | 47 | config: 1 * MB, |
| 47 | 48 | markdown: 1 * MB, |
| 49 | log: 1 * MB, | |
| 48 | 50 | subtitles: 5 * MB, |
| 49 | 51 | text: 1 * MB, |
| 50 | 52 | binary: 0, |
| @@ -138,6 +140,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 138 | 140 | md: "markdown", |
| 139 | 141 | markdown: "markdown", |
| 140 | 142 | mdx: "markdown", |
| 143 | log: "log", | |
| 144 | ansi: "log", | |
| 141 | 145 | srt: "subtitles", |
| 142 | 146 | vtt: "subtitles", |
| 143 | 147 | }; |
src/web/render/log.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** A terminal log (.log, .ansi) with its colours, laid out as the code view is. */ | |
| 2 | ||
| 3 | import { html, raw } from "../html.ts"; | |
| 4 | import { rowsMarkup } from "../code_rows.ts"; | |
| 5 | import { btn, empty } from "../ui.ts"; | |
| 6 | import { ansiLines } from "./ansi.ts"; | |
| 7 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 8 | ||
| 9 | /** The same windows the plain-text view draws: first lines, then up to the ceiling. */ | |
| 10 | const FIRST_LINES = 1_500; | |
| 11 | const MAX_LINES = 10_000; | |
| 12 | ||
| 13 | /** Whether a body holds an escape at all; one without is shown as plain text. */ | |
| 14 | export const hasEscape = (body: Uint8Array): boolean => body.includes(0x1b); | |
| 15 | ||
| 16 | export function log(input: RenderInput): Rendered { | |
| 17 | const { lines, total } = ansiLines(new TextDecoder().decode(input.body), input.all ? MAX_LINES : FIRST_LINES); | |
| 18 | const cut = total - lines.length; | |
| 19 | const href = input.pathHref ?? ""; | |
| 20 | return { | |
| 21 | lines: total, | |
| 22 | content: html`<div class="card code ansi"> | |
| 23 | <table> | |
| 24 | ${raw(rowsMarkup(lines.map(raw)))} | |
| 25 | </table> | |
| 26 | </div> | |
| 27 | ${cut > 0 | |
| 28 | ? empty({ | |
| 29 | icon: "file", | |
| 30 | title: `${cut.toLocaleString("en-US")} more lines`, | |
| 31 | note: input.all ? "The rest is in the raw file." : "The page draws the first lines so it opens at once.", | |
| 32 | action: input.all ? html`` : btn("Show all lines", { href: `${href}?all=1`, id: "code-cut", icon: "file" }), | |
| 33 | }) | |
| 34 | : html``}`, | |
| 35 | }; | |
| 36 | } |
src/web/render/renderers.ts +8 −1
| @@ -13,6 +13,7 @@ import { isFormConfig } from "./config-format.ts"; | ||
| 13 | 13 | import { csv } from "./csv.ts"; |
| 14 | 14 | import { image } from "./image.ts"; |
| 15 | 15 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 16 | import { log, hasEscape } from "./log.ts"; | |
| 16 | 17 | import { markdown } from "./markdown.ts"; |
| 17 | 18 | import { pdf } from "./pdf.ts"; |
| 18 | 19 | import { subtitles } from "./subtitles.ts"; |
| @@ -56,6 +57,8 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 56 | 57 | audio, |
| 57 | 58 | video, |
| 58 | 59 | config, |
| 60 | log, | |
| 61 | ||
Non-blocking: stray blank line inside the map; remove it. |
||
| 59 | 62 | subtitles, |
| 60 | 63 | archive, |
| 61 | 64 | }; |
| @@ -92,7 +95,11 @@ export function rendererFor( | ||
| 92 | 95 | if (blob.kind === "too_large" && !MEDIA_KINDS.has(kind)) return undefined; |
| 93 | 96 | // A kind may draw only some of its files: `config` draws JSON, YAML and |
| 94 | 97 | // TOML as a form, and leaves the rest (INI, .env …) their source. |
| 95 | const render = kind === "config" && !isFormConfig(path) ? undefined : renderers[kind]; | |
| 98 | // A log with no escape in it keeps the source view, exactly as plain text. | |
| 99 | const render = | |
| 100 | (kind === "config" && !isFormConfig(path)) || (kind === "log" && blob.kind !== "too_large" && !hasEscape(blob.body)) | |
| 101 | ? undefined | |
| 102 | : renderers[kind]; | |
| 96 | 103 | if (render === undefined) return undefined; |
| 97 | 104 | return { render, body: blob.kind === "too_large" ? NO_BYTES : blob.body }; |
| 98 | 105 | } |
src/web/style/content.ts +11
| @@ -414,6 +414,17 @@ form.inline { display: inline; margin-left: auto; } | ||
| 414 | 414 | .csv-view .num { text-align: right; } |
| 415 | 415 | .csv-more { padding: 10px 12px; margin: 0; } |
| 416 | 416 | |
| 417 | /* A terminal log (render/log.ts): SGR colours as fixed classes, theme tokens only. */ | |
| 418 | .ansi-red { color: var(--danger); } | |
| 419 | .ansi-green { color: var(--success); } | |
| 420 | .ansi-yellow { color: var(--attn); } | |
| 421 | .ansi-blue { color: var(--tok-number); } | |
| 422 | .ansi-magenta { color: var(--merged); } | |
| 423 | .ansi-cyan { color: var(--tok-property); } | |
| 424 | .ansi-gray { color: var(--tok-comment); } | |
| 425 | .ansi-b { font-weight: 700; } | |
| 426 | .ansi-u { text-decoration: underline; } | |
| 427 | ||
| 417 | 428 | /* ---- syntax tokens (colors come from the active theme) ---- |
| 418 | 429 | Two layers, exactly as the tokenizer emits them: a colour class every |
| 419 | 430 | consumer understands, and — where the web can say something finer — a kind |
test/render-ansi.test.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** Terminal colours in a .log: SGR becomes fixed classes, everything else is removed. */ | |
| 2 | ||
| 3 | import { describe, expect, test } from "bun:test"; | |
| 4 | import { ansiLines } from "../src/web/render/ansi.ts"; | |
| 5 | import { rendererFor, RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | import { log } from "../src/web/render/log.ts"; | |
| 7 | import { buildLines } from "../src/tui/screens/workspace.ts"; | |
| 8 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 9 | import type { Blob } from "../src/view/source.ts"; | |
| 10 | ||
| 11 | const one = (s: string): string => ansiLines(s, 100).lines.join("\n"); | |
| 12 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 13 | ||
| 14 | describe("ansiLines", () => { | |
| 15 | test("a red word is a red span; a reset ends it", () => { | |
| 16 | expect(one("a \x1b[31mfail\x1b[0m b")).toBe('a <span class="ansi-red">fail</span> b'); | |
| 17 | }); | |
| 18 | ||
| 19 | test("bold and underline are classes, and a colour carries across a newline", () => { | |
| 20 | expect(one("\x1b[1;4;32mok\nstill\x1b[m")).toBe( | |
| 21 | '<span class="ansi-green ansi-b ansi-u">ok</span>\n<span class="ansi-green ansi-b ansi-u">still</span>', | |
| 22 | ); | |
| 23 | }); | |
| 24 | ||
| 25 | test("an OSC 8 link and a title are stripped, the text stays", () => { | |
| 26 | expect(one("\x1b]8;;http://evil.test\x1b\\click\x1b]8;;\x1b\\ \x1b]0;title\x07x")).toBe("click x"); | |
| 27 | }); | |
| 28 | ||
| 29 | test("cursor moves and bare CR are removed, never interpreted", () => { | |
| 30 | expect(one("\x1b[2K\x1b[1Aa\rb\x1b[?25l")).toBe("ab"); | |
| 31 | }); | |
| 32 | ||
| 33 | test("<script> inside a colour is escaped", () => { | |
| 34 | const out = one("\x1b[31m<script>alert(1)</script>\x1b[0m"); | |
| 35 | expect(out).not.toContain("<script"); | |
| 36 | expect(out).toContain("<script>"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("no style attribute is ever emitted, even for truecolour", () => { | |
| 40 | const out = one('\x1b[38;2;1;2;3mx\x1b[48;5;9my\x1b[38;5;1mz'); | |
| 41 | expect(out).not.toContain("style"); | |
| 42 | expect(out).toBe('xy<span class="ansi-red">z</span>'); | |
| 43 | }); | |
| 44 | ||
| 45 | test("22, 24 and 39 end bold, underline and colour one at a time", () => { | |
| 46 | expect(one("\x1b[1;4;31ma\x1b[22mb\x1b[24mc\x1b[39md")).toBe( | |
| 47 | '<span class="ansi-red ansi-b ansi-u">a</span><span class="ansi-red ansi-u">b</span><span class="ansi-red">c</span>d', | |
| 48 | ); | |
| 49 | }); | |
| 50 | ||
| 51 | test("bright colours share the normal hues: 91 is red, 90 gray, 97 the page ink", () => { | |
| 52 | expect(one("\x1b[91mr\x1b[90mg\x1b[97mw")).toBe( | |
| 53 | '<span class="ansi-red">r</span><span class="ansi-gray">g</span>w', | |
| 54 | ); | |
| 55 | }); | |
| 56 | ||
| 57 | test("an unterminated OSC ends at the newline and the log text survives", () => { | |
| 58 | expect(one("a\x1b]0;title\nnext line\nthird")).toBe("a\nnext line\nthird"); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a file over the line limit is cut, and the total still counts", () => { | |
| 62 | const r = ansiLines("a\nb\nc\nd\n", 2); | |
| 63 | expect(r.lines).toEqual(["a", "b"]); | |
| 64 | expect(r.total).toBe(4); | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("the log kind", () => { | |
| 69 | const blob = (s: string): Blob => ({ kind: "text", body: enc(s), size: s.length }) as unknown as Blob; | |
| 70 | ||
| 71 | test("a .log or .ansi with escapes is drawn", () => { | |
| 72 | for (const p of ["build.log", "out.ansi"]) | |
| 73 | expect(rendererFor(p, blob("\x1b[31mx"))?.render).toBe(RENDERERS.log!); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a .log with no escape keeps the plain source view", () => { | |
| 77 | expect(rendererFor("build.log", blob("just text\n"))).toBeUndefined(); | |
| 78 | }); | |
| 79 | }); | |
| 80 | ||
| 81 | describe("log()", () => { | |
| 82 | const input = (n: number, all = false) => | |
| 83 | ({ body: enc(Array.from({ length: n }, (_, i) => `\x1b[31mline ${i}`).join("\n")), all, pathHref: "/o/r/blob/main/b.log" }) as never; | |
| 84 | ||
| 85 | test("a long log is cut with a count and a Show all lines link", () => { | |
| 86 | const r = log(input(1_600)); | |
| 87 | expect(r.lines).toBe(1_600); | |
| 88 | expect(String(r.content)).toContain("100 more lines"); | |
| 89 | expect(String(r.content)).toContain("Show all lines"); | |
| 90 | expect(String(r.content)).toContain("?all=1"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a short log has no cut, and ?all=1 drops the link", () => { | |
| 94 | expect(String(log(input(5)).content)).not.toContain("more lines"); | |
| 95 | expect(String(log(input(11_000, true)).content)).not.toContain("Show all lines"); | |
| 96 | }); | |
| 97 | }); | |
| 98 | ||
| 99 | describe("the terminal file view", () => { | |
| 100 | test("a .log's escapes are stripped; other files are left alone", () => { | |
| 101 | expect(buildLines("\x1b[31mred\x1b[0m\x1b]0;t\x07 ok\n", "b.log", theme("dark").c).plain).toEqual(["red ok"]); | |
| 102 | expect(buildLines("a\x1bb\n", "a.txt", theme("dark").c).plain[0]).toContain("\x1b"); | |
| 103 | }); | |
| 104 | }); |
test/render-kinds.test.ts +2
| @@ -55,6 +55,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 55 | 55 | md: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 56 | 56 | markdown: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 57 | 57 | mdx: [enc("# a\n"), "markdown", "application/octet-stream"], |
| 58 | log: [enc("ok\n"), "log", "application/octet-stream"], | |
| 59 | ansi: [enc("\x1b[31mok\n"), "log", "application/octet-stream"], | |
| 58 | 60 | srt: [enc("1\n00:00:01,000 --> 00:00:02,000\nHi\n"), "subtitles", "application/octet-stream"], |
| 59 | 61 | vtt: [enc("WEBVTT\n"), "subtitles", "application/octet-stream"], |
| 60 | 62 | }; |
Item 3: the 90-97 branch, 22, 24 and 39 have no test that fails without them.